Compare commits

..

No commits in common. "main" and "@moonshot-ai/kimi-code@0.13.0" have entirely different histories.

2864 changed files with 12138 additions and 463918 deletions

View file

@ -1,70 +0,0 @@
---
name: agent-core-dev
description: Use when developing in packages/agent-core-v2 (the DI × Scope agent engine) — adding or modifying a domain Service, choosing a LifecycleScope, wiring DI dependencies, splitting a domain across scopes, owning or migrating a config section, gating behavior behind an experimental flag, raising coded errors, working on the permission system, writing DI/Scope tests, porting business logic from agent-core (v1) to v2, triaging a main-branch commit against v2, or exposing a v2 domain over server-v2 while keeping the /api/v1 wire contract compatible with released clients. Self-contained guide organized by development stage (orient → design → implement → test → verify) plus align workflows for v1→v2 migration, main-branch commit triage, and server-v2 wire exposure; each file carries the rules, examples, and red lines for its step.
---
# agent-core-dev
> Develop `packages/agent-core-v2` by lifecycle stage. This skill is **self-contained**: every rule, recipe, and red line lives in the stage files below — it does not delegate to `packages/agent-core-v2/docs/`.
`agent-core-v2` is the new agent engine built on the **DI × Scope** architecture (a port of `packages/agent-core`). Everything resolves through the container: a service declares an **identity**, its **dependencies**, and a **lifetime**; the container decides construction, singleton-per-scope, ordering, and disposal. The stage files restate the rules in imperative form so you can work without reading the source docs.
## Lifecycle at a glance
```text
Orient → Design → Implement → Test → Verify
│ │ │ │ │
│ │ │ │ └─ lint:domain · typecheck · test · dep graph · red lines
│ │ │ └─ test.md
│ │ └─ implement.md (+ errors.md · flags.md · permission.md)
│ └─ design.md
└─ orient.md
```
Stages are ordered but not strictly linear: a test failure (stage 4) that reveals a wrong scope sends you back to design (stage 2); a `CyclicDependencyError` sends you to `design.md` §dependency-direction and `implement.md` §cycles.
## Workflows
End-to-end procedures that span the stages. Reach for these before reading the stage files individually.
- [Align (port `agent-core` → `agent-core-v2`)](align.md): split a v1 class into semantic units, fix each unit's domain / scope / Service / dependencies, then migrate the logic and tests. Use when the task is "move feature X from v1 to v2" or "port `IXxxService` to v2".
- [Commit align (triage a `main` commit against v2)](commit-align.md): given one `main` commit hash + a short note, find the v1 logic it changed, check whether v2 already has the corresponding implementation, bucket it (aligned / partial / missing / not-applicable), and recommend a minimal fix. Use in the `kimi-code-v2`-catching-up-to-`main` phase, for one commit at a time; escalate to [align.md](align.md) if the gap is a whole domain.
- [Server align (expose `agent-core-v2` over `server-v2`)](server-align.md): wire a v2 domain into `packages/kap-server` over `/api/v2` (native) and `/api/v1` (v1-compatible mirror), keep the wire schema byte-compatible with the established v1 contract by sharing the `@moonshot-ai/protocol` schema, and isolate v1-only behavior in a `<domain>Legacy` edge adapter instead of distorting the native v2 Service. Use when the task is "expose the new v2 Service on the server", "add a route to the `/api/v1` surface", or "keep server-v2 wire-compatible with released v1 clients".
## Stages
- [Stage 1 — Orient](orient.md): the DI black box (identity / dependencies / lifetime), the four `LifecycleScope` tiers and visibility, and the file-header comment convention. Read before touching business code.
- [Stage 2 — Design a service](design.md): pick a scope, split a domain across scopes, choose a calling style (direct call vs event vs hook), and direct dependencies. Decide *where things live and who knows whom* before coding.
- Topic: [Domain boundaries vs Scope](domain-boundaries.md) — keep `session` / `agent` / `turn` from becoming god objects; data-ownership test and their split conclusions.
- Topic: [Persistence layering](persistence.md) — the three-layer `Store → Storage → backend` model, naming Stores by access pattern, and which layer business code should depend on.
- Topic: [Edge exposure — `resource:action` + WS events](edge-exposure.md) — which Services are exposed over `/api/v2` (per-scope action map) and which events stream over WS; what to wrap in a facade.
- [Stage 3 — Implement](implement.md): the standard Service recipe and the DI building blocks — interface + identity, constructor injection, scoped registration, `Disposable`, eager vs delayed, `invokeFunction`, `createInstance`, child scopes, and the cycle-refactor playbook.
- Topic: [Service authoring](service-authoring.md) — file layout, naming, contract vs impl contents, interface style, constructor/field conventions, events, multi-Service domains, comment rules.
- Topic: [Config](config.md) — the section-registry model, App vs Session split, owning a config section, the TOML format, and the env overlay.
- Topic: [Errors](errors.md) — co-located `XxxError`, the central code registry, wire serialization, boundary translation.
- Topic: [Flags](flags.md) — `registerFlagDefinition`, `IFlagService.enabled(id)`, the `[experimental]` config section, resolution precedence.
- Topic: [Permission](permission.md) — composable chain-of-responsibility kernel, 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.
## How to use this skill
Jump to the stage you are in and read that one file; each is self-contained and ends with its own red lines. Skim the global red lines below before submitting — they catch most mistakes across every stage. The repo's source of truth remains the code in `packages/agent-core-v2/src/`; this skill codifies the same rules so you do not have to re-derive them.
## Global red lines
Invariants that hold across every stage. Each is expanded in the stage file noted.
1. No `new` on a class whose constructor carries `@IService` deps — inject with `@IX` or `accessor.get(IX)`. (implement.md)
2. `@IX` decorates constructor parameters only; parameter order depends on construction (static-first for `createInstance`, `@IX`-first for scoped services). (service-authoring.md)
3. Both interface and impl carry `_serviceBrand`; the `createDecorator` name is globally unique. (implement.md)
4. Parent scope never depends on child scope — short-lived may inject long-lived, never the reverse. (orient.md)
5. No cyclic dependencies — refactor (extract a third Service / use an event / re-scope); do not break the cycle with `Delayed`. (design.md, implement.md)
6. `ServicesAccessor` is valid only during `invokeFunction` — never stash it for async use. (implement.md)
7. Scope follows state identity — no `Map<sessionId, …>` at `App` to fake per-session state. (design.md)
8. Foundational layers never know upstream ones; business code never depends on the edge layer (`gateway`/`rpc`). (design.md)
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)

View file

@ -1,235 +0,0 @@
# Subskill — Align (port `agent-core``agent-core-v2`)
Port business logic from `packages/agent-core` (v1) into `packages/agent-core-v2` (v2) by **splitting semantics, then fixing the domain, scope, Service, and dependency relationships**, and finally migrating the logic and tests.
Use this when the task is "move feature X from v1 to v2", "port `IXxxService` to v2", or "align a v1 domain with the v2 architecture". It complements the stage files: orient / design / implement / test explain the *target* architecture; this file explains how to get there *from v1*.
## The one-paragraph mental model
v1 is a **VSCode-style singleton container**: services self-register with `registerSingleton`, resolve as singleton-per-container, and have no explicit lifetime tier — so a single `ISessionService` / `IToolService` tends to accumulate global, per-session, and per-agent state in one class. v2 is a **DI × Scope tree**: every service binds to one of `App` / `Session` / `Agent`, and a domain with state at several lifetimes is split into several Services. Porting is therefore **not** a file copy — it is "find each lifetime of state hiding in the v1 class, give each its own v2 Service at the right scope, then re-wire the dependencies".
## v1 → v2 at a glance
| Concern | v1 (`agent-core`) | v2 (`agent-core-v2`) |
|---|---|---|
| Registration | `registerSingleton(IX, X, InstantiationType.Delayed)` | `registerScopedService(LifecycleScope.X, IX, X, InstantiationType.Delayed, 'domain')` |
| DI import | `from '../../di'` | `from '#/_base/di/scope'` / `'#/_base/di/instantiation'` / `'#/_base/di/extensions'` / `'#/_base/di/lifecycle'` |
| Lifetime | implicit singleton-per-container | explicit `LifecycleScope` (App/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 |
| Scope tests | none | `createScopedTestHost` — see test.md |
| Errors | `from '../../errors'` (central `KimiError`, `ErrorCodes`) | `from '#/_base/errors'` + domain co-located `XxxError` — see errors.md |
| Flags | `flags/` (process-global `FlagResolver`) | `flag/` (App-scope `IFlagService`) — see flags.md |
| Permission | `agent/permission/` (hardcoded chain) | `permission*` (registry + composer) — see permission.md |
## The align workflow
```text
Read v1 → Semantic split → Map domain → Assign scope → Shape Services
→ Direct dependencies → Port logic → Port tests → Verify
```
Each step below states the goal and the concrete action, then points to the stage file that goes deeper. Do them in order; a later step often sends you back to an earlier one (a scope that does not fit means the semantic split was wrong).
### 1. Read v1
**Goal:** build an accurate inventory of what the v1 code actually owns. Read the v1 *source*, not v1 docs.
Actions:
- Locate the v1 entry: contract (`<domain>/<domain>.ts`) + impl (`<domain>/<domain>Service.ts`), plus any helpers under the same folder.
- Inventory three things from the impl:
- **State** — every field / `Map` / cache the class holds. For each, note its *identity* (global? keyed by `sessionId`? by `agentId`?).
- **Behavior** — every public method; group them by which state they touch.
- **Dependencies** — every `@IFoo` constructor injection and every cross-domain relative import (`from '../<other>/...'`).
- Note the v1 registration line (`registerSingleton(...)`) and any `services.set(IX, ...)` overrides at bootstrap (these reveal runtime static args or prebuilt instances the port must preserve).
Do not start splitting yet — an accurate inventory prevents the common mistake of porting the class shape instead of the semantics.
### 2. Semantic split
**Goal:** break one v1 class into independent semantic units, each owning state at exactly one lifetime. This is the heart of the port.
Method — for each piece of state from the inventory, ask:
1. **What is it keyed by?** nothing → a global unit; `sessionId` → a per-session unit; `agentId` → a per-agent unit.
2. **When should it die?** with the process / the session / the agent. State that must outlive its neighbors is a different unit.
3. **Which methods touch only this state?** they travel with the unit.
Worked example — v1 `ISessionService` (one class, ~600 lines) holds:
- a global index of all sessions → **global** unit → v2 `sessionStore` (`ISessionStore`, App);
- 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).
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.
Red lines:
- If two pieces of state have different identities, they belong in different units — do not keep them together "because v1 did".
- Do not split by method count or file aesthetics; split by state identity (design.md §3).
- If a unit has no mutable state (pure behavior), defer its scope decision to step 4 (it is pulled down by its shortest-lived dependency).
### 3. Map to v2 domain
**Goal:** assign each semantic unit to a v2 domain — an existing one if it fits, a new one only if none does.
Actions:
- Search v2 `src/` for an existing domain that owns the same responsibility. Prefer joining an existing domain over creating a new one.
- If creating a domain, name it after the responsibility (camelCase folder, e.g. `sessionActivity`), not after the v1 file.
- Keep a domain's public surface to one contract file (`<domain>.ts`) plus its impl(s).
Reference mapping (a **starting point**, not gospel — verify against the current v2 `src/`, which is the source of truth):
| v1 location | v2 domain(s) |
|---|---|
| `services/session/`, `session/` | `session`, `sessionStore`, `sessionMetaStore`, `sessionActivity`, `sessionContext`, `agentLifecycle` |
| `services/tool/`, `tools/`, `agent/tool/` | `toolRegistry`, `toolStore`, `toolExecutor`, `tooldedup`, `userTool` |
| `loop/`, `agent/` (turn loop) | `loop`, `llmRequester`, `llmRequestLog`, `turn` |
| `agent/context/`, `agent/compaction/` | `contextMemory`, `contextProjector`, `contextSize`, `fullCompaction`, `dynamicInjector` |
| `agent/permission/` | `permission`, `permissionMode`, `permissionPolicy`, `permissionRules`, `approval`, `externalHooks` |
| `agent/goal/`, `agent/plan/`, `agent/swarm/`, `agent/cron/`, `agent/background/` | `goal`, `plan`, `swarm`, `cron`, `background`, `subagentHost` |
| `services/config/`, `agent/config/` | `config` |
| `services/event/`, `base/common/event` | `event`, `eventBus` |
| `services/logger/`, `logging/` | `log` |
| `services/fileStore/` | `filestore`, `blobStore` |
| `services/fs/`, `services/workspace/` | `fs`, `workspace` |
| `services/auth/`, `services/oauth/` | `auth` |
| `services/environment/` | `environment` |
| `services/terminal/` | `terminal` |
| `services/question/`, `services/approval/` | `question`, `approval` |
| `services/prompt/`, `agent/injection/` | `prompt`, `dynamicInjector` |
| `services/mcp/`, `mcp/` | `mcp` |
| `plugin/`, `profile/`, `skill/` | `plugin`, `profile`, `skill` |
| `rpc/`, `services/coreProcess/` | `rpc`, `gateway` |
| `di/` | `_base/di` |
| `errors/`, `errors.ts` | `_base/errors` + co-located domain errors |
| `flags/` | `flag` |
| `telemetry.ts` | `telemetry` |
| `agent/records/` | (records split) — verify in v2 `src/` |
When the table says "verify", or when v1 and v2 have diverged, **read the v2 `src/` tree and decide from the code** — do not invent a mapping.
### 4. Assign scope
For each semantic unit, fix its `LifecycleScope` from the identity you found in step 2. Follow design.md §2 verbatim:
- global → `App`; per `sessionId``Session`; per `agentId``Agent`.
- Stateless unit → default to `App`, pulled down only by a shorter-lived dependency.
- Self-check: "when this scope is disposed, should this state disappear with it?"
This is the decision v1 never had to make — get it right before writing any v2 code, because the scope is fixed at registration and changing it later ripples through every consumer.
### 5. Shape Services
Decide the Service shape per unit, following design.md §3:
- A unit that owns **one instance's** state → a single per-instance Service (`ISessionXxx` / `IAgentXxx`).
- A unit that owns a **global view plus per-instance** state → split into an `App` registry/factory (`XxxStore` / `XxxRegistry` / `XxxCatalog`) **and** a per-instance Service. The `App` half creates or locates the per-instance half.
- Do not pre-split a unit that has state at only one lifetime.
Most consumers inject the per-instance Service; inject the `App` factory only for genuine cross-instance management.
### 6. Direct dependencies
Re-wire the dependencies you inventoried in step 1, now across the new v2 Services. Follow design.md §4§5:
- **Calling style** — need a result / I orchestrate → direct call (`@IX` injection); stating a fact → event; ordered participation that may veto → hook.
- **Scope direction** — a Service may inject only its own scope or an ancestor. If an `App` Service needs something from a `Session` Service, the dependency is backwards: re-scope or invert into an event.
- **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.
### 7. Port the business logic
Move the behavior into the shaped v2 Services, applying the mechanical conversions below. Follow implement.md for the recipe.
**Registration:**
```ts
// v1
import { InstantiationType, registerSingleton } from '../../di';
registerSingleton(IXxxService, XxxService, InstantiationType.Delayed);
// v2
import { InstantiationType } from '#/_base/di/extensions';
import { LifecycleScope, registerScopedService } from '#/_base/di/scope';
registerScopedService(LifecycleScope.Session, IXxxService, XxxService, InstantiationType.Delayed, 'xxx');
```
**Imports:**
```ts
// v1
import { createDecorator, Disposable, IInstantiationService } from '../../di';
import { KimiError, ErrorCodes } from '../../errors';
// v2
import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation';
import { Disposable } from '#/_base/di/lifecycle';
import { IInstantiationService } from '#/_base/di/instantiation';
import { KimiError, type ErrorCode } from '#/_base/errors';
```
**Constructor injection** — unchanged in shape (`@IX` on constructor params, service params after static params). Verify each dependency is resolvable from the new scope (step 6).
**Errors** — move any shared error into a co-located `XxxError extends KimiError` with a registered `code` (errors.md). Do not keep throwing v1's central error codes from a v2 domain.
**Flags** — replace any `FlagResolver` / env check with `IFlagService.enabled(id)`; contribute new flags from the owning domain's `flag.ts` via `registerFlagDefinition` (flags.md).
**Events** — v1's `Emitter` / `Event` from `base/common/event` maps to v2's `event` / `eventBus` domains. Read existing v2 usage in neighboring domains and match it; do not import v1's `Emitter`.
**Runtime static args / prebuilt instances** — if v1 bootstrap did `services.set(IX, new SyncDescriptor(C, [bag]))` or set a prebuilt instance, preserve that behavior at the v2 composition root (the scope that owns the Service). Do not silently drop it.
Red lines:
- Do not copy a v1 file and "fix imports". Re-split first (steps 26); a straight copy carries v1's implicit-singleton assumptions into v2 and creates the `Map<sessionId, …>`-at-`App` anti-pattern.
- Do not leave v1 relative imports (`from '../x/...'`) in v2 — use the `#/...` alias and respect the domain layers.
- 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
Convert v1 tests to the v2 harness, following test.md:
```ts
// v1
import { TestInstantiationService } from '@moonshot-ai/agent-core/di/test';
const svc = ix.createInstance(XxxService, 'static-arg');
// v2
import { createServices } from '#/_base/di/test';
// in additionalServices:
reg.define(IXxxService, XxxService);
// in the test body:
const svc = ix.get(IXxxService);
```
- Resolve the SUT by interface (`ix.get(IX)`), never `new` a `@IService`-carrying impl, and prefer `ix.get(IX)` over `ix.createInstance(Impl)`.
- Move shared stubs into `test/<domain>/stubs.ts`; import by relative path, never `#/...`.
- If the port introduced scope-layer behavior, add a `createScopedTestHost` test that asserts resolution from the correct scope (with `_clearScopedRegistryForTests()` + explicit re-registration in `beforeEach`).
- Keep v1's behavioral assertions where they still describe observable behavior; delete assertions that only checked v1's internal class shape.
## Migration checklist
Before submitting a port:
- [ ] Every piece of v1 state landed in a v2 Service whose scope matches its identity (no `Map<sessionId, …>` at `App`).
- [ ] Each v1 dependency now points in the right scope and domain direction; `lint:domain` 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`.
- [ ] Tests resolve the SUT by interface; scope behavior is asserted via `createScopedTestHost`; teardown goes through one `DisposableStore`.
- [ ] v1 bootstrap overrides (`services.set(...)`) are preserved at the v2 composition root.
## Red lines (this subskill)
- Porting is semantic splitting, not file copying — never preserve a v1 class shape in v2.
- Decide scope from state identity before writing v2 code; the scope is fixed at registration.
- Verify the domain mapping against current v2 `src/`; the table here is a starting point, not authority.
- One Service owns state at exactly one lifetime; split global-view + per-instance into registry + per-instance.
- A dependency cycle introduced by the port means a v1 import is now backwards — refactor, do not route around it with `Delayed`.

View file

@ -1,155 +0,0 @@
# Topic — Close vs Dispose
How to shut down a scoped service in `agent-core-v2`: when `dispose()` is enough, when to add an async `close()`, and where cancellation / abort belongs. Read this before putting business shutdown logic into a `Disposable`.
## The one-sentence rule
> **`close()` is async business shutdown; `dispose()` is synchronous resource cleanup.**
`close()` finishes a domain's work: stop in-flight operations, apply shutdown policy, flush persistence, release async resources. `dispose()` releases object resources: event subscriptions, timers, hook registrations, and child disposables.
## Why they must stay separate
`IDisposable.dispose()` is synchronous:
```ts
export interface IDisposable {
dispose(): void;
}
```
The container calls it during scope teardown. Disposal order is deterministic (orient.md): child scopes first, then reverse construction order within a scope. Nothing awaits a Promise returned from `dispose()`.
Business shutdown is usually async. It may need to:
- stop in-flight tasks and wait for settlement;
- decide policy (`kill` vs `keepAliveOnExit` vs `markLost`);
- flush write queues and persistence;
- emit final records / events / telemetry;
- close sockets, child processes, or external clients.
If that logic lives in `dispose()`, it becomes fire-and-forget: the scope keeps tearing down, dependencies may be disposed immediately afterward, and the async continuation can run against a half-dead object graph.
## What `close()` owns
Add `close(): Promise<void>` when a service owns async shutdown work:
```ts
export interface IXxxService {
readonly _serviceBrand: undefined;
close(reason?: string): Promise<void>;
}
```
A good `close()`:
- is idempotent — repeated calls return the same Promise or no-op;
- is called by lifecycle code **before** `scope.dispose()`;
- rejects new work after it starts;
- applies shutdown policy explicitly;
- awaits the work it starts;
- leaves `dispose()` with only synchronous cleanup.
Sketch:
```ts
class XxxService extends Disposable implements IXxxService {
declare readonly _serviceBrand: undefined;
private closed = false;
async close(reason = 'scope closed'): Promise<void> {
if (this.closed) return;
this.closed = true;
await this.stopInFlightWork(reason);
await this.flushPersistence();
}
override dispose(): void {
this.closed = true;
// synchronous cleanup only: clear timers, remove listeners, release handles.
super.dispose();
}
}
```
`flush()` is different from `close()`: `flush()` persists buffered state while the service stays open; `close()` is terminal.
## What `dispose()` owns
`dispose()` releases resources owned by the object instance:
```ts
class WSBroadcastService extends Disposable implements IWSBroadcastService {
declare readonly _serviceBrand: undefined;
constructor(@IEventService event: IEventService) {
super();
this._register(event.subscribe(() => { /* … */ }));
}
}
```
Use `dispose()` to:
- `_register(...)` event subscriptions and hook registrations;
- clear timers;
- remove signal listeners;
- dispose child `IDisposable`s;
- detach from synchronous handles.
`dispose()` must be idempotent and should avoid throwing. If `close()` was already called, `dispose()` should be a no-op for business work and only clean resources.
## Where abort / cancellation belongs
Cancellation is not the same thing as graceful shutdown.
For an operation-scoped object, a cancellation trigger can be disposed:
```ts
const tokenSource = new CancellationTokenSource();
store.add(toDisposable(() => tokenSource.cancel()));
```
This is fine when the contract is **fire-and-forget cancel**: the operation observes the token and settles asynchronously; disposal does not wait for completion.
For a manager/service that owns many tasks and their state, do not use `dispose()` as the graceful abort path. Expose `stop()` / `stopAll()` / `close()` and let lifecycle code await the one it needs.
Background-specific rule: a `background`-style service may use `AbortController` internally to propagate cancellation to process / agent / question tasks, but manager shutdown belongs in `close()` or explicit `stopAll()`. `dispose()` may best-effort abort controllers only as a safety net; it must not be the mechanism that decides terminal status, persistence, or notifications.
## Decision tree
```text
What does the service own?
├─ only event subscriptions / timers / disposable handles?
│ └─ extend Disposable; no close() needed.
├─ async work, in-flight tasks, persistence buffers, sockets, child processes?
│ └─ add close(): Promise<void>; call it before scope.dispose().
├─ a single operation that callers may cancel?
│ └─ expose an AbortSignal / CancellationToken or a fire-and-forget cancel handle.
└─ both async shutdown and disposable resources?
└─ close() for business shutdown; dispose() for resource cleanup.
```
## VSCode parallel
VSCode uses the same split:
- `src/vs/base/common/lifecycle.ts``IDisposable.dispose(): void` for synchronous cleanup.
- `src/vs/base/parts/storage/common/storage.ts``close(): Promise<void>` flushes and closes the database.
- `src/vs/base/common/cancellation.ts``CancellationTokenSource.dispose(true)` / `cancelOnDispose()` cancels operation-scoped work without awaiting it.
The lesson is not "never cancel in dispose". It is: **disposal may trigger cancellation for a scoped operation, but service shutdown policy stays in an explicit async close path.**
## Red lines (this topic)
- Do not put business shutdown in `dispose()``dispose()` is synchronous and is not awaited.
- Do not `await` inside `dispose()`.
- Do not rely on `dispose()` to flush persistence, emit final events, wait for tasks, or send notifications.
- Add `close(): Promise<void>` for async shutdown and call it before `scope.dispose()`.
- Keep `close()` and `dispose()` idempotent; `dispose()` after `close()` must be safe.
- Use disposal as a cancellation trigger only for operation-scoped work, not as a manager/service shutdown policy.

View file

@ -1,78 +0,0 @@
# Subskill — Commit align (triage a `main` commit against v2)
Context: you are on the `kimi-code-v2` branch, in the phase of catching it up to **new commits that landed on `main`**. Those commits change `packages/agent-core` (v1); the job is to decide, for one commit at a time, whether v2 (`packages/agent-core-v2`) already has the corresponding logic — and if not, what the minimal fix is.
Use this when the user hands you **one commit hash plus a short description** ("look at `<commit>` — it fixed the steering race"). It is the small, per-commit sibling of [align.md](align.md): `align.md` ports a whole v1 domain into v2; this file triages a single `main` commit and says *port / adapt / skip*. If the triage reveals a whole missing domain, stop and switch to [align.md](align.md).
## The one-paragraph mental model
A `main` commit edits v1's singleton-container code. The same behavior in v2 lives behind a scoped Service, so a commit lands in one of four buckets: **already-aligned** (v2 has it, possibly by construction), **partial** (v2 has a nearby version whose semantics drift), **missing** (v2 has nothing), or **not-applicable** (the v2 architecture removed the very problem the commit fixes). Your output is a bucket assignment plus evidence, then a fix sized to that bucket — never a blind port of the diff.
## The workflow
```text
Read the commit + the user's note → Locate the v1 logic → Map to a v2 domain
→ Check v2 for a corresponding implementation → Bucket it → Recommend a fix → Verify
```
### 1. Read the commit and the note
**Goal:** know exactly what changed in v1 and *why*. The user's one-liner gives the intent; the diff gives the facts.
Actions:
- Inspect the change scoped to v1: `git show <commit> -- packages/agent-core` (and `--stat` first to see the blast radius).
- From the diff, list: touched files, changed functions/methods, and the observable behavior delta (before → after).
- Reconcile with the user's note: is this a bugfix, a semantic correction, new behavior, or a refactor? The *why* decides whether v2 even needs the change.
Do not skim the user's sentence and guess — the diff is the spec for what "aligned" means here.
### 2. Locate the v1 logic
Pin the change to a v1 place: the contract (`<domain>/<domain>.ts`) + impl (`<domain>/<domain>Service.ts`), or the helper/handler the commit touched. Note which state it reads/writes and which other v1 services it calls — this is the same inventory as [align.md](align.md) §1, scoped to the commit's footprint.
### 3. Map to a v2 domain
Use the v1 → v2 domain table in [align.md](align.md) §3 as a starting point, then **verify against the current `packages/agent-core-v2/src/` tree** — it is the source of truth. Identify the candidate v2 Service(s) that would own this behavior, and their `LifecycleScope`.
### 4. Check v2 and assign a bucket
Search the candidate domain in v2 (Grep the method name, the state field, the error code). For each piece of the commit's behavior delta, decide:
- **Already-aligned** — v2 produces the same observable result (sometimes for free, because the v2 design never had the bug). Cite the v2 file:line.
- **Partial** — v2 has a near miss: same method, different guard/ordering/error; or the state lives at a different scope. Name the exact drift.
- **Missing** — no v2 Service owns this behavior. Confirm it is a single-Service gap, not a whole-domain gap (latter → [align.md](align.md)).
- **Not-applicable** — the v2 architecture removed the condition the commit fixes (e.g. the scope tree already serializes what v1 patched with a lock). Explain why, so a reviewer trusts the skip.
Every claim needs a citation (`path:line`) on both sides; "I couldn't find it" is a finding only after you name where you looked.
### 5. Recommend a fix (sized to the bucket)
- **Already-aligned** — say so and stop; reference the v2 location. No code change.
- **Partial** — propose the smallest edit that closes the drift: which Service, which method, which guard. Stay inside v2 rules — scope/domain direction, no `Map<sessionId, …>` at `App` (see [align.md](align.md) §6§7 red lines).
- **Missing** — sketch the port at commit granularity: target domain + scope, the Service/method to add or extend, the dependency direction, and which [align.md](align.md) §7 conversions apply (registration, `#/…` imports, co-located coded error, `IFlagService` for any gate). If it needs a new scope or a wire change, flag it.
- **Not-applicable** — recommend no v2 change, but call out any test worth adding so the gap stays closed.
Keep the recommendation to the commit's footprint. If it keeps growing, that is the signal to hand off to [align.md](align.md) for a full domain port.
### 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.
## Output shape
When triaging, answer in this order so the user can act on it directly:
1. **Commit + intent** — one line restating what the commit changed and why (from the note + diff).
2. **v1 location** — file(s) and the behavior delta.
3. **v2 status** — one of the four buckets, with `path:line` evidence on both sides.
4. **Recommendation** — the concrete fix (or the justified skip), scoped to the commit; name the target Service / scope / dependency direction.
5. **Verify** — which checks should pass, and whether to escalate to [align.md](align.md).
## Red lines (this subskill)
- Read the diff and the note before judging v2; never infer "aligned" from the description alone.
- Do not copy a v1 diff into v2. Decide the bucket first; a bugfix commit often maps to **not-applicable** because the v2 design already removed the defect.
- Cite `path:line` on both sides. A recommendation without evidence is a guess.
- Stay in the commit's footprint. Growing scope means "switch to [align.md](align.md)", not "keep porting here".
- Do not break v2 invariants to chase v1 parity — scope direction, domain direction, and no `Map<sessionId, …>` at `App` still hold ([align.md](align.md) red lines).

View file

@ -1,269 +0,0 @@
# Topic — Config
How the `config` domain works and how a domain owns its configuration section. Covers the section-registry model, the App vs Session split, the TOML on-disk format, and the recipe for adding or migrating a config section.
The `config` domain is a thin registry + loader: it does **not** know the shape of any individual section. Each domain owns the schema (and, where needed, the TOML transform) for the config it consumes, registers the section into `IConfigRegistry`, and reads it through `IConfigService`. There is no whole-config object passed around.
## What belongs in Config
`IConfigService` is the **preference registry**: it holds values a user or
operator *chooses*, each with a schema and a default, that *can* be persisted to
`config.toml`. It is not a grab-bag for every value a domain needs. Before
registering a section, classify the value along three axes — **decision-maker**,
**preference vs fact**, **mutability / persistence**:
| Type | Decision-maker | Preference/Fact | Persisted? | Examples | Home |
|---|---|---|---|---|---|
| User preference | user | preference | ✅ config.toml | model, theme, log level | **Config** |
| Operational override | operator/deployer | preference | ❌ env / flag | `KIMI_MODEL_*`, `KIMI_LOG_*` | **Config** (env overlay) |
| Per-run intent | invoker | preference | ❌ ephemeral | CLI `--model`, `--config` | **Config** (Memory layer) |
| Host fact | host | fact | ❌ | platform, CI, proxy, home dir | **Bootstrap** |
| Derived convention | code | fact (derived) | ❌ | `configPath`, `logsDir` | **Bootstrap / code** |
| Session runtime state | session/agent | state | ✅ session meta | active model, plan mode | **Session scope** |
| Tuning constant | developer | preference | ❌ compile-time | retry backoffs, buffer sizes | **code** |
A value belongs in Config **iff** it satisfies all of:
1. **Preference** — a choice among valid values, not an observed fact.
2. **Persistable** — it *can* be written to `config.toml`, even when a given
value arrives via env or CLI.
3. **Schema + default** — registerable as a section with validation.
4. **User- or operator-facing** — meaningful to set as a preference.
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.
- **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`.
- **Tuning constant** (retry config, buffer sizes) → domain code; promote to
Config only when it becomes user-tunable.
**`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.
Any value that belongs to a specific domain — including env-only operational
toggles (`KIMI_CRON_*`, `KIMI_CODE_EXPERIMENTAL_*`), model parameters, or feature
flags — goes through **Config registration**: the owning domain registers a
section with a declarative `envBindings` map (and a `stripEnv` when the value must
not be persisted) and reads it via `config.get(...)`. Each config value declares
an optional env binding (`{ field: 'ENV_VAR' }`, with optional `parse`/`default`);
IConfig resolves each field by `env > config.toml > default` automatically. This
keeps every domain's config in one registry and keeps Bootstrap free of upstream
knowledge.
Operational env overrides and per-run intent live *inside* Config as layers over
the same persistable key: `model` can be set in `config.toml`, via `KIMI_MODEL_*`,
or via CLI `--model`. They are not separate abstractions — see "Reads vs writes"
and "Layered resolution" below.
Env access is encapsulated: business domains read `config.get(...)` or structured
`IBootstrapService` facts; only the `config` domain reads the raw env bag (from
`IBootstrapService`) to build its overlays. Business domains must not call
`IBootstrapService.getEnv()` directly.
## Layered resolution
`IConfigService` resolves a key by precedence across layers, lowest to highest:
```text
Default registered defaultValue (and code constants promoted to a section)
User config.toml (persisted user preferences)
Operational env overlay (e.g. KIMI_MODEL_*, KIMI_CODE_EXPERIMENTAL_*)
Memory per-run intent (CLI flags); never persisted; highest
```
`set(domain, patch, target?)` writes the `User` layer (persisted) by default;
pass `ConfigTarget.Memory` for a per-run override that is never written to disk.
`inspect(domain)` reports the value at each layer.
## Layout
- `src/config/config.ts``IConfigRegistry` / `IConfigService` tokens, `ConfigSection`, `ConfigEffectiveOverlay`, event types.
- `src/config/configService.ts``ConfigRegistry` + `ConfigService` impl; self-registers at App scope.
- `src/config/toml.ts` — generic snake_case ↔ camelCase machinery plus the registry-aware `transformTomlData` / `applySectionToToml` entry points. Per-domain normalization lives in the section owner's `configSection.ts` (registered as `fromToml` / `toToml`); this module stays free of any other domain's semantics.
- `src/profile/thinking.ts` (owner domain, not `config`) — the `resolveThinkingEffort` helper; uses the authoritative `ThinkingConfig` from `configSection.ts`.
- `src/config/configPure.ts``isPlainObject`, `deepMerge`, `omitUndefined`, `describeUnknownError`.
A domain that owns a section keeps the schema in its own `configSection.ts` (e.g. `src/flag/flag.ts` for `experimental`, `src/profile/configSection.ts` for `thinking`, `src/loop/configSection.ts` for `loopControl`). A cross-section env overlay (e.g. the `KIMI_MODEL_*` synthesis) lives in the owning domain too (`src/provider/envOverlay.ts`) and is registered via `IConfigRegistry.registerEffectiveOverlay`.
## Scope
- `IConfigRegistry` / `IConfigService`**App** scope, process-global. One registry of sections; one loader reading `~/.kimi-code/config.toml` (path from `IBootstrapService.configPath`).
All config reads go through `IConfigService` (global config). Per-session runtime state (active model, thinking level, etc.) lives in the owning Session-scoped service (e.g. `IProfileService`), not in `config`.
## The section-registry model
A config section is identified by a camelCase domain key (`'providers'`, `'thinking'`, `'loopControl'`). Each section has:
- `schema?: ConfigSchema<T>` — zod schema used to validate the value (absent ⇒ passthrough).
- `defaultValue?: T` — filled when the file has no value for the domain.
- `merge?: ConfigMerge<T>` — how `set(domain, patch)` combines base + patch (default `deepMerge`).
- `fromToml?: ConfigFromToml` — read-path transform (snake_case file value → in-memory shape). Defaults to a plain key-casing pass; owners register one when the on-disk shape needs custom normalization (record key preservation, nested object conversion, array entries, key renames, reshapes).
- `toToml?: ConfigToToml` — write-path transform (in-memory value → snake_case file value). Defaults to a plain camelCase→snake_case key mapping.
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.
- **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
A section can declare how its fields are read from environment variables, so the
value resolves through `config.get(...)` rather than ad-hoc `process.env` reads.
Declare the bindings with `envBindings(schema, { … })` — the field names are
type-checked against the schema (no magic strings), and nested schemas recurse:
```ts
registerSection('thinking', ThinkingConfigSchema, {
env: envBindings(ThinkingConfigSchema, {
effort: 'KIMI_MODEL_THINKING_EFFORT',
}),
});
// nested / record section — outer key is a runtime constant, inner fields are
// checked against the value schema:
registerSection('providers', ProvidersSectionSchema, {
env: envBindings(ProvidersSectionSchema, {
[ENV_MODEL_PROVIDER_KEY]: envBindings(ProviderConfigSchema, {
apiKey: 'KIMI_MODEL_API_KEY',
type: 'KIMI_MODEL_PROVIDER_TYPE',
baseUrl:'KIMI_MODEL_BASE_URL',
}),
}),
stripEnv: stripProvidersEnv,
});
```
Each field is an `EnvBinding` — a string (env var name) or
`{ env, parse?, default? }`. IConfig resolves every field by
`env > config.toml > default`, sets it on the effective value, and validates the
section. Empty nested entries (no field resolved) are omitted, so a synthetic
entry like `__kimi_env__` only appears when at least one of its env vars is set.
`stripEnv(value, rawSnake?)` removes env-derived fields before `set`/`replace`
persists, so env overrides never leak into `config.toml`.
Business domains read `config.get('section')`; they never read env directly, and
never write their own env-merge logic.
## Add a config section (recipe)
1. Define the schema in the owning domain, e.g. `src/<domain>/configSection.ts`:
```ts
export const MY_SECTION = 'mySection';
export const MySectionSchema = z.object({ /* ... */ });
export type MySection = z.infer<typeof MySectionSchema>;
```
2. In the domain's service constructor, inject `IConfigRegistry` and register:
```ts
constructor(@IConfigRegistry registry: IConfigRegistry) {
registry.registerSection(MY_SECTION, MySectionSchema, { defaultValue: {} });
}
```
Pick a service whose scope matches when the config is first needed. Registering from an Agent-scope service is fine — see "Late registration".
3. Read it anywhere via `IConfigService`:
```ts
constructor(@IConfigService private readonly config: IConfigService) {}
// ...
const value = this.config.get<MySection>(MY_SECTION);
```
4. React to edits by subscribing `IConfigService.onDidChange` and filtering on `e.domain === MY_SECTION` (see `FlagService`).
5. Write it only through `IConfigService.set(domain, patch)` (merge) or `.replace(domain, value)` (wholesale). Never write `config.toml` directly.
## Reads vs writes
Data flow is one-way by default — reading config never touches the file:
```text
config.toml ──load──▶ IConfigService.effective ──get──▶ services read
▲ │
└──────── IConfigService.set/replace ◀──── only on explicit writes
```
- **Read path** (startup, every service): `config.toml` is loaded into `IConfigService` once; services read via `get()`. This path **never writes the file**.
- **Write path** (rare): `config.toml` is rewritten only when something explicitly calls `IConfigService.set/replace`. The only production writers today are provider CRUD (`ProviderService.set/delete`, e.g. provisioning a provider after OAuth login).
**Runtime service state is not config.** Mutating a service at runtime does **not** rewrite `config.toml`:
- `ProfileService.configure(...)` / `update(...)` / `setModel(...)` / `setThinking(...)` only change **in-memory** fields and append to the session **wireRecord** (for replay). They never call `IConfigService.set`.
- Switching model or thinking level mid-session is session runtime state, not a config edit — the user's `config.toml` is left untouched.
So `configure(...)` never overwrites the local file. Treat `config.toml` as the user's static config; runtime overrides live in memory and the session record.
## Late registration
`ConfigService` loads in its constructor (first `get(IConfigService)`). Domain services that register sections may be constructed later (especially Agent-scope services). To keep validation and defaults correct:
- `IConfigRegistry` emits `onDidRegisterSection` whenever a section is registered.
- `ConfigService` subscribes and, on registration, re-validates the already-loaded raw value for that domain, applies the default if the raw value is absent, re-runs the env overlay, and fires `onDidChange` if the effective value changed.
- Before a section is registered, `get(domain)` returns the raw (transformed, unvalidated) value; consumers that need validated values should read after the owning service is constructed, or react to `onDidChange`.
This means registration order is never a correctness concern — you do not need an eager bootstrap.
## TOML on-disk format
`config.toml` stores keys in **snake_case**; in-memory values are **camelCase**. `ConfigService` converts both ways by dispatching to each section's registered transform:
- **Read**: `transformTomlData(fileData, registry)` maps each top-level key to a domain and applies that domain's `fromToml` hook (or a plain key-casing pass when none is registered). Owner domains register their own normalization — e.g. provider `oauth`/`env`/`customHeaders`, permission `deny/allow/ask``rules`, `loop_control.max_steps_per_run``maxStepsPerTurn`, `experimental` keys preserved verbatim. When a section registers after the initial load, `ConfigService` re-applies its `fromToml` against the preserved snake_case raw value (see "Late registration"), so registration order is never a correctness concern.
- **Write**: `applySectionToToml(rawSnake, domain, value, registry)` applies the domain's `toToml` hook (or a plain camelCase→snake_case mapping) into a raw clone of the file, preserving unknown top-level keys and unknown sub-fields (lossless round-trip).
`ConfigService` keeps three views:
- `rawSnake` — snake_case clone of the file; the write base, never carries the env overlay.
- `raw` — camelCase, env-free; the read/set/replace base.
- `effective` — validated `raw` plus the env overlay; what `get()` returns.
### `KIMI_MODEL_*` env overlay
When `KIMI_MODEL_NAME` is set, the `provider` domain's `kimiModelEnvOverlay` (`src/provider/envOverlay.ts`) injects a reserved model alias (`__kimi_env_model__`) into `effective`, points `defaultModel` at it, and merges the request `modelOverrides`; the reserved provider (`__kimi_env__`) comes from the `providers` section env bindings. The overlay is registered via `IConfigRegistry.registerEffectiveOverlay` and applied **only to `effective`**, never to `rawSnake`, so it is never persisted. Its `strip` (plus the providers section `stripEnv`) is the final guard so a caller that read `effective` (with the overlay) cannot write the reserved entries or the shell API key back to disk. `config` itself only runs registered overlays — it does not know the `KIMI_MODEL_*` semantics.
## Owner-owned sections
`config` holds no monolithic config schema and no whole-config object. Every section is owned by the domain that consumes it: the schema (and any `fromToml` / `toToml` normalization and `stripEnv`) lives in that domain's `configSection.ts`, and the domain registers it via `IConfigRegistry.registerSection`. Cross-section env behavior (e.g. `KIMI_MODEL_*`) lives in an owner-registered `ConfigEffectiveOverlay`. To add a section, follow "Add a config section" above in the owning domain — never add schema or normalization to `config` itself.
## Ownership map (current)
| Section | Owner | Layer | Status |
|---|---|---|---|
| `providers` | `provider` | L2 | owner-owned (`IProviderService` CRUD) |
| `experimental` | `flag` | L3 | owner-owned |
| `thinking` | `profile` | L4 | owner-owned |
| `loopControl` | `loop` | L4 | owner-owned (read by `loop` + `profile`) |
| `McpServerConfig` (type) | `mcp` | L5 | owner-owned (type only; not a registered section) |
| `session` | `config` | L2 | in config |
| `models` / `defaultModel` / `defaultProvider` | `kosong` | L1 | owner-owned (read by `ProviderManager`) |
| `hooks` | `externalHooks` | L4 | owner-owned |
| `permission` | `permissionRules` | L3 | owner-owned |
| `background` | `background` | L5 | owner-owned |
`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
- `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.
- `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 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(...)`.
- 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.
- Never persist env overlays (`__kimi_env__` / `__kimi_env_model__` / shell API key / experimental env); overlays live only in `effective` / `Memory`.
- Registering from an Agent-scope service is fine — the late-registration mechanism keeps validation correct; do not add an eager bootstrap.

View file

@ -1,285 +0,0 @@
# Stage 2 — Design a service
Decide *where things live and who knows whom* before writing code. Every rule here derives from two questions:
1. **What is the identity of the state it owns?** → decides the **Scope**.
2. **Who owns the decision, and who needs the result?** → decides the **calling style** and **dependency direction**.
## 1. What a Service is
A Service = a bundle of **state** + a set of **behaviors**, bound to a **lifetime**.
- **Behavior** is almost free — the same logic runs anywhere, so it does not by itself decide a scope.
- **State** pins a Service to a scope. State has an **identity** (what it is keyed by) and a **lifetime** (when it is born, when it dies).
- **Dependencies / calling style** answer a different question: who controls whom, and who knows whom.
## 2. Choosing a scope
> Scope = the identity + lifetime of the owned state.
| Scope | State identity (keyed by) | Lifetime |
|---|---|---|
| `App` | none (single global instance) | the process |
| `Session` | `sessionId` | one session |
| `Agent` | `agentId` | one agent |
### Decision tree
**Q1. Does it own mutable state?**
- No (pure behavior) → jump to Q3.
- Yes → Q2.
**Q2. What is the identity of that state?**
- one global instance → **`App`**
- one per session → **`Session`**
- one per agent → **`Agent`**
- a mix (a global registry *and* per-instance state) → **split it** (see §3).
**Q3 (stateless). What is the shortest-lived dependency it must inject?**
A stateless Service is pulled *down* by its shortest-lived dependency: if it injects an `Agent`-scoped Service, it cannot be `App`. Among the scopes that still satisfy every dependency, **default to the longest-lived one** (usually `App`) to maximize reuse. Push it down only when it must inject a shorter-lived Service, or when you want to limit its visibility.
### The core anti-pattern (a litmus test)
> **Do not store per-session state in a `Map<sessionId, …>` inside an `App` Service.**
This is the tell-tale sign of "should have been `Session`-scoped but was parked at `App`". Consequences: nobody cleans the entry up when the session ends (leak); every consumer threads `sessionId` around (loss of type safety); it cannot inject `Session`/`Agent`-scoped collaborators.
### One-sentence self-check
> "When this scope is disposed, should this state disappear with it?"
>
> - Yes → the scope is right.
> - It must outlive the scope → too short; move up one tier.
> - It should be one-per-unit but is shared → too long; move down one tier.
## Scope is not a domain
Scope answers **lifetime and visibility**. Domain answers **responsibility and data ownership**. A Service registered at `Session` or `Agent` scope is not automatically part of the `session` or `agent` domain, and an entity Service must not be named `I{Scope}EntityService` just because its data is scoped that way.
Use the data-ownership test and the `session` / `agent` / `turn` split conclusions in [domain-boundaries.md](domain-boundaries.md) before naming a Service or adding `I{Domain}EntityService`.
## 3. Multi-Scope splitting
> One Service owns state at exactly one identity / lifetime. If a domain owns state at several lifetimes, split it along those boundaries — one Service per lifetime.
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` |
Canonical splits in the codebase:
- **`records`** — `ISessionStore` (`App`) + `ISessionMetaStore` (`Session`) + `IAgentRecords` (`Agent`).
- **`config`** — `IConfigRegistry` / `IConfigService` (`App`).
- **`kosong`** — `IProtocolHandlerRegistry` (`App`) + `IProviderManager` (`Session`). Generation is driven by `ILLMRequester` (`Agent`) in the `llmRequester` domain.
- **`tool`** — `IToolDefinitionRegistry` (`App`) + `IToolService` (`Agent`).
Split when the domain genuinely has both a global view and per-instance state. Do **not** split when state lives at only one lifetime (e.g. purely `App` like `log`; purely `Agent` like `prompt`). Do not pre-split for symmetry.
After the split, the `App` Service usually plays the **factory**; most consumers inject the **per-instance** Service. Inject the `App` factory only when you genuinely need cross-instance management.
## 4. Choosing a calling style
Three mechanisms answer three different questions:
| Mechanism | Nature | Coupling | Returns a value? | Consumers |
|---|---|---|---|---|
| **Direct call** | command: A tells B to do | A → B | yes | one (known) |
| **Event** | fact: A announces "X happened" | both depend only on the bus | no | zero / one / many (unknown) |
| **Hook** (`onWill` / `onDid`, `OrderedHookSlot`) | participation: observers step into an operation, in order | both depend only on the bus | can observe / veto | many, but ordered |
### Decision tree
**Q1. Does A need a return value from B?** → Yes: **direct call**. Events cannot return a value (request/reply over events is an anti-pattern).
**Q2. Is B's reaction part of A's responsibility, or B's own concern?**
- A's responsibility *includes* B's behavior (A orchestrates B) → **direct call**. E.g. `session` drives `agentLifecycle`; `loop` drives `llmRequester` / `toolExecutor`.
- B's reaction is B's own concern, A merely states a fact → **event**. E.g. `flag` reacts to `config.onDidChange`.
**Q3. How many consumers?**
- exactly one, known → **direct call**.
- zero / one / many, producer should not know → **event**.
**Q4. Would a direct A→B call create a cycle or violate scope direction?** → A *consequence check*, not a primary reason. Decide by Q1Q3 first; do not turn a genuine direct call into an event just to break a cycle.
**Q5. Is this fact part of the durable record / replay / cross-agent projection?** → Yes: **emit it on the wire** (`wireRecord`). State changes that must be recorded, replayed, or synchronized across agents are projected onto the wire, not handled by a direct call alone (`permission.set_mode`, `goal.create/update/clear`, `plan_mode.enter/exit`). The wire is the *durable record*, not the live notification channel.
### One-sentence rule
> "I am telling you to do this, and I may need the result" → **direct call.**
> "I am announcing that something happened; react if you care" → **event.**
> "I am announcing something, and you may step in, in order, possibly to veto" → **hook.**
### As extension points (open-closed)
The three mechanisms above are also where a domain accepts new behavior without being edited. When adding a scenario would otherwise require changing this domain's `if/else`, expose the right extension point instead:
| Need | Extension point | Typical scope |
|---|---|---|
| Register a new implementation / definition | a **registry / catalog** the domain queries | `App` |
| React to a fact the domain announces | an **event** on the bus | the announcing scope |
| Step into an operation in order / veto | a **hook** (`onWill`/`onDid`, `OrderedHookSlot`) | the owning scope |
| Swap a backend (File ↔ DB ↔ S3) | a **Store / Storage token** at the byte layer (see persistence.md) | `App` (composition root) |
Closed-for-modification means: the domain's own file is not where new scenarios branch. If a new scenario forces an edit here, an extension point is missing or misplaced.
## 5. Dependency direction
Two layers are involved:
- **Scope direction**: short-lived → long-lived, **enforced by the container** (see orient.md).
- **Domain direction**: which domain may depend on which — **a matter of judgment**, not enforced by the container.
> **A depends on B iff A needs B's data or behavior to do its own job.**
Add one anti-rot heuristic to keep the graph from collapsing into a clique:
> **Do not let a more foundational / more-reused Service come to know a more specific / more-upstream one.**
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
`agent-core-v2` is stratified into eight dependency layers, **L0L7** (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`.
The tiers, from lowest to highest:
- **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).
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.
- 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.
> When a Service is meant to be reached over the wire (`/api/v2`, WS), see [edge-exposure.md](edge-exposure.md) for the per-scope `resource:action` map, which Services may be exposed directly vs wrapped in a facade, and how events stream.
## 6. New-Service checklist
1. **What does it remember, and what is the state's identity?** → pick the scope (§2).
2. **What is the shortest-lived dependency it must inject?** → the scope cannot be longer than that.
3. **Does it own state at both a global and a per-instance lifetime?** → if yes, split Multi-Scope (§3).
4. **For each collaborator: am I commanding it, notifying it, or letting it participate?** → pick the calling style (§4).
5. **Does each dependency arrow make a more foundational thing know a more specific thing?** → if yes, invert it (§5).
## 7. Render the placement tree
After the checklist, render the result as a plaintext tree — the deliverable reviewers read. Keep it in the design doc or PR description.
```text
domain: `<name>` (owning scope: <Scope>)
├─ serves (who uses me) tag = HOW they reach me
│ ├─ (inject) <ConsumerDomain> @<Scope><what they use me for>
│ └─ (accessor) <ConsumerDomain> @<Scope><what they use me for>
├─ exposes (interfaces I provide, by scope)
│ ├─ App : <IXxxRegistry><role>
│ ├─ Session : <ISessionXxx><role>
│ └─ Agent : <IAgentXxx><role>
└─ depends (what I inject) tag = calling style
└─ <DepDomain> @<Scope> direct/event/hook — <what for>
```
Conventions:
- List **only real interfaces**; write `—` for a scope with no exposed interface. Most domains are single-scope — do not invent symmetry.
- On `depends`, tag each arrow with its calling style: `direct`, `event`, or `hook`.
- On `serves`, tag each consumer with its **access mechanism**, grouped `inject` first then `accessor`:
- `inject` — a descendant or peer scope DI-injects me. Resolved by the container; lifetime-safe.
- `accessor` — an ancestor or edge scope borrows me through `IScopeHandle.accessor.get(...)`. Valid only while this scope lives; never cache the result; must run before the child scope is disposed. See the cross-scope borrow diagram below.
- An empty `(inject)` group with a non-empty `(accessor)` group is a signal: the interface is currently an edge / lifecycle command surface — check it is not leaking internals.
- A consumer is upstream of you. If you cannot name one business consumer, the domain may be dead or mis-scoped.
### Cross-scope borrow diagram
When a domain has `accessor` consumers, draw the reverse-direction borrow next to the tree so it is never mistaken for injection:
```text
App scope
<AncestorService> ──holds──► IScopeHandle(<id>)
│ accessor.get(<IMyService>)
│ └── resolve runs inside the child scope
<Child> scope (<id>)
<MyService> ← the interface lives here
```
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`:
```text
domain: `sessionLifecycle` (owning scope: App)
├─ serves (who uses me)
│ ├─ (inject) — (none yet)
│ └─ (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
│ ├─ 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
├─ workspaceRegistry @App direct — resolves a session's workspace
└─ 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
```
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`.
For a multi-scope split, the `exposes` block fills more than one scope — see the `records` pattern in §3.
## Red lines (this stage)
- Scope is not a domain; ownership follows write authority and invariants, not read consumption.
- Do not create `I{Scope}EntityService` bundles (`IAgentEntityService`, `ISessionEntityService`) that re-merge multiple domains.
- No `Map<sessionId, …>` at `App` to fake per-session state.
- Scope follows state identity; stateless Services are pulled down by their shortest-lived dependency, otherwise default to `App`.
- Do not pre-split a domain that has state at only one lifetime.
- Need a result / I orchestrate → direct call; stating a fact → event; ordered participation / may veto → hook.
- Foundational layers never know upstream ones; business code never depends on the edge layer.
- A cycle means knowledge is placed backwards — refactor, do not route around it.
- Render the placement tree with real interfaces only — never pad an empty scope for symmetry.
- Tag `serves` consumers with `inject` / `accessor`; an empty `inject` group is a signal to check the interface is not leaking internals.
- An `accessor` consumer is a runtime borrow across a scope boundary, not DI injection — never cache the result and finish before the child scope disposes.
- A `serves` list with no business consumer (or only edge consumers) signals a dead or leaking interface.

View file

@ -1,203 +0,0 @@
# Topic — Domain boundaries vs Scope
How to keep `agent-core-v2` from recreating a god object after splitting one. Read this before naming a Service, adding an `I{Domain}EntityService`, or deciding whether data belongs to `session`, `agent`, or `turn`.
## The one-sentence rule
> **Scope is a lifetime and visibility boundary; a domain is a responsibility and data-ownership boundary.**
A Service registered at `LifecycleScope.Session` or `LifecycleScope.Agent` is **not automatically in the `session` or `agent` domain**. Scope says when an instance is born, when it dies, and who can see it. Domain says which business responsibility it owns and which data it is allowed to mutate.
## Definitions
| Term | Meaning |
|---|---|
| **Scope** | Lifetime / visibility tier. Current code registers Services at `App`, `Session`, or `Agent`. |
| **Domain** | A cohesive business responsibility with its own model, invariants, and write authority. |
| **Entity** | Data with identity and lifecycle, usually suitable for `get/list/create/update/delete` semantics. |
| **Aggregate** | A consistency boundary: the owner that enforces invariants over a cluster of data. |
| **Read model / projection** | Derived data built for queries; it may be shaped like a domain, but it is not the write authority. |
| **Runtime state** | Ephemeral data that dies with its scope; it should not be forced into an entity store. |
## The data-ownership test
Do not ask "does Session / Agent / Turn use this data?". Most data is used by several of them. Ask these instead:
1. **What is the data's identity?** `sessionId`, `agentId`, `turnId`, `taskId`, `workspaceId`, `providerName`, or something else?
2. **Who is the only writer?** The writer is usually the owner. Readers and projectors are not owners.
3. **Who enforces the invariants?** The domain that decides valid transitions owns the model.
4. **What is the authoritative source?** Atomic document, append-log / event stream, blob, query projection, config, or runtime memory?
5. **Can it be named without `Session` / `Agent` / `Turn`?** If yes, it probably deserves its own domain.
Examples:
- `PermissionRules` are Agent-scoped, but `permission` owns rule changes and evaluation.
- `BackgroundTask` is spawned by an Agent, but `background` owns task state and output.
- `ContextMessage` is consumed by the Agent loop, but `contextMemory` / `wireRecord` owns history and replay.
- `SessionMeta` is about a Session, but it is owned by `sessionMetadata`, not by a broad `session` data bag.
## Persistence models are not all entity CRUD
Before introducing `I{Domain}EntityService`, classify the persistence model:
| Persistence model | Use when | Examples |
|---|---|---|
| **Atomic document** | One typed document per key | `SessionMeta`, `config.toml` |
| **Append-log / event-sourced** | The authoritative record is "what happened" | `wireRecord`, `contextMemory`, `goal`, `plan`, `permission` transitions |
| **Blob / key-value** | Large or content-addressed bytes | media offload, blob store |
| **Indexed query / read model** | Derived, queryable view | `sessionIndex`, future `IQueryStore` projections |
| **Registry / catalog** | Global or scoped known items | `workspaceRegistry`, `toolRegistry` |
| **Ephemeral runtime state** | No durable entity | active turn handle, pending interactions, terminal handles |
See [persistence.md](persistence.md) for the `Store → Storage → backend` rules. A domain EntityService is a business facade over those stores; it is not a replacement for the store layer.
## Naming consequence
Do not name Services after a scope or a god-object-shaped concept:
- ❌ `IAgentEntityService`
- ❌ `IAgentDataService`
- ❌ `ISessionEntityService`
- ❌ `ITurnEntityService` that bundles context, tools, permissions, and telemetry
Name Services after the real owning domain:
- ✅ `ISessionMetadata`
- ✅ `ISessionIndex`
- ✅ `IAgentLifecycleService`
- ✅ `ITurnService`
- ✅ `IBackgroundTaskEntityService`
- ✅ `ICronTaskEntityService`
- ✅ `IPermissionRulesService`
`Session` and `Agent` are valid scope names. They are usually **not** good data-owner names.
## Split conclusion — `session`
`session` is both a Scope and a narrow Domain. Keep the Domain small.
The `session` domain owns only Session-level identity, metadata, lifecycle commands, and Session-level read views:
| Concern | Owner | Notes |
|---|---|---|
| `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 |
| 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 |
`session` must not reabsorb these:
| Data | Real owner |
|---|---|
| Agent instances / handles | `agentLifecycle` |
| Turns | `turn` |
| Context messages | `contextMemory` / `wireRecord` |
| Tool state | `toolStore` / `tool` |
| Permission rules / mode | `permission` |
| Profile / model | `profile` |
| Goal / Plan | `goal` / `plan` |
| Background tasks | `background` |
| Cron tasks | `cron` |
| Pending approvals / questions | `interaction` / `approval` / `question` |
| Workspace | `workspaceRegistry` |
| Provider / config | `provider` / `config` |
Entity-service conclusion for `session`:
- ✅ `ISessionMetadata` is already an entity-document Service.
- ✅ `ISessionIndex` is a query/read-model Service.
- ❌ Do not create a broad `ISessionEntityService` that owns agents, turns, records, interactions, logs, workspace, and config.
## Split conclusion — `agent`
`agent` is primarily a Scope and composition boundary, not a large data Domain.
Strictly, the `agent` domain owns only Agent-instance concerns:
| Concern | Owner | Notes |
|---|---|---|
| Agent instance identity / handle | `agentLifecycle` | Owns live Agent scope handles |
| Agent creation / removal | `agentLifecycle` | Lifecycle, not a data bag |
| Parent / child relationship | `session` / `agentLifecycle` depending on current code | Do not duplicate it into a new Agent data service |
| Active turn reference | `turn` | Turn is its own domain even though it is Agent-scoped |
Many Agent-scoped Services are **not** in the `agent` domain:
| Data / capability | Real owner | Persistence model |
|---|---|---|
| Wire records | `wireRecord` | Append-log |
| Context messages | `contextMemory` | Event-sourced through `wireRecord` |
| Profile / model config | `profile` | Config + wire records |
| Tool definitions / registry | `toolRegistry` | Runtime registry |
| Tool mutable state | `toolStore` | Wire records |
| Permission mode / rules | `permissionMode` / `permissionRules` | Wire records + config |
| Goal | `goal` | Wire records |
| Plan | `plan` | Wire records + plan file |
| Skill activation | `skill` | Wire records |
| Background tasks | `background` | Task records / output logs, candidate for entity service |
| Cron tasks | `cron` | Task records, candidate for entity service |
Entity-service conclusion for `agent`:
- ✅ Keep `IAgentLifecycleService` for Agent instance lifecycle.
- ✅ If a persisted Agent identity registry is ever needed, name it after that narrow concern, e.g. `IAgentInstanceRegistry`.
- ❌ Do not create `IAgentEntityService` or `IAgentDataService` that bundles profile, records, tools, permission, goal, plan, background, cron, and turn.
## Split conclusion — `turn`
`turn` is a Domain, but it is **not** currently a separate `LifecycleScope` in code; `ITurnService` is registered at `Agent` scope.
`turn` owns one execution round's runtime state and turn-level facts:
| Concern | Owner | Notes |
|---|---|---|
| Active `Turn` handle | `turn` | `id`, `abortController`, `ready`, `result` |
| Turn id allocation | `turn` | Restored from `turn.prompt` records and `context.append_loop_event` turn ids |
| Turn lifecycle hooks | `turn` | `onLaunched`, `onEnded`, `beforeStep`, `afterStep` |
| `turn.started` / `turn.ended` live events | `turn` | Live event stream |
`turn` must not own these:
| Data / capability | Real owner |
|---|---|
| Prompt and context messages | `contextMemory` |
| Append-only record log mechanics | `wireRecord` |
| Step loop | `loop` |
| Tool execution | `toolExecutor` / `tool` |
| Permission decisions | `permission` |
| External hook policy | `externalHooks` |
| Telemetry pipeline | `telemetry` |
| Event transport | `eventSink` |
Entity-service conclusion for `turn`:
- ✅ Keep `ITurnService` as a runtime orchestrator.
- ✅ Add a Turn read model / projection only if history queries are needed.
- ❌ Do not create `ITurnEntityService` with `create/update/delete/list` over a turn table as the authoritative model.
## Migration recipe
When moving data out of a v1 god object or reviewing a proposed EntityService:
1. **Name the data without using `Session`, `Agent`, or `Turn`.** If you cannot, the domain is probably unclear.
2. **Find the writer.** The exclusive writer is the likely owner.
3. **Find the invariant.** The Service that rejects invalid transitions owns the model.
4. **Classify the persistence model.** Atomic document, append-log, blob, query projection, registry, or runtime-only.
5. **Pick the Service shape.**
- Entity document / record → `I{Domain}EntityService` or domain-specific CRUD Service.
- Event-sourced → behavior Service + `wireRecord` record types + optional projection.
- Derived query → read-model Service, not a write authority.
- Runtime-only → scoped Service with no entity store.
6. **Choose the Scope by state identity.** Scope follows what the state is keyed by; it does not decide the domain name.
7. **Render the placement tree** from [design.md §7](design.md#7-render-the-placement-tree).
## Red lines (this topic)
- Scope is not a domain. `Session` / `Agent` scopes do not make data `session` / `agent` owned.
- Ownership follows write authority and invariants, not read consumption.
- Do not create `I{Scope}EntityService` bundles (`IAgentEntityService`, `ISessionEntityService`, `ITurnEntityService`) that re-merge multiple domains.
- Event-sourced domains keep behavior Services and append-log records; do not replace them with arbitrary CRUD.
- Read models may be shaped like a domain, but they are projections, not write authorities.
- A dependency is not ownership. A Service may inject another domain without owning that domain's data.

View file

@ -1,181 +0,0 @@
# Edge exposure — `resource:action` + WS events
How a domain's Services become the wire surface (`/api/v2`) and WebSocket events. This is a **design-time** decision: which Services are exposed, under what public `resource:action` name, and which events stream.
The transport (`/api/v2` over HTTP + WS) lives in the **edge** layer (`gateway`/`rpc`/`transport`). It borrows business Services by interface; business code never imports it.
## 1. The edge model
Three scopes, three URL shapes, one dispatcher:
```text
GET|POST /api/v2/:sa Core
GET|POST /api/v2/session/:session_id/:sa Session
GET|POST /api/v2/session/:session_id/agent/:agent_id/:sa Agent
```
`:sa` is a single path segment of the form `<resource>:<action>` (e.g.
`sessions:list`, `session:read`, `profile:getModel`).
- `:resource` is a **public** name (`sessions`, `session`, `profile`), never an internal domain token (`ISessionMetadata`).
- `:action` is the method. `GET` for reads, `POST` for writes.
- Body = the method's single argument (JSON), omitted for no-arg.
- Response = the project envelope `{ code, msg, data, request_id, details? }`.
- The dispatcher resolves the **scope** from the URL, the **Service** from an `actionMap`, calls the method, wraps the result.
```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' }, ... },
};
```
The `actionMap` is the single allowlist: only mapped `resource:action` pairs are callable; unknown → `40001`.
## 2. What may be exposed directly
A Service method is directly exposable iff **all** hold:
1. Args are JSON-serializable (no live objects, `AbortSignal`, callbacks, resumer fns).
2. Return is JSON-serializable data or `void` (no `IScopeHandle`, `Turn`, `IProcess`, `AsyncIterable`, `IDisposable`, `Event`).
3. Errors are `KimiError` (coded).
4. It is a command/query, not a factory, stream, byte-store, or sink.
If any fail → wrap in a **facade** (a Service that takes ids, returns data, throws `KimiError`) and expose the facade. The repo already ships a wire-shaped facade in `rpc/core-api.ts` (`CoreAPI` / `SessionAPI` / `AgentAPI`) behind `IAgentRPCService` / `ISessionRPCService` — prefer building the HTTP edge on top of it rather than re-deriving a new one.
## 3. Per-scope `resource:action` map
Read = `GET`, write = `POST`. `sid` = `session_id`, `aid` = `agent_id`.
### Core (`/api/v2/:resource:action`)
| resource | action | Service.method | verb |
|---|---|---|---|
| `sessions` | `list` | ISessionIndex.list | GET |
| `sessions` | `get` | ISessionIndex.get | GET |
| `sessions` | `countActive` | ISessionIndex.countActive | GET |
| `workspaces` | `list` | IWorkspaceRegistry.list | GET |
| `workspaces` | `get` | IWorkspaceRegistry.get | GET |
| `workspaces` | `createOrTouch` | IWorkspaceRegistry.createOrTouch | POST |
| `workspaces` | `update` | IWorkspaceRegistry.update | POST |
| `workspaces` | `delete` | IWorkspaceRegistry.delete | POST |
| `config` | `get` / `getAll` / `inspect` | IConfigService.* | GET |
| `config` | `set` / `replace` / `reload` | IConfigService.* | POST |
| `providers` | `list` / `get` | IProviderService.* | GET |
| `providers` | `set` / `delete` | IProviderService.* | POST |
| `oauth` | `startLogin` / `cancelLogin` / `logout` | IOAuthService.* | POST |
| `oauth` | `getFlow` / `status` | IOAuthService.* | GET |
| `auth` | `summarize` | IAuthSummaryService.summarize | GET |
| `auth` | `ensureReady` | IAuthSummaryService.ensureReady | POST |
| `flags` | `snapshot` / `enabled` / `explain` / `explainAll` | IFlagService.* | GET |
| `fs` | `browse` / `home` | IHostFolderBrowser.* | GET |
| `meta` | `getEnv` / `detect` | IBootstrapService.* | GET |
### Session (`/api/v2/session/:sid/:resource:action`)
| resource | action | Service.method | verb |
|---|---|---|---|
| `session` | `read` | ISessionMetadata.read | GET |
| `session` | `update` | ISessionMetadata.update | POST |
| `session` | `setTitle` | ISessionMetadata.setTitle | POST |
| `session` | `setArchived` | ISessionMetadata.setArchived | POST |
| `session` | `status` | ISessionActivity.status | GET |
| `session` | `isIdle` | ISessionActivity.isIdle | GET |
| `session` | `archive` | ISessionLifecycleService.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 |
### Agent (`/api/v2/session/:sid/agent/:aid/:resource:action`)
| resource | action | Service.method | verb |
|---|---|---|---|
| `goal` | `get` | IGoalService.getGoal | GET |
| `goal` | `create` / `pause` / `resume` / `cancel` | IGoalService.* | POST |
| `plan` | `status` | IPlanService.status | GET |
| `plan` | `enter` / `exit` / `cancel` / `clear` | IPlanService.* | POST |
| `tasks` | `list` / `get` / `readOutput` | IBackgroundService.* | GET |
| `tasks` | `stop` / `detach` | IBackgroundService.* | POST |
| `usage` | `status` | IUsageService.status | GET |
| `context` | `status` | IAgentContextSizeService.get | GET |
| `swarm` | `isActive` | ISwarmService.isActive | GET |
| `swarm` | `enter` / `exit` | ISwarmService.* | POST |
| `permission` | `getMode` | IPermissionModeService.mode | GET |
| `permission` | `setMode` | IPermissionModeService.setMode | POST |
| `permissionRules` | `list` | IPermissionRulesService.rules | GET |
| `permissionRules` | `addRules` | IPermissionRulesService.addRules | POST |
| `profile` | `get` / `getModel` / `getSystemPrompt` / `getActiveToolNames` | IProfileService.* | GET |
| `profile` | `setModel` / `setThinking` | IProfileService.* | POST |
| `messages` | `list` | IContextMemory.get | GET |
| `messages` | `splice` | IContextMemory.splice | POST |
| `toolStore` | `get` / `data` | IToolStoreService.* | GET |
| `toolStore` | `set` | IToolStoreService.set | POST |
| `mcp` | `list` | IMcpService.list | GET |
| `mcp` | `reconnect` | IMcpService.reconnect | POST |
| `tools` | `list` | IToolRegistry.list | GET |
## 4. Facade-needed (wrap before exposing)
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 |
| IAgentPromptService / IAgentTurnService | returns `Turn` handle | `prompts.submit` / `steer` / `abort` / `undo` |
| ILLMRequester | `AsyncIterable` stream | stream over WS, not RPC |
| ISubagentHost | `SubagentHandle` | `subagents.spawn` / `resume` → info |
| IProcessRunner | `IProcess` streams | terminal (separate WS protocol) |
| Storage / Store (IFileSystemStorageService / IAppendLogStore / IAtomicDocumentStore / IBlobStore) | bytes / streams | not for RPC |
| IAgentFileSystem | `withCwd` handle | `fs.read` / `write` → text/bytes |
| IExternalHooksService | server-side outbound | not exposed |
| IWireRecord | write-ahead log | internal |
## 5. WS events
A single WebSocket endpoint multiplexes RPC `call`s and event `listen`s over a JSON protocol (the lean counterpart of VSCode's `IMessagePassingProtocol`, carrying the same safety features — see §6):
```text
WS /api/v2/ws
```
Client → server: `hello` (auth), `call` (scope + `resource:action` + arg), `cancel`, `listen` (scope + event), `unlisten`, `pong`.
Server → client: `ready`, `result`, `error`, `event`, `ping`.
`call` reuses the same dispatcher as the HTTP routes (scope + `actionMap`). `listen` subscribes to an `Event<T>` source and forwards each emission as an `event` message, keyed by the client-chosen `id`.
The `eventMap` binds a public event name to the scope's `Event` source (analogous to the `actionMap`):
| Scope | event | Source |
|---|---|---|
| Core | `events` | `IEventService.subscribe` (process-wide `DomainEvent` bus) |
| Agent | `events` | `IEventSink.on` (per-agent `AgentEvent` stream) |
Session-level `onDidChange` sources (metadata / interactions) carry no payload today, so they are not exposed until there is a concrete consumer.
Safety / reliability (carried over from `packages/server/src/ws/connection.ts` and VSCode's `ChannelServer`):
- request ids + active-request table — `cancel` / `unlisten` disposes them;
- heartbeat — `ping` every 30s, `pong` timeout 10s → `terminate`;
- schema validation — invalid frames are dropped, not fatal;
- graceful close — dispose listeners, cancel pending, reject in-flight calls;
- no stack traces over the wire;
- non-serializable event payloads are dropped, never fatal.
Cursor / replay / resync for events is a future addition (a separate `call` before `listen`); the raw stream is the foundation.
## 6. Red lines (edge exposure)
- Never expose an internal domain token (`ISessionMetadata`) as a URL segment — use a public `resource` name + `action`.
- Never expose a method that returns a handle / stream / bytes / disposable — wrap in a facade.
- Never expose a method that takes a live object / `AbortSignal` / callback / resumer fn — wrap in a facade.
- Session / Agent Services are reached by `accessor.get` with the id from the URL — never cache the result; finish before the scope disposes.
- The `actionMap` is the allowlist — only mapped `resource:action` pairs are callable; unknown → `40001`.
- Events stream over WS (`listen`), never RPC (`call`).
- Business code never imports the edge (`gateway` / `rpc` / `transport`) — the edge borrows business Services by interface.
- Read = `GET`, write = `POST`; do not overload `POST` for reads when caching / browser-friendliness matters.

View file

@ -1,40 +0,0 @@
# Topic — Errors
Error infrastructure for agent-core-v2: base classes, the per-domain code contract, wire serialization, and the conventions domains follow when raising errors. The package-level reference is `packages/agent-core-v2/docs/errors.md`; this topic summarizes the hot-path rules.
Base classes and serialization are **centralized** in `_base/errors`; error **codes** are **decentralized** — each domain owns an `errors.ts` that self-registers its codes and metadata, and the `src/errors.ts` facade aggregates them into the unified `ErrorCodes` const.
## Where things live
- `src/_base/errors/errors.ts`: base classes — `Error2`, `ExpectedError`, `ErrorNoTelemetry`, `BugIndicatingError`, `NotImplementedError`, plus `isError2` and `unwrapErrorCause`.
- `src/_base/errors/codes.ts`: the `ErrorDomain` contract, the `ErrorCode` type (aliased to the protocol's `KimiErrorCode`), the registry (`registerErrorDomain` / `errorInfo` / `isErrorCode`), and `CoreErrors` (`internal`, `not_implemented`).
- `src/_base/errors/serialize.ts`: `ErrorPayload`, `isCodedError`, `toErrorPayload`, `fromErrorPayload`. Wire-facing names (`KimiErrorPayload`, `toKimiErrorPayload`) mirror the protocol and are kept as-is.
- `src/_base/errors/unexpectedError.ts`: `onUnexpectedError` / `setUnexpectedErrorHandler` (global handler).
- `src/<domain>/errors.ts`: the domain's `XxxErrors` descriptor (codes + retryable list + per-code info overrides), self-registered on import.
- `src/errors.ts`: the **facade** — imports every domain's `errors.ts`, builds `ErrorCodes`, re-exports the primitives. Throw sites import from here.
## Conventions (hard rules)
- **Throw a coded error, not a bare string.** `throw new Error2(ErrorCodes.X, …)`. Bare `new Error` only for unreachable guards; `BugIndicatingError` for caller bugs; `NotImplementedError('feature')` for stubs.
- **Define codes in the owning domain**, in `<domain>/errors.ts` as an `XxxErrors` descriptor (`satisfies ErrorDomain` + `registerErrorDomain`), then wire it into the facade. Never add domain codes to `_base/errors`.
- **One `code` per failure mode.** Codes read `domain.reason`. The valid code strings are fixed by the protocol (`KimiErrorCode` in `packages/protocol/src/events.ts`): **add new codes to the protocol first**. Renaming/removing a code is a major.
- **Translate foreign errors at the boundary.** Provider/HTTP, fs, MCP errors are re-thrown as the owning domain's coded error. `_base/errors` never imports a business domain.
- **Translation is idempotent and cause-preserving.** Translators (`toHostFsError`, `toStorageIoError`) pass through an already-translated error and always keep the original as `cause`.
- **`details` is structured and JSON-serializable; `message` is a short human sentence.** Paths/errnos/scope/key go into `details`, not the message.
- **Cancellation passes through untranslated** (`UserCancellationError` from `_base/utils/abort`) — apply only at boundaries that can actually see cancellation; do not sprinkle the check everywhere.
- **Classify wrapped errors via `unwrapErrorCause`** — errno/status predicates test the unwrapped cause, not the coded wrapper.
- **Branch on `code`, never `instanceof`, across the wire.** In-process, `instanceof Error2` / `isCodedError` are fine.
## Reference tiers
- `os.fs``HostFsError` via `toHostFsError` (`os/interface/hostFsErrors.ts`): errno → `os.fs.*`, details `{ path, op, errno?, syscall? }`.
- `os.process``HostProcessError`: `spawn_failed` / `kill_failed`, raw error as `cause`.
- `storage``StorageError` (`persistence/interface/storage.ts`): `not_found` / `decode_failed` / `corrupted` / `io_failed` (retryable) / `locked` (retryable). ENOENT keeps absence semantics, never an error. A locked query store throws `storage.locked`; consumers catch it explicitly and fall back — no silent no-op degradation.
- `wire``WireError` (`wire/errors.ts`): `DuplicateOpError`, `CycleError`, and `wire.unknown_record` (replay skips unknown records, reports via `onUnexpectedError`, returns `{ unknownRecords }`).
## Red lines (this topic)
- Throw a coded error with a `code`, not a bare string (except unreachable guards / `BugIndicatingError` / `NotImplementedError`).
- Codes live in the owning domain's `errors.ts` and self-register; new codes land in the protocol first.
- Translate foreign errors at the owning domain's boundary, idempotently, with `cause` and structured `details`; `_base/errors` never imports a business domain.
- Branch on `code` across the wire, never `instanceof`.

View file

@ -1,108 +0,0 @@
# Topic — Flags
Experimental feature-flag gating for agent-core-v2 — an App-scope `IFlagService` resolver plus a writable `IFlagRegistry` catalog that domains contribute their flags to, backed by the `[experimental]` config section.
Gate not-yet-public features behind `IFlagService.enabled(id)`, per the repository hard rule that unreleased behavior must be flag-gated. v1 was a process-global `FlagResolver` singleton over a central `FLAG_DEFINITIONS` array; v2 is a scoped DI service whose flag definitions are registered **decentrally** by each owning domain — there is no central catalog to edit.
## Layout
- `src/flag/flagRegistry.ts``IFlagRegistry` token + `FlagDefinitionInput` / `FlagId` / `FlagSurface` types + `registerFlagDefinition` / `getContributedFlags` (import-time contribution queue).
- `src/flag/flagRegistryService.ts``FlagRegistryService` impl; in-memory catalog seeded from import-time contributions; App scope.
- `src/flag/flag.ts``IFlagService` token + resolver types (`ExperimentalFlagMap`, `ExperimentalFlagConfig`, `ExperimentalFlagSource`, `ExperimentalFeatureState`) + `ExperimentalConfigSchema` / `ExperimentalConfig` (zod).
- `src/flag/flagService.ts``FlagService` impl + `MASTER_ENV` (`KIMI_CODE_EXPERIMENTAL_FLAG`) + `EXPERIMENTAL_SECTION` (`experimental`); reads definitions from `IFlagRegistry`; self-registers at App scope.
- `src/flag/index.ts`**removed (no barrel)**; `src/index.ts` imports the `flag` leafs precisely instead (e.g. `import './flag/flagService'`).
- `src/<domain>/flag.ts` — each domain that owns a flag declares it here and calls `registerFlagDefinition` at the module top level (e.g. `src/multiServer/flag.ts`). The directory already names the domain, so the file is just `flag.ts`.
## Public surface
- `IFlagService` (DI token, App scope): `enabled(id)`, `explain(id)`, `snapshot()`, `enabledIds()`, `explainAll()`, `setConfigOverrides(overrides)`, `registry`.
- `IFlagRegistry` (DI token, App scope): `register(definition)`, `get(id)`, `list()` — writable catalog. `register` is the **runtime** path (tests, dynamic registration); `IFlagService.registry` exposes the same instance for hosts/UI to enumerate flags without resolving them.
- `registerFlagDefinition(definition)` — the **import-time** path. Domains call this from their `flag.ts` top level; contributions are queued and drained by `FlagRegistryService` when it is instantiated.
- `FlagService` / `FlagRegistryService`: exported for tests and hosts that construct them directly.
## Resolution precedence
Highest wins; env is read live on every call (nothing cached):
1. Master env `KIMI_CODE_EXPERIMENTAL_FLAG` truthy → every flag on.
2. Per-feature `def.env` (e.g. `KIMI_CODE_EXPERIMENTAL_MY_FEATURE`) → forces on/off.
3. `[experimental]` config section per-flag override.
4. Registry `default`.
`explain(id)` returns the winning `source` (`master-env` | `env` | `config` | `default`) plus the effective `configValue`. `explain(id)` returns `undefined` (and `enabled(id)` returns `false`) for an id that no domain has registered.
## Config integration
- `FlagService` registers the `[experimental]` section into `IConfigRegistry` at construction (`registerSection('experimental', ExperimentalConfigSchema)`) and reads overrides from `IConfigService`.
- It subscribes `IConfigService.onDidChange` and refreshes overrides whenever the `experimental` domain changes, so config edits apply live.
- `IConfigRegistry.registerSection` throws if a domain is registered twice — `experimental` is owned exclusively by `FlagService`.
- `setConfigOverrides(overrides)` is an imperative escape hatch for tests and hosts without an `IConfigService`; hosts on `IConfigService` should set the `[experimental]` section instead.
Config shape:
```toml
[experimental]
my_feature = false
```
Keys are intentionally loose (`z.record(z.string(), z.boolean())`), so obsolete flags stay inert config.
## Add a flag
Declare the definition in the owning domain's `flag.ts` and call `registerFlagDefinition` at the module top level. There is no central catalog to edit.
`src/<domain>/flag.ts`:
```ts
import { type FlagDefinitionInput, registerFlagDefinition } from '#/flag';
export const myFeatureFlag: FlagDefinitionInput = {
id: 'my_feature',
title: 'My feature',
description: '...',
env: 'KIMI_CODE_EXPERIMENTAL_MY_FEATURE',
default: false,
surface: 'both',
};
registerFlagDefinition(myFeatureFlag);
```
Then ensure the package entry `src/index.ts` imports the flag leaf precisely so the top-level call runs at import time — there is no `src/<domain>/index.ts` barrel:
```ts
// src/index.ts
import './<domain>/flag';
```
`src/index.ts` imports every domain's leaf files precisely (one line per leaf), so the contribution runs during bootstrap, before any scope is created — and therefore before any consumer resolves `IFlagService`.
- `env` must start with `KIMI_CODE_EXPERIMENTAL_`, be unique, and not equal `KIMI_CODE_EXPERIMENTAL_FLAG`.
- `id` must not be `flag`. A duplicate `id` throws when `FlagRegistryService` drains the contributions.
- `FlagId` is `string`, not a literal union: with no central catalog there is nothing to derive it from, so `enabled()` has no compile-time typo-checking. Cover gated behavior with tests instead.
- `surface`: `core` | `tui` | `both` (documentation/grouping only; not used in resolution).
## Consume a flag
Inject `IFlagService` and gate on it. It is resolvable from any scope (App ancestor):
```ts
constructor(@IFlagService private readonly flags: IFlagService) {}
// ...
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.
- 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.
## Red lines (this topic)
- Gate unreleased behavior behind a registered flag; no ad-hoc env toggles.
- Contribute each flag from the **owning domain's** `flag.ts` (`src/<domain>/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.

View file

@ -1,258 +0,0 @@
# Stage 3 — Implement
Write the contract leaf, implementation leaf (with its registration), and the package-entry lines that load them. Each section below introduces one DI building block as you need it. Source lives in `src/_base/di/`.
## Standard recipe for a new `IXxxService`
1. **Contract leaf**`src/<domain>/<domain>.ts`: interface (with `_serviceBrand`) + `createDecorator` identity.
2. **Impl leaf**`src/<domain>/<domain>Service.ts`: class with `@IX` constructor deps; top-level `registerScopedService(scope, IX, Impl, type, '<domain>')`.
3. **Entry**`src/index.ts`: load each leaf precisely — `export * from './<domain>/<domain>';` for the contract and `import './<domain>/<domain>Service';` for the impl (importing the impl runs the registration). **No `src/<domain>/index.ts` barrel.**
4. **Tests** — see test.md.
There is **no central wiring file**: bindings live in each domain's impl file and are collected through import side effects.
## §1 Interface + identity (a global service, no deps)
```ts
// greet/greet.ts
import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation';
export interface IGreeter {
readonly _serviceBrand: undefined; // type marker: tells DI "this is a service"
hello(): string;
}
export const IGreeter: ServiceIdentifier<IGreeter> = createDecorator<IGreeter>('greeter');
```
`createDecorator(name)` produces a `ServiceIdentifier` that is three things at once: a runtime key, a parameter decorator, and a compile-time carrier of the `IGreeter` type.
> **The identity name is globally unique.** `createDecorator` caches by `name`; two domains using the same string collide and share one identity.
```ts
// greet/greetService.ts
import { InstantiationType } from '#/_base/di/extensions';
import { LifecycleScope, registerScopedService } from '#/_base/di/scope';
import { IGreeter } from './greet';
export class Greeter implements IGreeter {
declare readonly _serviceBrand: undefined; // mirrors the interface marker
hello(): string { return 'hi'; }
}
registerScopedService(
LifecycleScope.App, // lifetime: process-wide
IGreeter, // identity
Greeter, // implementation
InstantiationType.Eager, // when to construct: immediately
'greet', // domain name (for diagnostics)
);
```
The scope a class binds to is an **intrinsic property of the class**, decided at the registration point, not the call site.
The impl's top-level `registerScopedService` runs as soon as the module is imported. There is no `greet/index.ts` barrel — instead, add the leafs to the package entry `src/index.ts`, one line per leaf:
```ts
// src/index.ts
export * from './greet/greet';
import './greet/greetService'; // this import runs registerScopedService
```
Anyone can now `accessor.get(IGreeter)` the single global instance.
## §2 Constructor injection (your service uses others)
```ts
export class SessionMetadata extends Disposable implements ISessionMetadata {
declare readonly _serviceBrand: undefined;
constructor(
@ISessionContext private readonly ctx: ISessionContext,
@IAtomicDocumentStore private readonly store: IAtomicDocumentStore,
@ILogService private readonly log: ILogService,
) {
super();
}
}
```
`@ISessionContext` records "parameter 0 needs `ISessionContext`" on the class metadata; the container fills it when constructing.
Three inviolable constraints:
1. **Do not `new` a class with `@IService` deps**`new` bypasses registration, scope, and the singleton cache. Inject with `@IX` or `accessor.get(IX)`.
2. **`@IX` decorates constructor parameters only.** Decorating a field/method throws at runtime.
3. **Parameter order depends on how the object is built** — for `createInstance` non-singletons, static params come first (see §7); for scoped services, `@IX` params are conventionally first and any static params need defaults. See service-authoring.md §constructor-conventions.
Consumers resolve by interface and never import the impl class:
```ts
const meta = accessor.get(ISessionMetadata); // type is ISessionMetadata
```
> If you need "a config" rather than "a service", model it as a service (e.g. `IConfigService`) and inject it. If you need a per-turn, parameterized, non-singleton object, see §7.
## §3 Scoped registration (not global)
Swap the `scope` argument to bind to a different tier:
```ts
registerScopedService(LifecycleScope.Session, ISessionMetadata, SessionMetadata, InstantiationType.Delayed, 'sessionMetadata');
```
Remember the visibility rule from orient.md: a service may inject services from its own scope or any ancestor; never from a descendant.
## §4 Releasing resources (`Disposable`)
For a service that subscribes to events, starts timers, or holds handles:
```ts
import { Disposable } from '#/_base/di/lifecycle';
export class WSBroadcastService extends Disposable implements IWSBroadcastService {
declare readonly _serviceBrand: undefined;
constructor(@IEventService event: IEventService) {
super();
this._register(event.subscribe(() => { /* … */ })); // collect child resources
}
}
```
- Extend `Disposable`, collect any `IDisposable` with `this._register(d)` (event subscriptions, `toDisposable(fn)`, etc.).
- The container calls `dispose()` automatically when the service is torn down; child resources release in turn.
- Disposal order is deterministic (orient.md): child scopes first, then reverse construction order within a scope.
## §5 Eager vs delayed instantiation
```ts
// Eager: constructed when the scope is created
registerScopedService(LifecycleScope.App, ILogService, LogService, InstantiationType.Eager, 'log');
// Delayed: constructed on first get
registerScopedService(LifecycleScope.App, IScopeRegistry, ScopeRegistry, InstantiationType.Delayed, 'gateway');
```
A `Delayed` service returns a **Proxy** that constructs the real instance on first property access. Listeners registered on its `onDid…` / `onWill…` events before construction are not lost — the container records them and replays the subscriptions once the instance exists.
> Rule of thumb: `Eager` for dependency-free, frequently-used, or "early side effect" services (e.g. `ILogService`); default to `Delayed` otherwise.
## §6 Using a service inside a plain function (`invokeFunction`)
When you do not want a new class and just need a service once, or when you expose a `ServicesAccessor` to the outside:
```ts
const accessor: ServicesAccessor = {
get: <T>(id: ServiceIdentifier<T>): T => instantiation.invokeFunction((a) => a.get(id)),
};
```
`invokeFunction(fn)` hands `fn` a `ServicesAccessor` valid **only during that call**.
> **The accessor is valid only during the invocation.** Calling `accessor.get()` after `invokeFunction` returns throws `"service accessor is only valid during the invocation"`. Do not stash it for async use — inject the service in the constructor (§2) if you need it long-term.
## §7 Creating a non-singleton object with deps (`createInstance`)
For a per-turn executor that also has `@IService` deps:
```ts
class TurnRunner {
constructor(
private readonly input: string, // static param: passed by caller
private readonly turn: number, // static param: passed by caller
@ILogService private readonly log: ILogService, // service param: injected by container
) {}
}
const runner = instantiation.createInstance(TurnRunner, 'hello', 1);
```
Static params come first (you pass them), service params follow (the container fills them), then `Reflect.construct` builds the instance. This object is **not** placed in any scope's singleton cache — every call is a fresh instance.
> This is why service params must follow static params **for `createInstance`**: the container sorts by the parameter positions recorded via `@IX`. `_serviceBrand` lets the compiler tell the two kinds apart. Scoped services built by `registerScopedService` follow a different convention (`@IX` params first, optional static params after) — see service-authoring.md §constructor-conventions.
## §8 Spawning a child scope / child container
For a service that "starts a new session / agent" and needs a child scope, inject `IInstantiationService` itself (every container binds itself as `IInstantiationService`):
```ts
export class ScopeRegistry implements IScopeRegistry {
declare readonly _serviceBrand: undefined;
constructor(@IInstantiationService private readonly instantiation: IInstantiationService) {}
createSession(opts: CreateSessionOptions): Promise<IScopeHandle> {
const collection = new ServiceCollection();
for (const entry of getScopedServiceDescriptors(LifecycleScope.Session)) {
collection.set(entry.id, entry.descriptor); // collect Session-tier descriptors
}
const child = this.instantiation.createChild(collection); // spawn child container
const accessor: ServicesAccessor = {
get: <T>(id: ServiceIdentifier<T>): T => child.invokeFunction((a) => a.get(id)),
};
const handle: IScopeHandle = { id: opts.sessionId, kind: LifecycleScope.Session, accessor };
this.sessions.set(opts.sessionId, handle);
return Promise.resolve(handle);
}
}
```
Key points:
- `getScopedServiceDescriptors(scope)` returns every descriptor registered at that tier; load them into a `ServiceCollection`.
- `instantiation.createChild(collection)` builds a child container whose parent pointer is the current container — so the child resolves upward to `App` services (the visibility rule).
- Expose the child to the outside by wrapping it in a `ServicesAccessor` via `invokeFunction` (§6).
> Higher-level code usually calls `Scope.createChild(kind, id)` (it does the "filter descriptors + build child" for you). Drop to the manual `ServiceCollection` form only when you need explicit control.
## §9 Cyclic dependencies (forbidden — refactor)
Business rule: **no cyclic dependencies.** The container rejects them; the correct response is to refactor, not to make it run.
### The container rejects synchronous cycles
If A needs B while being created and B needs A while being created, the container throws `CyclicDependencyError` with a `path` like `['A', 'B', 'A']`. Self-cycles (A depends on itself) are also rejected. This is a protection mechanism telling you the two services' responsibilities are mis-drawn.
### Why cycles are disallowed
- Scope layering makes normal dependencies a DAG (Agent → Session → 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.**
### How to refactor (in priority order)
1. **Extract a third service C.** Move the part A and B both need into C; let A and B both depend on C instead of each other. The most common fix.
2. **Decouple with an event.** If A only needs to know about a change in B, have B emit via `IEventService` and A subscribe, rather than A holding a reference to B.
3. **Re-partition scope.** One of them may belong at a different tier — moving it makes the cycle disappear.
### Delayed as a cycle-breaker (legacy escape hatch — forbidden)
A legacy mechanism lets a `Delayed` edge turn a "soft cycle" into a non-synchronous Proxy. **Do not use it to bypass cyclic dependencies** — it exists for historical compatibility, not to paper over your design. On `CyclicDependencyError`, refactor per the above.
## Interface cheat sheet
| Interface | Section | Role |
|---|---|---|
| `createDecorator<T>(name)``ServiceIdentifier<T>` | §1 | identity (runtime key + compile-time type + param decorator) |
| `@IService` | §2, §7 | declare a dependency on a constructor param |
| `registerScopedService(scope, id, ctor, type, domain)` | §1, §3, §5 | bind an impl to a lifetime tier |
| `ServicesAccessor.get(IX)` | §2, §6 | resolve an instance by interface |
| `IInstantiationService.invokeFunction(fn, …)` | §6, §8 | obtain a temporary accessor inside a function |
| `IInstantiationService.createInstance(ctor, …args)` | §7 | build a non-singleton object with deps injected |
| `IInstantiationService.createChild(collection)` | §8 | spawn a child container |
| `getScopedServiceDescriptors(scope)` | §8 | retrieve all descriptors registered at a tier |
| `Disposable` / `DisposableStore` / `IDisposable` | §4 | resource management and disposal |
| `Scope` / `LifecycleScope` | §3, §8 | the lifetime tree |
| `SyncDescriptor` | (tests / low-level) | package a constructor + static args into a pending descriptor |
> Legacy export (not used in v2, just recognize it): `refineServiceDecorator` is a VS Code leftover DI helper. v2 src/test has zero references; always use `registerScopedService`.
## Red lines (this stage)
- No `new` on a class whose constructor carries `@IService` deps — inject or `accessor.get(IX)`.
- `@IX` decorates constructor params only; parameter order depends on construction (static-first for `createInstance`, `@IX`-first for scoped services — see service-authoring.md).
- Both interface and impl carry `_serviceBrand`; the `createDecorator` name is globally unique.
- `ServicesAccessor` is valid only during `invokeFunction` — never stash it for async use.
- No cyclic dependencies — refactor (extract / event / re-scope); do not break the cycle with `Delayed`.

View file

@ -1,111 +0,0 @@
# Stage 1 — Orient
Understand the DI × Scope black box and the file conventions before touching business code.
## The DI black box
When writing business code you declare three things; the container handles the rest (when to construct, whether it is the same instance, ordering, disposal):
- **Who am I** — an identity that is both a runtime key and a compile-time type.
- **Whom do I need** — the dependencies that provide my capabilities.
- **How long do I live** — which lifetime tier I belong to.
Classes talk only to interfaces and never care how an implementation is constructed.
## The three `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
```
```ts
export enum LifecycleScope {
App = 0,
Session = 1,
Agent = 2,
}
```
- A larger number = shorter life = closer to a leaf.
- "Singleton" means **one per scope**: `ILogService` is global once; each `Session` scope has its own `ISessionMetadata`.
- `kind` strictly increases along the parent→child direction.
### Visibility rule
A child scope sees its ancestors; a parent never sees its children. Resolution walks *up* the tree:
- ✅ An `Agent` service injects a `Session` or `App` service (found upward).
- ❌ An `App` service injects a `Session` service (the parent does not look down, and the child may not exist yet).
> **Short-lived may inject long-lived; never the reverse.** The tree structure enforces this — it is not a matter of discipline.
### Disposal order
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
The `Ln` in a file-header identity line is the domain's **dependency layer** (L0L7), **not** its `LifecycleScope`. They are easy to confuse because both are small integers, but they answer different questions:
- `LifecycleScope` (App=0 / Session=1 / Agent=2) — **lifetime & visibility** (this stage).
- Dependency layer `Ln` (L0L7) — **who may import whom**: a domain at layer `L` may import only domains at layer `<= L`. Enforced by `lint:domain` from the authoritative `DOMAIN_LAYER` map in `scripts/check-domain-layers.mjs`.
So a Session-scoped service is not "L1" — e.g. `session` is Session-scoped but lives at **L6**. When you write the header, read the number from the layer map, not from the scope.
| 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`, `workspaceRegistry` |
| 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` |
| L7 | boundary / edge | `gateway`, `rpc`, `approval`, `question`, `*Legacy` |
## 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>` domain (Ln) — <one-line role>. `` Keep an existing `(cross-cutting)` label as-is. Write the role as a responsibility ("drives the turn lifecycle"), not a symbol list.
- **Scope is in the filename.** `session*.ts` = Session, `agent*.ts` = Agent, no prefix = App (see service-authoring.md). State the same scope in the header so the two never drift.
- **Interface files** (`<name>.ts`) state the public contract + scope: which `IXxx` they define and what it is for.
- **Impl files** (`<name>Service.ts`) add collaborators + scope: list every imported cross-domain collaborator as a role ("persists records through `records`"); read scope from `registerScopedService(LifecycleScope.X, …)`.
- **Contribution files** (`<targetDomain>.ts` / `<what>.contrib.ts`) state what they register into the target domain (e.g. "registers the `log` config section into `config`").
- **Pure-function / `.types` / `.errors` files** state the responsibility only — they own no scoped state, so no scope line.
Impl file example (`sessionMetadataService.ts`):
```ts
/**
* `sessionMetadata` domain (L6) — `ISessionMetadata` implementation.
*
* Persists the session metadata document (`state.json`) through the `storage`
* access-pattern store (`IAtomicDocumentStore`), rooted at the `metaScope`
* namespace from `sessionContext`. Loads the existing document on
* construction (creating it on first run), and logs through `log`. Bound at
* Session scope.
*/
```
Contribution file example (`config.ts` inside `log/`):
```ts
/**
* `log` domain — registers the `log` config section into `config`.
*
* Owns the `log` section schema and its env overlay; imported for the
* registration side effect. Bound at App scope.
*/
```
## Red lines (this stage)
- Import via the `#/...` alias (mapped to `src/`); never reach into another domain's internals by relative path.
- Short-lived may inject long-lived; never the reverse.
- File-header comments describe role and scope only; never narrate implementation beside statements.

View file

@ -1,206 +0,0 @@
# Topic — Permission
The target design for the agent-core permission system. Read this when touching `permission`, `permissionMode`, `permissionRules`, or when adding a new permission dimension.
> **The permission system should be a composable, registrable chain of responsibility (a microkernel).** The kernel only runs the chain in order, first hit wins; concrete permission dimensions (policies) are contributed by their owning Domain Services through a registry; tools only declare standardized resource access (`accesses`) in `resolveExecution`, and generic dimensions consume that metadata.
>
> **Do not introduce Casbin** — the hard part here is *decision behavior* (continuations, side effects, RPC, state machines), not "match + scalar decision".
## 1. Problem definition
The permission system answers one question: **for each tool call, in the current agent and current mode — allow / deny / ask the user?** Three traits shape the architecture:
1. **Decisions carry behavior.** Returning `ask` is not an enum value — it is a workflow with an RPC round-trip, hooks, telemetry, state writes, and a continuation; returning `deny` may be the result of running an external hook.
2. **Heterogeneous policies.** Some check a tool-name set, some count same-batch `AgentSwarm` calls, some run a hook, some inspect the plan state machine — no uniform `(sub, obj, act)` shape.
3. **Multi-agent × multi-mode × external extension.** Different agents / modes need different permissions, and outsiders (org admins, plugins) must contribute rules or behavior in a decoupled way.
## 2. Current state (v1) at a glance
Code lives in `packages/agent-core/src/agent/permission/`.
- **Architecture: ordered chain of responsibility, first hit wins.** `PermissionManager` holds `PermissionPolicy[]`; evaluation iterates in order, the first non-`undefined` result wins.
- **`PermissionPolicyResult` is a behavior bundle, not a scalar:** `approve` (with `executionMetadata`), `deny` (with `message`), or `ask` (with `resolveApproval` / `resolveError` continuations).
- **11 dimensions, 19 policies**, hardcoded in `policies/index.ts#createPermissionDecisionPolicies()`. Order is a high-to-low safety cascade: external force → structural deny → state-machine deny → static deny → mode allow → session-memory allow → static ask → static allow → flow allow → sensitive-path ask → default allow → fallback ask.
- **Resource-access declaration:** tools declare accessed resources in `resolveExecution(input)` via `accesses` (`ToolAccesses`, currently `file` and `all`); generic dimensions read `context.execution.accesses`.
### v1 pain points the target design fixes
1. The chain is hardcoded — outsiders cannot contribute.
2. `mode` is an `if` inside each policy (`YoloModeApprove` / `AutoModeApprove` self-guard).
3. No per-agent chain entry point (only scattered `agent.type === 'sub'` checks).
4. No external extension point beyond the single `PreToolUse` hook slot.
## 3. Why not Casbin
- **`policy_effect` is unusable** — composition here is a fixed, intentionally hardcoded safety cascade; the real complexity lives in each policy's `evaluate` behavior, which a Casbin expression cannot absorb. Externally tunable safety knobs are already exposed via `mode` + allow/deny/ask rules.
- **Flexible priority is unusable** — there is no plugin injection point, no multi-subject/RBAC, and a fixed subject (agent/user), so priority collisions do not arise. Casbin's `(sub, obj, act)`, `g()`, and domains would idle.
- **Fundamental mismatch: decisions are not scalars.** `enforce()` maps a request to an effect; agent-core decisions are behavior bundles (continuations, side effects, synthesized results). Even if Casbin computed `ask`, the surrounding behavior would still need to be rewritten — Casbin would degrade to an enum generator.
- **When Casbin becomes worth it:** when the hard part is matching semantics itself — role inheritance, domain isolation, ABAC expressions, policies loaded from a DB. Not before.
## 4. Design-pattern placement
Permission orchestration is a layered combination, not a single pattern:
| Layer | Pattern | Role |
|---|---|---|
| Runtime decision | **Chain of Responsibility** | multiple candidates in order; first hit wins, rest short-circuit |
| Single handler | **Strategy** | each policy is an interchangeable "permission adjudication" algorithm |
| Assembly / external extension | **Plugin / Microkernel** | minimal kernel + explicit extension points + pluggable policies |
| Landing support | **Registry + Factory** | collect plugins; assemble the chain per `(agent, mode)` on demand |
Casbin = single Strategy + data-driven. This design = multiple Strategies + chain-of-responsibility composition. Behavior-heavy systems must choose the latter — behavior cannot be flattened into data rows.
## 5. Target design
### 5.1 Core principles
1. **The chain encodes "permission dimensions", not "tools".** Adding a tool does not lengthen the chain; only adding a dimension adds a node.
2. **Two contribution paths:** high-frequency trivial specifics go through the **data path** (rules); low-frequency new dimensions with behavior go through the **code path** (policies).
3. **Domain self-registration:** a domain that owns a dimension (plan/goal/swarm) registers its policy in DI, mirroring v2's existing "domain self-registers tools".
4. **Tools declare resources; generic dimensions consume them:** bash/write/read only declare `accesses`; file/security dimensions judge centrally.
### 5.2 Core abstractions
```ts
type Phase =
| 'guard' | 'user-deny' | 'mode' | 'session'
| 'user-ask' | 'default' | 'fallback';
interface PermissionPolicyEntry {
name: string;
phase: Phase;
modes?: PermissionMode[]; // declare which modes this applies in (no more in-evaluate if)
agentTypes?: AgentType[];
factory: (accessor: ServicesAccessor) => PermissionPolicy;
}
// App scope — collects every domain's registration
interface IPermissionPolicyRegistry {
register(entry: PermissionPolicyEntry): IDisposable;
list(): readonly PermissionPolicyEntry[];
}
```
`PermissionPolicyService` (Agent scope) changes from a hardcoded list to "assemble by `(agent, mode)`":
```ts
this.policies = registry.list()
.filter(e => !e.modes || e.modes.includes(mode))
.filter(e => !e.agentTypes || e.agentTypes.includes(agentType))
.sort(byPhaseThenRegistrationOrder)
.map(e => e.factory(accessor));
```
Key points:
- `modes` / `agentTypes` are **declarations** — they lift the `if (mode !== 'yolo') return` out of `YoloModeApprove` into metadata.
- `factory`, not `instance`: a node may depend on agent-scoped services (mode, rules) and must be instantiated in the Agent scope — symmetric to `IToolDefinitionRegistry` (App) storing factories and `IToolService` (Agent) instantiating tools.
- **Different `(agent, mode)` produce differently-shaped chains** — under yolo the ask/fallback phases are physically filtered out.
### 5.3 Two contribution paths
| What is being added | Path | Chain length |
|---|---|---|
| New tool, new org rule, new user preference ("deny `Bash(curl *)`") | **Data path**: add a `PermissionRule` to an existing node | unchanged |
| New cross-cutting behavior (custom approval UI, audit log, new mode) | **Code path**: register a new policy node | +1 |
Most growth goes through the data path — node count is bounded by "kinds of behavior"; rule count grows with specifics (rule matching is a cheap Set/glob).
### 5.4 Domain self-registration
Mirrors v2's "domain registers tools in its constructor". `PlanService` self-registers its dimensions:
```ts
// src/plan/planService.ts
constructor(@IPermissionPolicyRegistry registry: IPermissionPolicyRegistry) {
registry.register({ name: 'plan-mode-guard-deny', phase: 'guard',
factory: a => new PlanModeGuardDenyPolicy(a.get(IPlanService)) });
registry.register({ name: 'plan-mode-tool-approve', phase: 'mode',
factory: a => new PlanModeToolApprovePolicy(a.get(IPlanService)) });
registry.register({ name: 'exit-plan-mode-review-ask', phase: 'user-ask',
factory: a => new ExitPlanModeReviewAskPolicy(a.get(IPlanService), a.get(IPermissionModeService)) });
}
```
A complex domain may register a single **composite** node externally and run a small internal chain, hiding its internal order from the global chain.
### 5.5 Tools declare resources at runtime (`resolveExecution` / `accesses`)
In `resolveExecution(input)`, before execution, declare accessed resources with the `ToolAccesses.*` builders:
```ts
resolveExecution(args: WriteInput): ToolExecution {
const path = resolvePathAccessPath(args.path, { kaos, workspace, operation: 'write' });
return {
accesses: ToolAccesses.writeFile(path), // declares: write this file
approvalRule: literalRulePattern(this.name, path),
matchesRule: (ruleArgs) => matchesPathRuleSubject(ruleArgs, path, ...),
execute: () => this.execution(args, path),
};
}
```
Current resource types:
```ts
type ToolResourceAccess =
| { kind: 'file'; operation: 'read'|'write'|'readwrite'|'search'; path: string; recursive?: boolean }
| { kind: 'all' }; // non-enumerable side effects (pessimistic, globally exclusive)
```
Two complementary channels:
- **Enumerable resources** (write/read/edit/grep/glob) → use `accesses`; generic file dimensions cover them automatically.
- **Non-enumerable resources** (bash running arbitrary commands) → do not declare `accesses`; use the `matchesRule` DSL (e.g. `Bash(rm *)` globs by command string).
**kaos's role:** kaos is the execution-environment abstraction (fs/process/pathClass) used by the file dimension for path normalization and judgment — it is **not** the permission-dimension abstraction itself. Permission semantics live one layer above kaos, at "file access".
**v2 evolution:** extend the `ToolResourceAccess` union so non-file resources can be declared structurally:
```ts
type ToolResourceAccess =
| { kind: 'file'; operation: FileOp; path: string; recursive?: boolean }
| { kind: 'network'; operation: 'connect'; host: string }
| { kind: 'shell'; command: string }
| { kind: 'datastore'; operation: 'read'|'write'; table: string }
| { kind: 'all' };
```
Each new resource kind can pair with a generic dimension that consumes it; tools always only **declare**.
### 5.6 Dimension ownership
| Dimension | Owner (who registers) | Type |
|---|---|---|
| external hook veto | `externalHooks` domain | generic |
| tool-batch exclusivity | `swarm` domain | domain-specific (ships with the AgentSwarm tool) |
| runtime-mode posture | `permissionMode` domain | generic |
| plan-mode constraints | `plan` domain | domain-specific |
| goal-start approval | `goal` domain | domain-specific |
| static config rules | `permissionRules` domain | generic (data path) |
| session approval memory | `permissionRules` domain | generic |
| sensitive / special paths | generic "file-access/security" dimension | generic (consumes `accesses`) |
| tool intrinsic risk | core permission | generic (consumes tool declarations) |
| workspace write trust | generic "file-access/security" dimension | generic (consumes `accesses`) |
| fallback | core permission | generic |
Pattern: **specific dimensions ship with their owning domain + tool; generic dimensions register centrally and apply across tools via the declared `accesses`.**
## 6. Evolution path
Incremental, not big-bang:
1. **Registry + Composer (zero behavior change).** Replace the 19 hardcoded `new`s in v2 `PermissionPolicyService` with reads from `IPermissionPolicyRegistry`; register existing policies as-is. Immediately gain multi-agent/mode selectable chains and an external registration entry.
2. **Declarative modes.** Lift the mode guards in `YoloModeApprove` / `AutoModeApprove` into `modes` metadata.
3. **Sink domain dimensions.** Move registration of plan/goal/swarm policies into their owning domain service constructors.
4. **(On demand) extend resource types.** When non-file resources (network/DB/shell) need structural dimensions, extend the `ToolResourceAccess` union.
5. **(On demand) swap the matching kernel for Casbin.** Only when external rules genuinely need RBAC/ABAC semantics, swap the data-path rule-matching kernel for Casbin. Not before.
## Red lines (this topic)
- Do not introduce Casbin — decisions are behavior bundles, not scalar effects.
- The chain encodes dimensions, not tools: a new tool must not lengthen the chain.
- New specifics go through the data path (rules); only new behavior goes through the code path (a policy node).
- A domain that owns a dimension self-registers its policy in DI; do not centralize domain policies in core.
- Tools only declare `accesses`; generic dimensions consume them. kaos is the execution environment, not the permission abstraction.
- Use `factory` (Agent-scope instantiation), not `instance`, for registered policies.

View file

@ -1,204 +0,0 @@
# Topic — Persistence layering
How business code persists data in `agent-core-v2`: the three-layer model (`Store → Storage → backend`), the naming rules for each layer, and how to decide which layer a domain should depend on. Read this before adding any persistence to a domain.
A domain `I{Domain}EntityService` is a business facade over these layers, not a replacement for them. Before naming or bundling EntityServices by `session` / `agent` / `turn`, read [domain-boundaries.md](domain-boundaries.md).
## The three-layer model
Persistence is split into three layers, each hiding one kind of change:
```text
Business Service
│ inject
┌────────────────────────────────────────┐
│ Store (semantic layer) │ ← access-pattern facade
│ IAppendLogStore / IAtomicDocumentStore│ append-log / atomic-doc / blob
└────────────────────────────────────────┘
│ inject
┌────────────────────────────────────────┐
│ Storage (byte layer) │ ← byte primitives
│ IFileSystemStorageService │ read/write/append/list/delete
└────────────────────────────────────────┘
│ implements
┌────────────────────────────────────────┐
│ Backend (deployment-specific) │ ← File / Postgres / Redis / S3
│ FileStorageService / PostgresStorage │
└────────────────────────────────────────┘
│ uses
┌────────────────────────────────────────┐
│ Platform primitives │ ← hostFs / dbClient / redisClient
└────────────────────────────────────────┘
```
Each layer hides exactly one concern:
| Layer | Hides | Business code sees |
|---|---|---|
| **Store** | how an access pattern works (append-log reads, atomic-doc serialization) | "append this record" / "save this document" |
| **Storage** | byte primitives (atomic write, ordered append, prefix list) | `read/write/append/list/delete` over `(scope, key)` |
| **Backend** | deployment environment (file vs DB vs Redis vs S3) | nothing — chosen at the composition root |
## The one-sentence rule
> **Business code expresses *what* to store or fetch, never *how* to store it.**
If business code contains any "how to persist" detail, it has punched through the layer it should depend on:
| Business code contains | It has punched through | Depend on instead |
|---|---|---|
| `INSERT INTO …` / `SELECT …` | Storage + backend | a Store |
| file paths / `rename` / `fsync` | Storage | Storage or a Store |
| `JSON.parse` / `JSON.stringify` | Store (serialization) | `IAtomicDocumentStore` |
| append offsets / sequential cursors | Store (log semantics) | `IAppendLogStore` |
| `hash(data)` used as a key | Store (blob semantics) | `IBlobStore` |
| `pathe.join / relative / basename` on `homeDir` etc. | Bootstrap (path layout) | `IBootstrapService.scope(...)` / scope contexts |
| only `read/write/list/delete` on bytes | nothing — this is the byte layer | `IFileSystemStorageService` directly ✅ |
## Where scopes come from — `IBootstrapService` and scope contexts
Business code **never assembles scope strings from paths**. Scope strings come from three places:
1. **`IBootstrapService.scope(name)`** — well-known top-level scopes (`'config' | 'sessions' | 'blobs' | 'store' | 'logs' | 'cache' | 'credentials'`). App-scope, deployment-agnostic contract.
2. **`ISessionContext.scope(subKey?)`** — persistence scope rooted at the current session; `scope('agents/main')` etc.
3. **`IAgentScopeContext.scope(subKey?)`** — persistence scope rooted at the current agent; `scope('cron')`, `scope('blobs')` etc.
The bootstrap layer decides how each semantic scope maps to concrete addressing. In the file deployment, `FileBootstrapService` reads a `ResolvedEnvironment` (the paths bag) and returns homeDir-relative scopes; a server deployment could bind a different `IBootstrapService` implementation that maps `'sessions'` to a DB table without any business change.
```ts
// ❌ Wrong — path arithmetic on homeDir/sessionDir leaks the file layout
const scope = relative(bootstrap.homeDir, join(session.sessionDir, 'agents', agentId, 'cron'));
// ✅ Right — the agent already knows its own scope root
const scope = agentCtx.scope('cron');
```
Absolute paths (`sessionDir`, `agentHomedir`) are still available on `IBootstrapService` for the very small number of legacy APIs that expose on-disk paths (session log rotation, background task tail file). Prefer scope strings; ask before adding a new absolute-path caller.
## Which layer to depend on — decision tree
```text
Need to persist
├─ read-whole / write-whole, JSON-serializable?
│ └─ IAtomicDocumentStore
├─ append-only writes / sequential reads, independent records?
│ └─ IAppendLogStore
├─ large object, addressed by content hash?
│ └─ IBlobStore
├─ custom byte layout (index / cache / binary) that read/write/list cover?
│ └─ IFileSystemStorageService directly
├─ new, reusable access semantics (multi-field query / time-range / graph)?
│ └─ add a new Store; business depends on the Store
└─ business-specific, trivial, one or two lines?
└─ IFileSystemStorageService directly; if it grows, extract a private Store
```
## Naming — Store by access pattern, not by business
A Store abstracts an **access pattern**, not a business data type. Name it after the pattern so its reusability is obvious from the name.
| Access pattern | Store name | Backend examples |
|---|---|---|
| append-log (append / sequential read) | `IAppendLogStore` | `FileAppendLogStore` / `PostgresAppendLogStore` |
| atomic-document (read/write whole) | `IAtomicDocumentStore` | `FileDocumentStore` / `RedisDocumentStore` |
| blob (hash-addressed large object) | `IBlobStore` | `FileBlobStore` / `S3BlobStore` |
**Do not name a generic Store after a business concept.** `IRecordStore` / `IConfigStore` make a reusable access pattern look like a private store for one feature. Any domain that needs an append-log uses `IAppendLogStore`; any domain that needs an atomic document uses `IAtomicDocumentStore`.
**Exception — business-specific Stores are named after the business.** When a Store captures one domain's unique query semantics (not a generic access pattern), name it after the domain:
```text
ISessionIndex query / enumerate sessions by workspace ← business-specific
```
Test: is the Store's semantics a *generic access pattern* (append-log / atomic-doc / blob) or *one domain's unique query*? Generic → name by pattern; unique → name by domain.
## Storage — a filesystem-specific byte layer
The byte layer is a single `IFileSystemStorageService` interface (read / readStream / write / append / list / delete / watch / flush / close). As the name says, it is **filesystem-specific**: it exposes the two irreducible durable primitives a local filesystem implements optimally — atomic whole-value replacement (`write`, via tmp + rename) and ordered durable extension (`append`, via `open('a')`). The node-fs Store backends (`AppendLogStore`, `JsonAtomicDocumentStore`, `BlobStoreService`) are built on it.
```ts
export interface IFileSystemStorageService {
read(scope: string, key: string): Promise<Uint8Array | undefined>;
readStream(scope: string, key: string): AsyncIterable<Uint8Array>;
write(scope: string, key: string, data: Uint8Array, options?: { atomic?: boolean }): Promise<void>;
append(scope: string, key: string, data: Uint8Array, options?: { durable?: boolean }): Promise<void>;
list(scope: string, prefix?: string): Promise<readonly string[]>;
delete(scope: string, key: string): Promise<void>;
watch?(scope: string, key: string): Event<void>;
flush(): Promise<void>;
close(): Promise<void>;
}
```
Two backends implement it today, both bound at the composition root:
```ts
// Production — local filesystem rooted at homeDir
collection.set(IFileSystemStorageService, new FileStorageService(homeDir));
// Tests — in-memory backend seeded by the test harness
collection.set(IFileSystemStorageService, new InMemoryStorageService());
```
**Non-filesystem backends (Postgres, S3, Redis) do not implement this interface.** Atomic-rename and byte-append have no native equivalent in those stores, so they implement the **Store** interfaces directly via their own clients instead:
```ts
// Server profile — append-logs on Postgres, atomic documents on Redis.
// Each Store is backed by a native client; IFileSystemStorageService is not involved.
collection.set(IAppendLogStore, new PostgresAppendLogStore(db, 'records'));
collection.set(IAtomicDocumentStore, new RedisDocumentStore(redis, 'config'));
```
Use the `scope` parameter to express **business namespace** within a backend. Do not overload `scope` to route backends — bind a different Store implementation at the composition root instead.
## Store `acquire(scope, key)` — flush-on-dispose handle
Stores that buffer writes expose an `acquire(scope, key)` handle so a business can flush them on disposal:
```ts
export interface IAppendLogStore {
// …
/**
* Acquire a disposable handle for `(scope, key)`. Register it with your
* `Disposable` (via `this._register(...)`); when you are disposed, pending
* appends for that log are flushed. The shared store itself is not disposed.
*/
acquire(scope: string, key: string): IDisposable;
}
```
`IAppendLogStore.acquire` flushes the log's pending appends on dispose — it exists because `append` is fire-and-forget. `IAtomicDocumentStore.acquire` is a no-op today (atomic documents are durable on write) and exists for interface symmetry. Businesses that do not need flush-on-dispose simply do not call `acquire`.
## When the byte layer does not apply
`IFileSystemStorageService` covers only the local-filesystem byte primitives. It is not a universal storage abstraction:
- **Non-filesystem backends** (Postgres / S3 / Redis) implement the **Store** interfaces directly via native clients — they never implement `IFileSystemStorageService`.
- **Blobs** are a Store-level interface (`IBlobStore`) with their own backends; the node-fs `BlobStoreService` sits on `IFileSystemStorageService`, but an `S3BlobStore` would not.
- **A backend has a fast primitive the Store interface cannot express** (e.g. Postgres `COPY`) → as an exception, extend that backend's Store implementation directly. This is an exception, not the default.
## 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`.
## Red lines (this topic)
- Business code never contains "how to persist" details (serialization / paths / SQL / append offsets) — if it does, drop a layer.
- Business code never assembles scope strings from paths (`pathe.join / relative / basename` on `homeDir` / `sessionDir` / …). Use `IBootstrapService.scope(name)` for well-known scopes, `ISessionContext.scope(subKey?)` for session-rooted scopes, and `IAgentScopeContext.scope(subKey?)` for agent-rooted scopes.
- 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.
- 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.

View file

@ -1,250 +0,0 @@
# Subskill — Server align (expose `agent-core-v2` over `server-v2`)
Wire a v2 domain into `packages/kap-server`, and — when the endpoint is part of the established `/api/v1` wire contract — keep the wire shape **byte-for-byte compatible** with what released v1 clients expect. This is the server-side counterpart of [align.md](align.md): `align.md` ports v1 *business logic* into v2; this file exposes the v2 result over HTTP / WS, reusing the v1 wire contract where it already exists.
Use this when the task is "expose the new v2 Service on the server", "add a `/sessions/:sid/...` route to the `/api/v1` surface", or "keep server-v2 speaking the same `/api/v1` contract released clients rely on".
## The one-paragraph mental model
`server-v2` serves **two HTTP surfaces** off the same `agent-core-v2` scope tree:
- **`/api/v2/:sa`** — the native v2 RPC surface, driven by the `actionMap` allowlist (`packages/kap-server/src/transport/actionMap.ts`). One `resource:action` segment maps to one `Service.method`. New v2-native capabilities land here. See [edge-exposure.md](edge-exposure.md).
- **`/api/v1/...`** — the v1-compatible surface, hand-written routes in `packages/kap-server/src/routes/*.ts` that **implement the established v1 wire contract path-for-path and schema-for-schema**, mounted by `registerApiV1Routes.ts`. This surface IS the v1 contract now (the legacy v1 server is gone); it exists so existing v1 clients keep working against server-v2 unchanged.
The two surfaces can point at **different Services** for the same feature. v2's native `IAgentPromptService` serves `/api/v2`; a v1-shaped `IAgentPromptService` serves `/api/v1`. Keeping them separate is what lets v2's domain design stay clean while the wire stays compatible.
## Decision: which surface?
```text
Is the endpoint part of the established /api/v1 wire contract (protocol schema
+ released-client expectation)?
├─ YES → /api/v1 mirror route (this file, §schema-fidelity + §legacy-service).
│ Reuse the protocol schema; add a LegacyService if v2 semantics diverge.
└─ NO → /api/v2 native action (edge-exposure.md).
Add to actionMap, wrapping in a facade if the method fails §2 there.
```
A feature often needs **both**: the v1 mirror so old clients keep working, and the v2 action so new clients get the cleaner shape. Do them as two routes / two action-map entries over the same scope tree.
## The server-align workflow
```text
Pick surface → Read the v1 route (if any) → Reuse / add the protocol schema
→ Choose native Service vs LegacyService → Wire the route / actionMap entry
→ Map errors → Test against the v1 wire shape → Verify
```
### 1. Pick the surface
Apply the decision above. For a v1-matched endpoint, the **spec** is the protocol schema plus the existing mirror routes:
- `packages/protocol/src/rest/<resource>.ts` — the wire schema you must match.
- `packages/kap-server/src/routes/<resource>.ts` — the file you are writing (create it if missing); sibling route files show the conventions.
The protocol schema is the source of truth. Do not re-derive the wire shape from memory or from the v2 domain model.
### 2. Reuse (or add) the protocol schema
The wire schema lives in **`@moonshot-ai/protocol`** under `packages/protocol/src/rest/<resource>.ts` (e.g. `promptSubmissionSchema`, `promptListResponseSchema`, `configResponseSchema`). Every `/api/v1` route in `packages/kap-server` imports from it — that single import is what guarantees the server speaks the same shape released clients expect.
Actions:
- **Schema already in protocol** → import it in the server-v2 route and use it in `defineRoute` (`body`, `success.data`, error `dataSchema` / `detailsSchema`). Do **not** re-declare the schema inline in server-v2.
- **Schema missing in protocol** → add it to `packages/protocol/src/rest/<resource>.ts` first, with a `rest-<resource>.test.ts`, then consume it from the route. The protocol package is the source of truth; server-v2 never owns a v1 wire schema locally.
- **Schema exists but only v1 uses it** → move/keep it in protocol and import it into server-v2; do not fork a copy.
#### Schema-fidelity rule (the hard rule)
For a `/api/v1` endpoint, the request and response schemas **must be the established protocol schema** (or a strict superset):
- ✅ **Adding** an optional field is allowed (`field: z.string().optional()`). Old clients ignore it; new clients may send it.
- ❌ **Renaming** a field, **changing** its type, **tightening** its validation, or **changing its meaning** is a wire break — do not do it in a mirror route. If the v2 domain genuinely needs a different shape, that shape belongs on `/api/v2`, not on the `/api/v1` mirror.
- ❌ Re-declaring the schema inline in server-v2 (even if it "looks identical") is forbidden — it drifts. One schema, one home: `packages/protocol`.
Self-check: "would a released v1 client get a byte-identical envelope from `packages/kap-server` for this request?" If you cannot answer yes from the shared schema, the route is wrong.
### 3. Choose native Service vs LegacyService
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`, `IWorkspaceRegistry`, `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`.
Reach for a LegacyService when **any** hold:
- The v1 endpoint carries state the v2 domain deliberately dropped (e.g. a FIFO queue, a `prompt_id`, idempotent `abort`/`steer`, auto-start-next).
- The v1 method returns a handle/stream that v2 wraps differently, and the v1 clients expect the old envelope shape.
- Matching v1 would force a `Map<sessionId, …>`-at-`App` anti-pattern or a scope/domain-direction violation into the native Service (see [align.md](align.md) red lines).
- The native Service's error set / return type would have to grow v1-only branches.
Do **not** put v1 quirks into the native v2 Service "to keep the route simple". That is the conflict this rule exists to prevent: the native Service serves the v2 architecture; the LegacyService serves the wire contract.
#### LegacyService recipe
A LegacyService is a normal v2 Service (service-authoring.md) with one extra convention: its contract is shaped by the **protocol** types, not by the v2 domain model.
```text
packages/agent-core-v2/src/<domain>Legacy/
├── <domain>Legacy.ts ← contract: protocol-typed interface + decorator
├── <domain>LegacyService.ts ← impl: delegates to the native v2 Service(s)
└── errors.ts ← v1-compatible error codes (KimiError codes)
```
Skeleton (matches `prompt/`):
```ts
// prompt.ts — contract shaped by @moonshot-ai/protocol
import type { PromptSubmitResult, PromptSubmission } from '@moonshot-ai/protocol';
import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation';
export interface IAgentPromptService {
readonly _serviceBrand: undefined;
submit(body: PromptSubmission): Promise<PromptSubmitResult>;
// ...the rest of the v1 contract, typed by protocol
}
export const IAgentPromptService: ServiceIdentifier<IAgentPromptService> =
createDecorator<IAgentPromptService>('agentPromptLegacyService');
```
```ts
// promptService.ts — impl delegates to the native v2 Service
constructor(@IAgentPromptService private readonly prompt: IAgentPromptService /*, ... */) {}
// submit() builds v2-native input, calls the native Service, projects the result
// back into the protocol PromptSubmitResult.
registerScopedService(
LifecycleScope.Agent, // scope = the lifetime of the legacy state
IAgentPromptService,
AgentPromptLegacyService,
InstantiationType.Delayed,
'prompt',
);
```
Conventions:
- **Name** the domain `<domain>Legacy` and the interface with the scope prefix, `I<Scope><Domain>LegacyService` (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`).
- **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 `@moonshot-ai/protocol`**, so the interface cannot drift from the wire shape.
### 4. Wire the route / actionMap entry
**For `/api/v1` (mirror):** add a route file under `packages/kap-server/src/routes/<resource>.ts` using `defineRoute`, then register it in `registerApiV1Routes.ts`. Resolve the scope from the URL (`session_id` → Session scope, agent → Agent scope via `IAgentLifecycleService.getHandle`), then `accessor.get(IX)` the native or Legacy Service. Match the established verbs, paths (`:sid` / `{session_id}`), and `parseActionSuffix` actions (`:steer`, `:abort`) exactly — sibling routes under `packages/kap-server/src/routes/` are the reference.
```ts
const route = defineRoute(
{
method: 'POST',
path: '/sessions/{session_id}/prompts',
body: promptSubmissionSchema, // ← from @moonshot-ai/protocol
params: sessionIdParamSchema,
success: { data: promptSubmitResultSchema }, // ← from @moonshot-ai/protocol
errors: {
[ErrorCode.SESSION_NOT_FOUND]: {},
[ErrorCode.SESSION_BUSY]: {},
[ErrorCode.PROMPT_ALREADY_COMPLETED]: { dataSchema: z.object({ aborted: z.literal(false) }) },
},
operationId: 'submitPrompt',
tags: ['prompts'],
},
async (req, reply) => {
try {
const result = await resolveLegacy(core, req.params.session_id).submit(req.body);
reply.send(okEnvelope(result, req.id));
} catch (error) {
sendMappedError(reply, req.id, error);
}
},
);
app.post(route.path, route.options, route.handler);
```
**For `/api/v2` (native):** add a `resource:action` entry to `actionMap` ([edge-exposure.md](edge-exposure.md) §3). If the method fails the direct-exposure rules (returns a handle / stream / bytes, takes a live object), wrap it in a wire-shaped facade first (`IAgentRPCService` / `ISessionRPCService`) and map to the facade — as `prompts:*` does via `IAgentRPCService`.
### 5. Map errors
The route translates domain `KimiError` codes into protocol `ErrorCode` numbers. Two registries must stay in sync:
- **Domain code** — register in `agent-core-v2/src/errors.ts` (`ErrorCodes`) and throw from the Service (errors.md). Co-located domain errors go in `<domain>Legacy/errors.ts` (e.g. `prompt.not_found`, `session.busy`).
- **Wire code** — register the matching number in `packages/protocol/src/error-codes.ts` and reference it in the route's `errors` map and `sendMappedError`.
```ts
function sendMappedError(reply, requestId, err) {
if (isKimiError(err)) {
switch (err.code) {
case 'session.not_found':
case 'agent.not_found':
return reply.send(errEnvelope(ErrorCode.SESSION_NOT_FOUND, err.message, requestId));
case 'prompt.not_found':
return reply.send(errEnvelope(ErrorCode.PROMPT_NOT_FOUND, err.message, requestId));
// ...
}
}
return reply.send(errEnvelope(ErrorCode.INTERNAL_ERROR, String(err), requestId));
}
```
Match the v1 route's status codes and idempotent-conflict envelopes (e.g. `prompt.already_completed``40903` with `{ data: { aborted: false } }`). The error envelope is part of the wire contract — it is covered by the same schema-fidelity rule.
### 6. Test against the v1 wire shape
Add a `packages/kap-server/test/<resource>.test.ts` that boots the server and hits the route. Assert on the **envelope + protocol shape**, not on the v2 domain internals:
- success envelope `{ code: 0, data: <protocol shape>, request_id }`;
- each declared error envelope `{ code: <ErrorCode>, msg, data, request_id }`;
- the fields v1 clients read are present with the same names/types.
Where the route mirrors v1, the test is the regression guard for the schema-fidelity rule: if someone drifts the protocol schema or the projection, this test breaks.
### 7. Verify
- `pnpm -C packages/kap-server test` — server routes green.
- `pnpm -C packages/protocol test` — schema tests green (incl. any new `rest-*.test.ts`).
- `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/server-e2e ...` when a v1 parity scenario exists.
## Worked example — porting v1 `/sessions/:sid/prompts`
This is the reference alignment (commits `feat(server-v2): port v1 /sessions/:sid/prompts routes`, `feat(server-v2): return turn ids for prompt actions`). It shows all three decisions at once.
**The mismatch.** v1 `IPromptService` is a per-agent *scheduler*: it owns a FIFO queue, assigns `prompt_id`s, supports `steer`/`abort`, and auto-starts the next queued prompt when a turn settles. v2's native `IAgentPromptService` is a *turn driver*: a submission *is* a turn, there is no queue and no `prompt_id`. Forcing the queue into the v2 native Service would distort the v2 domain.
**The split.**
- `/api/v2` keeps the native shape — `prompts:submit` / `steer` / `undo` / `clear` / `cancel` map to `IAgentRPCService` (a wire facade over the v2 turn driver) in `actionMap`. The native `IAgentPromptService` is untouched.
- `/api/v1` gets an `AgentPromptLegacyService` (`prompt/`, `LifecycleScope.Agent`) that re-implements the v1 scheduler — queue, `prompt_id`, steer/abort, auto-start-next — **on top of** the native `IAgentPromptService`. The `/api/v1` routes consume the LegacyService.
**The schema.** Both surfaces import `promptSubmissionSchema` / `promptSubmitResultSchema` / `promptListResponseSchema` / `promptSteerRequestSchema` / `promptSteerResultSchema` / `promptAbortResponseSchema` from `@moonshot-ai/protocol`. The `/api/v1` and `/api/v2` routes are therefore compatible with released clients by construction; the LegacyService projects v2 turn results back into those protocol shapes.
**The errors.** v1 codes (`prompt.not_found`, `session.busy`, `prompt.already_completed`) are registered in `agent-core-v2` (`prompt/errors.ts`) and in `packages/protocol` (`error-codes.ts`), then mapped in the route's `sendMappedError` — including the idempotent `prompt.already_completed``40903 { data: { aborted: false } }`.
**The lesson.** When the v1 contract and the v2 domain disagree, add an adapter (LegacyService) at the edge; do not let the wire contract leak into the native domain. The two surfaces share the protocol schema but not the Service.
## Migration checklist
Before submitting a server-align change:
- [ ] Surface chosen deliberately: `/api/v1` mirror for a v1-matched endpoint, `/api/v2` for a new native capability (both if needed).
- [ ] For a `/api/v1` mirror, the route matches the established v1 contract (protocol schema + sibling routes) path-for-path, verb-for-verb, action-for-action.
- [ ] Request and response schemas come from `@moonshot-ai/protocol` (`packages/protocol/src/rest/<resource>.ts`); 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 `<domain>Legacy` / `I<Domain>LegacyService` 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.
- [ ] Domain error codes registered in `agent-core-v2`; wire codes registered in `packages/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; schema tests in `packages/protocol` added/updated.
- [ ] `lint:domain` passes; the LegacyService did not invert scope or domain direction.
## Red lines (this subskill)
- One wire schema, one home: `packages/protocol`. Never re-declare a v1 wire schema inline in server-v2.
- A `/api/v1` mirror route must keep every existing schema field's name, type, and semantics; only optional additions are allowed. A different shape belongs on `/api/v2`, not on the mirror.
- Do not distort the native v2 Service to satisfy a v1 quirk — add a `<domain>Legacy` edge adapter instead. The native Service serves the v2 architecture; the LegacyService serves the wire contract.
- A LegacyService is still a v2 Service: it follows scope, domain-direction, and DI rules. "Edge adapter" describes its role, not an exemption.
- The protocol schema (`packages/protocol/src/rest/<resource>.ts`) plus the existing mirror routes are the spec for a `/api/v1` route — match them; do not re-derive the wire shape from the v2 domain model or from memory.
- Register every new error code in **both** `agent-core-v2` and `packages/protocol`; an unmapped code is a wire break.
- Events stream over WS (`listen`), never over the REST mirror; do not invent REST polling for something v1 pushed as an event.

View file

@ -1,341 +0,0 @@
# Topic — Service authoring
How to write a Service in `packages/agent-core-v2`: file layout, naming, what goes in the contract vs the impl, interface style, constructor / field conventions, events, multi-Service domains, and the comment rules. This is the day-to-day reference for stage 3 (implement.md covers the DI *mechanics*; this file covers the *authoring details*).
## File layout
One folder per domain, **camelCase**: `session/`, `sessionActivity/`, `contextMemory/`, `toolDedup/`. Inside, six kinds of files:
```text
<domain>/
├── <name>.ts ← interface file: exactly one IXxx + its createDecorator + the types it owns
├── <name>Service.ts ← impl file: exactly one class + exactly one registerScopedService(...)
├── <concern>.ts ← pure function(s): no Service suffix, no class, no registration
├── <targetDomain>.ts ← contribution file (common): registers into another domain's extension point
├── <what>.contrib.ts ← contribution file (uncommon / ad-hoc)
└── <domain>.types.ts ← shared types that no single interface owns
```
- **Strictly one service per file.** An interface file holds exactly one injectable interface and exactly one `createDecorator(...)`; an impl file holds exactly one service implementation class and exactly one `registerScopedService(...)`. No exceptions for "tightly-coupled" groups: even same-scope collaborators each get their own `<name>.ts` + `<name>Service.ts` pair.
- **Scope is in the filename.** `session*.ts` = Session, `agent*.ts` = Agent, no scope prefix = App (see [Naming](#naming)). The header comment restates the same scope.
- 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.
## Naming
### Interfaces and classes
| 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) |
| Class | the interface name minus the leading `I`, plus `Service` if it does not already end in `Service`; `implements` the interface | `SessionLogService implements ISessionLogService`, `AppendLogStoreService implements IAppendLogStore` |
| Decorator string | lowerCamelCase of the interface name minus the leading `I`; **globally unique and stable** (it surfaces in `CyclicDependencyError.path` and "no service registered" errors) | `createDecorator<ISessionLogService>('sessionLogService')` |
| Model / non-service types | PascalCase, no `I` prefix | `SessionMeta`, `LogEntry`, `ConfigSection` |
The scope prefix makes a service's lifetime readable from its name. App services carry **no** prefix (App is the default, longest-lived tier); Session and Agent services always carry `Session` / `Agent`. The prefix applies to the interface, the class, and therefore the file names.
> Do **not** use the scope prefix to re-merge domains by lifetime. `IAgentEntityService`, `IAgentDataService`, and `ISessionEntityService` are still banned — the prefix marks lifetime, the rest of the name must still be the real owning domain (`IBackgroundTaskEntityService`, `ISessionMetadata`, `IPermissionRulesService`). See [domain-boundaries.md](domain-boundaries.md).
### File names
File names derive from the interface / class names so that scope and role are visible in the tree:
| File kind | Rule | Example (interface → file) |
|---|---|---|
| Interface file | interface name minus leading `I`, minus trailing `Service` if present; acronym-aware lowerCamelCase | `ISessionLogService``sessionLog.ts`; `IAppendLogStore``appendLogStore.ts`; `ILogService``log.ts` |
| Impl file | the class name; acronym-aware lowerCamelCase | `SessionLogService``sessionLogService.ts`; `AppendLogStoreService``appendLogStoreService.ts` |
| Pure-function file | the function / concern name; no `Service` suffix | `formatLogEntry.ts`, `levelEnabled.ts` |
| Contribution file (common) | the **target** domain name | `config.ts` (registers a config section), `tool.ts`, `flag.ts` |
| Contribution file (uncommon) | `<what>.contrib.ts` | `slackWebhook.contrib.ts` |
| Shared-types file | `<domain>.types.ts` | `log.types.ts` |
| Errors file | `<name>.errors.ts` | `appendLogStore.errors.ts` |
Acronym-aware lowerCamelCase lowercases a leading acronym as a group: `ILLMRequester``llmRequester.ts`, `IWSGateway``wsGateway.ts`, `IOAuthToolkit``oauthToolkit.ts`, `IAgentRPCService``agentRpcService.ts`.
Because the impl class always ends in `Service` and the interface file never does, the two files of one service never collide — even for `Store` / `Registry` / `Resolver` interfaces (`IAppendLogStore``appendLogStore.ts` + `appendLogStoreService.ts`).
## The contract file (`<domain>.ts`)
Holds the public surface of the domain. A typical contract:
```ts
/**
* `greet` domain (Ln) — one-line role.
*
* Defines the `Greeting` model and the `IGreeter` used by … Bound at … scope.
*/
import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation';
export interface Greeting { // model — no _serviceBrand
readonly message: string;
}
export interface IGreeter { // injectable service — carries _serviceBrand
readonly _serviceBrand: undefined;
hello(): Greeting;
}
export const IGreeter: ServiceIdentifier<IGreeter> =
createDecorator<IGreeter>('greeter');
```
What belongs here:
- **Model types** (`type` / `interface`) the domain exposes — `SessionMeta`, `LogEntry`, `ConfigSection`.
- **Service interface(s)** — the contract consumers depend on.
- **Decorator(s)** — one `createDecorator` per injectable service.
- **Helper types and pure functions** tightly bound to the contract — e.g. option bags, `satisfies`-checked seeds, predicate functions like `levelEnabled`.
### Which interfaces carry `_serviceBrand`
Only interfaces used as a **DI token** carry `readonly _serviceBrand: undefined`. Everything else does not:
- ✅ Service interface resolved via `@IX` / `accessor.get(IX)` → carries `_serviceBrand`.
- ❌ Base interface extended by a service (e.g. `ILogger` extended by `ILogService`) → no `_serviceBrand`.
- ❌ Plain model / data interface (`LogEntry`, `SessionMeta`) → no `_serviceBrand`.
```ts
export interface ILogger { // base interface — no brand
info(message: string): void;
}
export interface ILogService extends ILogger { // DI token — branded
readonly _serviceBrand: undefined;
setLevel(level: LogLevel): void;
}
```
## Interface style
- **Sync methods** return a concrete type; **async methods** return `Promise<T>`. Do not wrap a sync return in `Promise`.
- **Readonly fields** for immutable exposed state: `readonly ready: Promise<void>`, `readonly modelAlias: string | undefined`.
- **Optional members** with `?`: `flush?(): Promise<void>`, `close?(): Promise<void>`.
- **Generics** where the caller supplies the shape: `get<T = unknown>(domain: string): T`.
- **Extend** a base interface to share method groups: `interface ILogService extends ILogger`.
- **Events** as `readonly onDid…` / `onWill…` properties typed `Event<T>` — see [Events](#events).
```ts
export interface IConfigService {
readonly _serviceBrand: undefined;
readonly ready: Promise<void>;
readonly onDidChange: Event<ConfigChangedEvent>;
get<T = unknown>(domain: string): T;
set(domain: string, patch: unknown): Promise<void>;
reload(): Promise<void>;
}
```
## The impl file (`<domain>Service.ts`)
Holds the concrete class(es) and the top-level registration. A typical impl:
```ts
/**
* `greet` domain (Ln) — `IGreeter` implementation.
*
* … collaborators as roles ("logs through `log`") … Bound at App scope.
*/
import { InstantiationType } from '#/_base/di/extensions';
import { LifecycleScope, registerScopedService } from '#/_base/di/scope';
import { ILogService } from '#/log';
import { type Greeting, IGreeter } from './greet';
export class Greeter implements IGreeter {
declare readonly _serviceBrand: undefined;
constructor(@ILogService private readonly log: ILogService) {}
hello(): Greeting {
this.log.info('hello');
return { message: 'hi' };
}
}
registerScopedService(LifecycleScope.App, IGreeter, Greeter, InstantiationType.Eager, 'greet');
```
What belongs here:
- **Imports**`InstantiationType` from `'#/_base/di/extensions'`; `LifecycleScope` + `registerScopedService` from `'#/_base/di/scope'`; collaborators via the `#/<domain>` alias; the contract's types + decorator via a relative `./<domain>` import.
- **Class**`XxxService implements IXxxService`, with `declare readonly _serviceBrand: undefined`.
- **Helper classes / functions** used only by this impl (e.g. a built-in writer, an `extractError` helper) — co-located in the same file.
- **Top-level `registerScopedService(...)`** — one per Service the file owns; importing the impl file runs the registration.
## Constructor conventions
- Declare every dependency with `@IX` on a constructor parameter.
- Use `private readonly` (or `protected readonly`) to store a used dependency as a field.
- For an injected dependency the class does **not** directly use (e.g. passed through, or only needed to force construction order), drop the visibility modifier and prefix with `_`: `@IEventService _event: IEventService`.
- Service parameters and static parameters may both appear; the ordering rule depends on how the object is created — see below.
### Parameter order: scoped service vs `createInstance`
- **`registerScopedService` services** — the container injects only the `@IX` parameters; any static parameters must have defaults and are left at their default when the container builds the instance. Order is therefore not enforced by the container, but the common style is **`@IX` parameters first, optional static parameters after**:
```ts
constructor(
@ILogWriterService protected readonly writer: ILogWriterService,
private readonly bound: LogContext = {},
level: LogLevel = 'info',
) {}
```
- **`createInstance` objects** (non-singletons built with `instantiation.createInstance(Ctor, …staticArgs)`) — static parameters **must come first**, service parameters after, because the caller passes the static prefix positionally:
```ts
constructor(
private readonly input: string, // static — passed by caller
@ILogService private readonly log: ILogService, // service — injected
) {}
```
### Factory methods
A scoped Service may expose a factory method that returns a **new** instance of itself (or a related class) with extra context bound — e.g. `ILogger.child(ctx)` returns `new LogService(this.writer, { …this.bound, …ctx }, this._level)`. This is not a DI violation: it is an explicit factory, not a request for the container to build a Service. Do not use it to circumvent scope or singleton semantics.
## Fields and state
- `private readonly` for fields set once at construction (injected deps, derived config).
- `private _name` (underscore prefix) for mutable private state: `private _level: LogLevel`.
- `readonly` public fields only for immutable exposed state; prefer a getter (`get level()`) when the value can change.
- Keep state minimal — a Service owns only the state that matches its scope's identity (design.md §2). Anything else belongs in a different Service.
## Events
v2 has two distinct event mechanisms. Pick by audience:
### `Event<T>` / `Emitter` — typed property on a Service
Use when a Service exposes a typed event its consumers subscribe to. Lives in `'#/_base/event'`.
```ts
// contract
import type { Event } from '#/_base/event';
export interface IConfigService {
readonly onDidChange: Event<ConfigChangedEvent>;
}
// impl
import { Emitter, type Event } from '#/_base/event';
export class ConfigService extends Disposable implements IConfigService {
private readonly _onDidChange = this._register(new Emitter<ConfigChangedEvent>());
readonly onDidChange: Event<ConfigChangedEvent> = this._onDidChange.event;
private notify(changed: ConfigChangedEvent): void {
this._onDidChange.fire(changed);
}
}
```
Conventions:
- Back the public `Event<T>` with a private `Emitter<T>`, registered with `this._register(...)` so it disposes with the Service.
- Naming: `onDid…` for "happened" (past tense, after the fact); `onWill…` for "about to happen" (may allow `waitUntil` participation / veto — see `AsyncEmitter` / `IWaitUntil` in `'#/_base/event'`).
- The Delayed-instantiation Proxy preserves early `onDid…` / `onWill…` subscriptions (implement.md §5).
### `IEventService` — global pub-sub bus
Use to broadcast protocol events across domains. Lives in `'#/event'`.
```ts
export interface IEventService {
readonly _serviceBrand: undefined;
publish(event: ProtocolEvent): void;
subscribe(handler: (event: ProtocolEvent) => void): IDisposable;
}
```
Inject `@IEventService` and `publish(...)`; `subscribe(...)` returns an `IDisposable` to register with `this._register(...)`. This is the bus for "a fact happened, react if you care" (design.md §4) — not for typed per-Service events.
## Multi-Service domains
A domain may define several Services. Each Service gets its own pair of files regardless of scope or coupling:
- **One pair per Service**`<name>.ts` for the contract + `<name>Service.ts` for the implementation.
- **Different scopes** → the scope prefix in the Service name makes this obvious (`logService.ts` for App `ILogService`, `sessionLogService.ts` for Session `ISessionLogService`).
- **Same interface, multiple role tokens** (e.g. `IAtomicDocumentStore` and `IAtomicTomlDocumentStore` share one interface type but are distinct DI tokens) → each token is its own Service identity and must be registered and resolved independently.
There is no `index.ts` barrel: consumers import each contract/impl from its precise leaf path (e.g. `import { ILogService } from '#/log/log'`), never the domain directory.
## No barrel — the package entry loads leafs precisely
A domain has **no `index.ts` barrel**. Its files are the contract leaf (`<name>.ts`) and the impl leaf (`<name>Service.ts`), and consumers import the precise file — never the directory:
```ts
import { IGreeter, type Greeting } from '#/greet/greet';
```
Self-registration is unchanged: `greetService.ts` keeps its top-level `registerScopedService(...)`. The package entry `src/index.ts` loads the domain's leafs precisely — `export *` for the contract, a side-effect `import` for the impl — one line per leaf:
```ts
// src/index.ts
export * from './greet/greet';
import './greet/greetService';
```
Importing the package therefore fires every `register*` side effect, exactly as the old per-domain barrels did. When you add a new domain, write the contract + impl leafs (with their top-level `register*`), then add the leaf path(s) to `src/index.ts`. **Do not create an `index.ts`.**
- Load the impl file too — its top-level `registerScopedService(...)` only runs when the module is imported.
- `export *` helper modules only if they are part of the domain's public surface.
- Each leaf's file-header comment still names the domain, scope, and (for impls) the `register*` binding it owns.
## Comments
- **File-header comment is mandatory** and the only place comments live (orient.md). State the identity line, the role, collaborators (impls), and scope.
- **Methods and fields carry no comments by default.** Well-named identifiers and types say *what*; the code is the source of truth for *how*.
- Write an inline comment only when the *why* is non-obvious (a hidden constraint, a subtle invariant, a workaround). One short line.
- For unimplemented stubs, throw `NotImplementedError('feature')` rather than `throw new Error('TODO: …')` (errors.md).
## Complete minimal example
```ts
// greet/greet.ts
import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation';
export interface Greeting { readonly message: string; }
export interface IGreeter {
readonly _serviceBrand: undefined;
hello(): Greeting;
}
export const IGreeter: ServiceIdentifier<IGreeter> = createDecorator<IGreeter>('greeter');
```
```ts
// greet/greetService.ts
import { InstantiationType } from '#/_base/di/extensions';
import { LifecycleScope, registerScopedService } from '#/_base/di/scope';
import { type Greeting, IGreeter } from './greet';
export class Greeter implements IGreeter {
declare readonly _serviceBrand: undefined;
hello(): Greeting { return { message: 'hi' }; }
}
registerScopedService(LifecycleScope.App, IGreeter, Greeter, InstantiationType.Eager, 'greet');
```
```ts
// src/index.ts
export * from './greet/greet';
import './greet/greetService';
```
## Red lines (this topic)
- One folder per domain, camelCase; one service per file pair: contract `<name>.ts` + impl `<name>Service.ts`; **no `index.ts` barrel**`src/index.ts` loads each leaf file precisely.
- Exactly one injectable interface and one `createDecorator(...)` per contract file.
- Exactly one service implementation class and one `registerScopedService(...)` per impl file.
- `IXxxService` / `XxxService` naming; decorator string is lowerCamelCase, globally unique, and stable.
- Name Services by owning domain, never by scope (`IAgentEntityService`, `ISessionEntityService`).
- `_serviceBrand` only on interfaces used as a DI token — never on base interfaces or plain models.
- Sync methods return concrete types, async return `Promise<T>`; do not `Promise`-wrap sync work.
- `createInstance` objects put static parameters before service parameters; scoped services put `@IX` parameters first (static params need defaults).
- Never `new` a `@IService`-carrying Service — except inside an explicit factory method, which is not a DI request.
- Events: typed per-Service event → `Event<T>`/`Emitter` from `'#/_base/event'`; cross-domain broadcast → `IEventService` from `'#/event'`.
- `src/index.ts` must import/export every leaf file (including the impl) so each `register*` side effect runs.
- File-header comment only; methods/fields carry no comments by default; stubs throw `NotImplementedError`.

View file

@ -1,95 +0,0 @@
# Topic — Telemetry
Telemetry infrastructure for agent-core-v2: how business services emit events, how context propagates, and how events reach a destination through appenders.
Telemetry is a **layer-1 root** domain (alongside `log`): pure `App` scope, stateless, no business-domain dependencies. It is a thin facade — enrichment, batching, and transport belong to the appenders, not to this layer.
## Where things live
- `src/app/telemetry/telemetry.ts`: contract — `ITelemetryService` (facade), `ITelemetryAppender` (destination), `TelemetryProperties`, `nullTelemetryAppender`, and `TelemetryServiceOptions`.
- `src/app/telemetry/events.ts`: event registry — `telemetryEventDefinitions` pairs every business event's property type with review metadata (owner / purpose / per-property comment); the single source of truth for `track2`.
- `src/app/telemetry/telemetryService.ts`: `TelemetryService` impl + `registerScopedService(LifecycleScope.App, …)`.
- `src/app/telemetry/consoleAppender.ts`: `ConsoleAppender` — echoes events to a log function (dev / debug).
- `src/app/telemetry/cloudAppender.ts`: `CloudAppender` — sanitizes + PII-cleans properties, batches + enriches + posts to the telemetry endpoint.
- `src/app/telemetry/cloudTransport.ts`: `CloudTransport` — HTTP transport behind `CloudAppender`.
- `src/app/telemetry/privacy.ts`: outbound PII redaction (`cleanTelemetryProperties`) — URLs, emails, tokens, and absolute file paths become `<REDACTED: ...>` labels; `node_modules/` tails are kept.
## Emitting events (business services)
Inject `ITelemetryService` and call `track2` with a registered event:
```ts
import { ITelemetryService } from '#/app/telemetry/telemetry';
constructor(@ITelemetryService private readonly telemetry: ITelemetryService) {}
this.telemetry.track2('cron_fired', { task_id: taskId, coalesced_count: 0, stale: false, buffered: false, recurring: true });
```
`track2` is checked against the registry in `events.ts` at compile time: the event name must be a key of `telemetryEventDefinitions`, and the properties must match the registered interface exactly (extra or missing keys are compile errors). **New events must be registered first** — add a properties interface and a `defineTelemetryEvent<P>({ owner, comment, properties })` entry documenting every property. Naming: snake_case for events and properties, unit suffixes (`_ms` / `_count` / `_bytes`), no user content or file paths; `test/app/telemetry/events.test.ts` enforces the conventions. The low-level `track` remains for appender plumbing and tests only.
`TelemetryService.track` merges the bound context into the properties and fans the event out to every registered appender. A single throwing appender is isolated via `onUnexpectedError` and never blocks the rest.
### Context (sessionId / agentId / turnId)
The service carries a bound context (`sessionId` / `agentId` / `turnId`) that is merged into every event. Bind it at construction or derive a scoped view:
```ts
const child = telemetry.withContext({ agentId: 'main', turnId: 't1' });
child.track2('tool_call', { turn_id: 1, tool_call_id: 'c1', tool_name: 'bash', outcome: 'success', duration_ms: 12 }); // carries sessionId + agentId + turnId
```
`withContext(patch)` returns a new service sharing the same appenders; per-call properties override bound context on key collision. `setContext(patch)` mutates the bound context in place and propagates to appenders that implement `setContext`.
## Appenders (destinations)
An appender is the destination an event is fanned out to. It is **not a DI Service** — it is a plain object implementing `ITelemetryAppender`, held by `TelemetryService`.
```ts
export interface ITelemetryAppender {
track(event: string, properties?: TelemetryProperties): void;
withContext?(patch: TelemetryContextPatch): ITelemetryAppender;
setContext?(patch: TelemetryContextPatch): void;
flush?(): Promise<void> | void;
shutdown?(): Promise<void> | void;
}
```
Built-in appenders:
- `ConsoleAppender``[telemetry] <event> <json>` to a log function (default `console.log`); options `prefix` / `pretty` / `log`.
- `CloudAppender` — batches events, enriches with common context (`app_name` / `version` / `platform` / …), and posts to `https://telemetry-logs.kimi.com/v1/event` through `CloudTransport` (Bearer auth, retry, on-disk fallback). Options: `homeDir` / `deviceId` / `sessionId?` / `appName` / `version` / `uiMode?` / `model?` / `getAccessToken?` / `endpoint?` / `flushThreshold?` / `flushIntervalMs?`.
### Registering appenders (bootstrap)
Appenders are added after the App scope exists, by resolving the service and calling `addAppender`:
```ts
const app = createAppScope();
const telemetry = app.accessor.get(ITelemetryService);
telemetry.addAppender(new ConsoleAppender({ prefix: '[dev]' })); // dev echo
telemetry.addAppender(new CloudAppender({ // production
homeDir, deviceId, sessionId,
appName: 'kimi-code', version, uiMode: 'shell', model,
getAccessToken: () => auth.getCachedAccessToken(KIMI_CODE_PROVIDER_NAME),
}));
```
`addAppender` returns an `IDisposable` that removes the appender when disposed. `setAppender(appender)` resets to a single appender (mainly for tests). `removeAppender(appender)` drops one.
> There is no production bootstrap wired yet — `TelemetryService` defaults to `[nullTelemetryAppender]`, so `track(...)` is a no-op until `addAppender` is called at startup.
## Lifecycle
- `setEnabled(false)` drops `track` (service-level switch); `setEnabled(true)` resumes. `flush` / `shutdown` are unaffected by the switch.
- `flush()` / `shutdown()` fan out to all appenders concurrently; a single rejecting appender is swallowed. Await `shutdown()` before process exit so buffered events (e.g. in `CloudAppender`) are sent.
## Red lines (this topic)
- Business services depend only on `ITelemetryService` — never import an appender class.
- Telemetry is layer-1 root: do not inject any business-domain service into it, and do not move it off `App`.
- Appenders are plain `ITelemetryAppender` objects, not DI Services — register them with `addAppender`, never via `registerScopedService`.
- `track` is fire-and-forget and must not throw; appender `track` must be synchronous — buffer and send asynchronously via `flush` / `shutdown`.
- Await `telemetry.shutdown()` before process exit when a buffering appender is registered.
- Keep event names stable; register every business event in `events.ts` and emit via `track2` — properties must be JSON-serializable primitives (non-primitives are dropped with a warning by `CloudAppender`).

View file

@ -1,262 +0,0 @@
# Stage 4 — Test
Exercise the **same path production uses**: a service is reached by its interface through the container, its `@IService` dependencies are resolved from the container, and — where the scope layer matters — through the scope tree. Tests that `new` a service and paper over its constructor with hand-rolled objects bypass that path and let the `registerScopedService(IX → Impl)` binding rot untested.
`@IService` parameter decorators run under vitest (the build uses `experimentalDecorators`), so fixtures declare dependencies exactly like production code. There is **no** `param()` helper, no manual `(Id as …)(Ctor, '', 0)`, and no capturing `accessor` inside a constructor to synchronously `.get()` a peer.
## The one rule
**Resolve the system under test by its interface, through the container. Never call `new` on a production service whose constructor carries `@IService` dependencies.**
```ts
// ✅ resolve by interface — the IX → Sut binding is exercised
ix.set(IMessageService, new SyncDescriptor(MessageService));
const svc = ix.get(IMessageService);
// ❌ construct the implementation directly — the registration is never run
const svc = new MessageService(stubContext);
```
Resolving by interface is what makes `registerScopedService(ISut, Sut, …)` part of the test. Constructing the class directly (or via `ix.createInstance(Sut)`) tests the class in isolation but leaves the binding, the scope layer, and the delayed/eager flag unverified.
Pure functions, value objects, and services with **no** `@IService` dependencies may be constructed directly.
The only other exception is a test that genuinely needs **two independent instances** of the same service with different dependencies (e.g. constructing two `TurnService`s with different `ILoopRunner`s). A singleton-per-container resolution cannot produce both, so `ix.createInstance(Impl)` is acceptable there — annotate it with a comment explaining why.
## Two harnesses
Pick the harness by *whether the scope layer is part of what you are testing*.
| Under test | Harness | Resolve the SUT with |
|---|---|---|
| A single service's behavior (unit) | `TestInstantiationService` (flat) | `ix.get(ISut)` after `ix.set(ISut, new SyncDescriptor(Sut))` |
| Cross-scope wiring, or which layer a service lives in | `createScopedTestHost` (scope tree) | `host.<scope>.accessor.get(ISut)` |
### Unit harness — `TestInstantiationService`
Default for domain service unit tests. It is an `InstantiationService` that also implements `ServicesAccessor` (so you can `ix.get(...)` directly) and owns sinon (so `dispose()` restores stubs).
```ts
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { DisposableStore } from '#/_base/di/lifecycle';
import { createServices } from '#/_base/di/test';
import type { TestInstantiationService } from '#/_base/di/test';
import { registerRecordsServices } from '../records/stubs';
describe('XxxService', () => {
let disposables: DisposableStore;
let ix: TestInstantiationService;
beforeEach(() => {
disposables = new DisposableStore();
ix = createServices(disposables, {
base: [registerRecordsServices],
additionalServices: (reg) => {
reg.define(IContextService, ContextService); // 1. real collaborator, by interface
reg.define(IXxxService, XxxService); // 2. system under test, by interface
},
});
});
afterEach(() => disposables.dispose());
it('does the thing', () => {
const svc = ix.get(IXxxService); // 3. resolve by interface
expect(svc.thing()).toBe('…');
});
});
```
`createServices` builds the container from domain **service groups** plus per-test overrides (see Service groups). Reach for `ix.stub(...)` / `ix.set(...)` directly only inside an `it` when a single test needs to swap a registration:
- whole service, partial object: `ix.stub(IId, { method() { return … } })`;
- single method: `ix.stub(IId, 'method', value)` returns a sinon stub; `ix.spy(IId, 'method')` returns a spy;
- a prebuilt instance or descriptor: `ix.set(IId, instance)` / `ix.set(IId, new SyncDescriptor(Impl))`;
- when a collaborator's behavior must vary per test, model it as a `Test*Service` subclass whose methods read suite-scoped `let` variables rather than rebuilding the container each test.
### Scope harness — `createScopedTestHost`
Reach for this only when *which layer a service lives in* is itself the thing being asserted, or when the SUT reads from parent/child scopes. It builds the real `Scope` tree and resolves through it.
```ts
import { beforeEach, describe, expect, it } from 'vitest';
import { InstantiationType } from '#/_base/di/extensions';
import {
LifecycleScope,
_clearScopedRegistryForTests,
registerScopedService,
} from '#/_base/di/scope';
import { createScopedTestHost, stubPair } from '#/_base/di/test';
describe('XxxService (scoped)', () => {
beforeEach(() => {
_clearScopedRegistryForTests();
registerScopedService(
LifecycleScope.Agent,
IXxxService,
XxxService,
InstantiationType.Delayed,
'xxx',
);
});
it('resolves from the Agent scope with ancestor deps injected', () => {
const host = createScopedTestHost([stubPair(ILogService, stubLog())]);
const agent = host.child(LifecycleScope.Agent, 'main');
const svc = agent.accessor.get(IXxxService); // by interface
expect(svc.thing()).toBe('…');
host.dispose();
});
});
```
Always `_clearScopedRegistryForTests()` and re-register explicitly in `beforeEach`. Do not rely on a production module's top-level `registerScopedService(...)` side effect: import order then becomes part of the test, and another suite's `_clearScopedRegistryForTests()` can wipe it.
## Register the SUT by interface
Whichever harness you use, the SUT is registered under its interface (`ix.set(IX, new SyncDescriptor(Impl))` or `registerScopedService(scope, IX, Impl, …)`) and resolved by that interface. This is non-negotiable: it is the only thing that keeps the production registration honest.
A test that does `ix.createInstance(Impl)` is testing the class, not the service. Convert those (see Migration).
## Shared stubs
Hand-rolled stubs (`noopLog`, `noneEvent`, `unusedRecords`, …) must not be copied between test files. Each domain that owns a frequently-stubbed interface exports a stub from a `stubs.ts` **in the `test/` tree**, never from `src/`:
```text
test/log/stubs.ts → stubLog() / stubLogger()
test/turn/stubs.ts → stubTurn()
test/records/stubs.ts → stubAgentRecords()
test/environment/stubs.ts → stubEnvironment()
```
All test support lives under `test/` so test-only code stays out of the production source tree. Because `tsdown` builds from `src/index.ts`, anything under `test/` is unreachable from the entry and is never bundled into `dist/`.
Conventions:
- export a **factory** (`stubXxx()`), not a shared singleton, so tests cannot leak state through a stub;
- name it `stub<Interface>` — e.g. `stubAgentRecords`;
- the stub satisfies the full interface so the compiler, not a cast, guarantees it stays in sync;
- import it with a **relative path**`./stubs` from the same domain's tests, `../<domain>/stubs` from another domain. Never import stubs from `#/…` (that alias is for production `src/`) and never import one test file from another;
- a `stubs.ts` may import its domain's production types via `#/<domain>/…`.
If a stub is needed by two test files, it belongs in that domain's `test/<domain>/stubs.ts`.
## Service groups
Most unit tests stub the same handful of collaborators (`ILogService`, `IAgentRecords`, `IConfigService`, `ITelemetryService`, …). Rather than repeat `ix.stub(...)` lines in every `beforeEach`, each domain exports a `register*Services` function from its `stubs.ts` that registers the default test doubles for that domain:
```ts
// test/log/stubs.ts
export function registerLogServices(reg: ServiceRegistration): void {
reg.defineInstance(ILogService, stubLog());
}
```
`createServices(disposables, { base, additionalServices })` composes them:
- `base` — an ordered list of service groups. Each group's registrations are deduped (first writer wins), so groups supply safe defaults without clobbering each other.
- `additionalServices` — applied after `base`. Registrations here **overwrite** any base default, so a test can swap a stub for a spy, register the system under test, or supply a one-off collaborator.
```ts
ix = createServices(disposables, {
base: [registerLogServices, registerConfigServices, registerRecordsServices],
additionalServices: (reg) => {
reg.definePartialInstance(IAgentKaos, {}); // one-off collaborator
reg.define(IAgentRecords, spyRecords); // override a base default
reg.define(IXxxService, XxxService); // system under test
},
});
```
`ServiceRegistration` offers three verbs:
- `define(id, Ctor)` — lazy `SyncDescriptor`; the service is instantiated on first resolve. Use for real collaborators and the system under test.
- `defineInstance(id, instance)` — a fully-built instance (a fake such as `stubLog()`, or `new ConfigRegistry()`).
- `definePartialInstance(id, { ... })` — a partial mock; only the supplied members are provided. Use for collaborators the test does not exercise.
Conventions:
- a group registers the domain's services **as dependencies** (a fake, or a `{}` partial when no fake exists yet). When a service is the system under test, the test registers the real implementation via `additionalServices` and does not rely on the group's default for it;
- keep groups small and domain-local. A service that is almost always the system under test, or that every consumer configures differently, should not have a group — register it inline via `additionalServices`;
- import groups with a **relative path** (`../<domain>/stubs`), never from `#/…`.
`createServices` defaults to `strict: false` (missing dependencies warn rather than throw), matching `new TestInstantiationService()`. Pass `strict: true` to surface unregistered `@IService` dependencies.
## Declaring dependencies
Always use `@IService` constructor decorators — in fixtures and in production services alike.
```ts
// ✅
class Consumer {
constructor(@IGreeter private readonly greeter: IGreeter) {}
}
// ❌ no param() helper, no inline cast
class Consumer {
constructor(private readonly greeter: IGreeter) {}
}
param(IGreeter, Consumer, 0);
```
Because the decorator runs when the class is defined, the `createDecorator` identifier must be initialized **before** the class that uses it. Declare the identifier, then the class:
```ts
const IDep = createDecorator<IDep>('dep');
class Consumer {
constructor(@IDep private readonly dep: IDep) {}
}
```
For two services that depend on each other (a cycle), declare both identifiers first, then both classes, so neither class references an uninitialized binding.
Declare fixtures at module top, interface + decorator + implementation co-located, and keep `_serviceBrand` on the interface when it represents a real service — `GetLeadingNonServiceArgs` relies on the brand to tell service parameters apart from static ones. Pure throwaway fixtures may omit `_serviceBrand`.
## Lifecycle / teardown
One `DisposableStore` per suite. Add the **container** and any event subscriptions to it; dispose in `afterEach`.
```ts
beforeEach(() => { disposables = new DisposableStore(); /* … */ });
afterEach(() => disposables.dispose());
```
Do **not** add the system-under-test itself to the store. `TestInstantiationService` disposes every service it creates when the container is disposed, so `ix.get(IX)` instances are cleaned up automatically via `disposables.add(ix)`. Wrapping the SUT in `disposables.add(...)` would double-dispose it. For the same reason, do not call `svc.dispose()` at the end of a test unless you are asserting something about disposal itself.
Scope-host tests call `host.dispose()` in `afterEach` (or at the end of the `it`). Route teardown through the store so ordering is deterministic and nothing leaks when a test fails mid-way.
## Assertions and naming
- One behavior per `it`; describe observable behavior (`child shadows parent registration`), not implementation (`calls _getOrCreateServiceInstance`).
- For cycles, assert `CyclicDependencyError` and its `path` array (e.g. `['A', 'B', 'A']`), not merely `toThrow`.
- For disposal order, capture events in an array and assert the sequence (`['C', 'B', 'A']` — children before parents).
## Migrating existing tests
Most legacy tests build the SUT with `ix.createInstance(Impl)`. Converting one is mechanical:
1. import the interface (`IX`) and the descriptor;
2. register the SUT by interface — `reg.define(IX, Impl)` inside `additionalServices` (or `ix.set(IX, new SyncDescriptor(Impl))`);
3. replace `ix.createInstance(Impl)` with `ix.get(IX)`;
4. drop the `disposables.add(...)` wrapper around the SUT and any trailing `svc.dispose()` — the container disposes it;
5. replace any hand-rolled collaborator object with the domain's shared stub or service group (or add one to `test/<domain>/stubs.ts` if it does not exist);
6. delete now-unused imports.
Before / after:
```ts
// before
const svc = ix.createInstance(MessageService);
// after — registration in beforeEach additionalServices
reg.define(IMessageService, MessageService);
// after — resolution in the test body
const svc = ix.get(IMessageService);
```
## Red lines (this stage)
- Resolve the SUT by interface — never `new` a production service with `@IService` deps; prefer `ix.get(IX)` over `ix.createInstance(Impl)`.
- Shared stubs live in `test/<domain>/stubs.ts` (never `src/`); import by relative path, never `#/...`.
- Scope tests call `_clearScopedRegistryForTests()` and re-register explicitly in `beforeEach`; do not rely on production import-order side effects.
- One `DisposableStore` per suite; add the container, dispose in `afterEach`; do not add the SUT itself.
- Declare fixture dependencies with `@IService`; initialize `createDecorator` identifiers before the classes that use them.

View file

@ -1,32 +0,0 @@
# Stage 5 — Verify & submit
Run the guards and re-scan the red lines before submitting.
## Commands
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 typecheck``tsc -p tsconfig.json --noEmit`.
- `pnpm --filter @moonshot-ai/agent-core-v2 test``vitest run`.
## Changesets (when the change ships through the CLI)
If the change is user-facing and ships through the CLI, generate a changeset with the repository's `gen-changesets` skill (root `AGENTS.md` workflow). `agent-core-v2` is an internal package; if its change enters the CLI bundle, the changeset lists `@moonshot-ai/kimi-code` and describes the real change — do not present an internal-only change as a user-facing feature. Never write a `major` bump without explicit user confirmation.
## Pre-submit checklist
Walk the stages you touched and confirm:
- **Design** — scope follows state identity; no `Map<sessionId, …>` at `App`; dependency arrows do not make a foundational layer know an upstream one; no cycle was routed around.
- **Implement** — no `new` on `@IService`-carrying classes; `@IX` on constructor params only (service params after static params); interface + impl carry `_serviceBrand`; decorator names unique; coded errors only; flags for unreleased behavior.
- **Test** — SUT resolved by interface; stubs under `test/`; scope tests re-register after `_clearScopedRegistryForTests()`; teardown through one `DisposableStore`.
- **Files** — header comments describe role + scope only; registration runs from the impl file's top level; the new domain is exported from `src/index.ts`.
Then re-read the [global red lines](SKILL.md#global-red-lines) once — they catch most cross-stage mistakes in a single scan.
## Red lines (this stage)
- Do not skip `lint:domain` — it is the only automated check for the dependency-direction 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.

View file

@ -1,21 +0,0 @@
---
name: agent-core-review
description: Use ONLY for code review and test write/review guidance in `packages/agent-core-v2` (the DI × Scope agent engine). Does NOT apply to the legacy `packages/agent-core` or to any other package — for those, do not load this skill. Groups the review and testing lenses used for agent-core-v2 — `slop` (single-level-of-abstraction / layered error-handling review, invoked only on explicit request) and `test` (contract-driven per-test rules for both authoring and reviewing tests). Apply the sub-skill that matches the task; do not apply `slop` unprompted.
has-sub-skill: true
---
# kc-review
> **Scope: `packages/agent-core-v2` only.** These lenses are calibrated for the v2 engine (DI × Scope). Do not apply them to the legacy `packages/agent-core` or to other packages.
A bundle of the lenses used when reviewing or testing `packages/agent-core-v2`. Each sub-skill is self-contained; invoke the one that matches the task.
## Sub-skills
- **`slop/`** — Single Level of Abstraction & layered error handling. A *review dimension*: a function should read as a straight-line description of its own layer, with errors handled above or below. The agent reports detections and measurements, not severity grades. **Invoke only when the user explicitly asks for this lens** — do not apply it unprompted to general reviews or refactors.
- **`test/`** — Per-test rules behind "test the contract / responsibility, not the implementation," serving two modes. **Write mode:** author a test — one behavior per `it`, drive through the public surface, stub only the true external boundary, control time/config via documented knobs, keep tests clear, isolated, and refactor-resilient (CCCR). **Review mode:** audit existing tests against the same rules and report findings with `file:line`. Use when writing, modifying, or reviewing tests, or when asked how to write a good single test.
## Routing
- Reviewing code structure / abstraction layers / where error handling belongs → `slop` (only on explicit request).
- Writing or modifying tests, reviewing test quality, or advising on a single test → `test`.

View file

@ -1,133 +0,0 @@
---
name: slop
description: Invoke only when the user explicitly asks to review code through the "single level of abstraction / layered error handling" lens — a function does only its own layer's business logic while errors are handled above or below. The agent reports detections, raw-count measurements, and move directions. Apply only when the user explicitly requests this lens.
---
# Single Level of Abstraction & Layered Error Handling
North star: **a function should read as a straight-line description of what its own layer does. Anything that is not that — input validation, error handling, error-to-response translation, logging, retries, low-level mechanics — belongs to a layer above or below, not inline.**
This is a review dimension, not a hard rule. See "Exemption checklist" at the end.
## Scope of this skill — detect and measure
The agent applying this lens is a **sensor**. Its one job is to report *whether* a function mixes levels and *by how much*; deciding *how serious* it is belongs downstream. Severity labels (`Block` / `Request changes` / `Nit`) compress a continuous quantity into an uncalibrated three-point scale and are the main source of review-to-review variance, so they are produced downstream — by a deterministic rubric, anchored examples, or a human — from the facts the agent reports.
The agent's output is exactly these four things:
- **Detection (yes/no):** does this statement / block / function violate a rule of the lens?
- **Measurement (raw factual counts only):** mechanically countable quantities — body size, control-flow keywords, named syntactic shapes (see "Quantify"). Anything that first requires classifying a line (core/foreign, happy/error, high/low level) is recorded under detection, not here.
- **Direction (where it moves):** for each foreign concern, the destination layer — push **down** into a value / parser / infra helper, or push **up** into the edge handler.
- **Exemption flags:** which items, if any, hit the exemption checklist — recorded, not weighed.
Severity grades, merge/block verdicts, and "is splitting worth it" calls live downstream, derived from the four items above.
## When to use
Apply this lens only when the user asks for it explicitly (for example "用单一抽象层次审视一下", "check whether this function does too much", "errors should be handled above/below, right?"). Leave general reviews and refactors to other lenses unless the user names this one.
## The principle
One function, one level of abstraction, one responsibility. Three mutually reinforcing rules:
1. **Single Level of Abstraction (SLAP).** Every statement inside a function sits at the same conceptual level. High-level intent ("reserve inventory, charge payment, create the order") must not be interleaved with low-level mechanics (building headers, escaping strings, opening sockets, parsing bytes). If some lines read as "what" and others as "how", they belong in different functions.
2. **Error handling is its own concern (Clean Code).** A function either does the work or handles the error — not both. Business logic describes the happy path and *signals* failure (throw or return a result); the catch, mapping, logging, and recovery live in a dedicated handler, usually one layer up. Prefer exceptions / result types over threaded check-and-return ladders that interrupt the main flow.
3. **Separation of concerns by layer.** Each layer owns exactly one kind of knowledge: low-level code knows formats and protocols; mid-level code knows business rules; edge code knows the outside world (HTTP / CLI / UI). A function that knows two of these at once is leaking a layer.
The combined test: **could you explain this function to someone without using the word "and"?** If the explanation is "it reserves stock AND validates the email format AND maps the error to a status code AND logs to metrics", it is doing more than its layer's job.
Concerns that usually do **not** belong in a business function:
- Format / range / null validation that a lower value or parser could guarantee once.
- Mapping domain failures to an external protocol (status code, exit code, UI message) — that is the edge layer's job.
- Catch-and-swallow, retry loops, backoff, timeout, circuit breaking around a single call — infrastructure, push down.
- Cross-cutting telemetry / log / metric noise woven through every step — extract or push to a wrapper.
- Check-and-return ladders that occupy more space than the business core — replace with signal + a handler above.
## Methodology — fixing a function that violates it
Work top-down. Never start by shuffling lines.
1. **Name the level.** In one sentence, write what this function is for at its own layer. If you cannot, the function has no clear level — split before polishing.
2. **Classify every statement.** Tag each line or block as: **core** (this layer's business), **down** (a detail a lower abstraction should own), **up** (a concern an upper / edge layer should own), or **cross-cutting** (log / metric / retry). Unlabeled lines are where the mess hides — do not "just leave them".
3. **Decide down vs. up for each foreign item.**
- Push **down** when it is a guarantee a lower building block can provide: a value that can only be constructed valid, a parser that returns a typed result, an infra helper that already retries. The business function then assumes validity and stays clean.
- Push **up** when it is about translating or reacting to failure for the outside world: status codes, messages, exit codes, aggregation of many errors. The edge layer catches once and maps; business code just signals.
- Rule of thumb: if removing it would change what the business rule says, it is core and stays; if removing it only changes how a failure is reported or a detail is computed, it moves.
4. **Extract, do not interleave.** Pull each foreign concern into its own named function or layer. Keep the original function as a readable sequence of same-level calls. For error handling specifically, separate the work body from the recovery body into distinct functions so neither clutters the other.
5. **Signal, do not handle, in the middle.** Mid-layer business functions throw / return and let the right layer react. Do not catch-and-log-and-continue in business code unless continuing is itself the business rule.
6. **Re-read for level.** After the moves, every remaining line should be explainable at the same altitude. If not, repeat from step 1.
Keep the change minimal: move the smallest thing that restores the level. Do not invent abstractions, frameworks, or generic "handler" machinery beyond what the function actually needs. Three straight-line, same-level calls beat a premature pipeline.
## Review method — applying the lens to a diff
Read each changed or touched function and, for each check, record only: **the hit (yes/no) plus evidence (`file:line`)**, and — where the check points at a construct — a raw factual count from "Quantify".
1. **Altitude check.** Are all lines at the same level of abstraction? Record each place where a "what" line is immediately followed by a "how" block (or vice versa) inside the same function, with `file:line`.
2. **Happy-path check.** Can you read the business intent top to bottom without stepping through error branches? Record whether error handling sits inline between business steps (yes/no + `file:line`), supported by raw counts from "Quantify" (e.g. number of `catch` clauses, `continue` statements).
3. **Ownership check.** For each validation, catch, mapping, log, retry: is this layer the rightful owner, or is it borrowed from above / below? Record each borrowed item with `file:line` and its destination (down / up), using the rules from the methodology.
4. **Layer-leak check.** Does a business function mention an external protocol (status code, exit code, UI text, wire field)? Does an edge function contain a business rule? Record each leak candidate with `file:line` and whether it names an *external* protocol or an *internal* domain shape.
5. **Explanation test.** Describe the function in one sentence with no "and". Record whether "and" was needed; if so, list the proposed split as candidate moves (down / up).
### Quantify — report only raw factual counts
Report only quantities that can be counted **mechanically from the text**. Anything that first requires classifying a line (core vs foreign, happy-path vs error-handling, high-level vs low-level) is recorded under detection (the five checks above) as evidence, not as a number here.
Report, per function:
- **Body size** — lines and/or statements of the function body; state the basis (e.g. "statements, excluding lone braces").
- **Control-flow keywords (raw counts)**`if`, `continue`, early `return`, `throw`, `try` / `catch` / `finally`, `await`, loops (`for` / `while` / `.forEach`).
- **Named syntactic shapes a check points at** — when a check cites a construct, count it verbatim and name the exact token: e.g. number of object literals, string literals, `.trim()` calls, `.length` reads, `origin.` property reads, spread `[...x]` operations.
- **Recovery presence (raw)** — number of `catch` clauses, and number of log / metric calls inside them.
Quantities that embed a prior classification — out-of-level vs core counts, guard-to-core ratios, happy-path vs error-handling volume, "repeated boundary checks a lower layer could guarantee once", "low-level literals in a high-level flow" — are captured as evidence under the relevant check (`file:line` + the verbatim tokens). A downstream rubric derives any ratio from those raw facts.
### Red flags
Record each as evidence (yes/no + `file:line`); these are candidates, not verdicts:
- A body that is mostly check-and-return / check-and-throw ladders around a thin core.
- A recovery block that logs, maps, and returns inline, sitting next to business steps.
- A function that both computes a value and decides how that value's failure is shown to the user.
- Low-level literals (byte offsets, header strings, format codes) inside a high-level workflow.
- A name that needs "And" / "Or" / "With" to be honest, or a name so vague ("handle", "process", "do") that it hides multiple levels.
- Catch-and-swallow that hides a failure the caller needed to see.
- Defensive null / format checks repeated at every call site instead of guaranteed once at the boundary.
### Severity grading belongs downstream
The agent's facts (detections, raw counts, directions, exemptions) feed a downstream grade; the agent reports those facts and stops there. Grades compress a continuous quantity into an uncalibrated three-point scale and are exactly where identical evidence gets labeled differently across runs. Grading happens above the agent:
- A **deterministic rubric** — a versioned threshold table over the raw counts from "Quantify"; or
- **Anchored examples** — the reviewer judges relative to repo-known reference functions rather than against an absolute adjective like "materially"; or
- A **human**, for items that land near a threshold boundary.
If a downstream consumer still asks the agent for a grade, the agent returns the underlying facts and the threshold band it would fall under, with `confidence: low` on boundary cases; the grade itself is produced downstream.
### How to report findings
Report **evidence + direction**. Lead with the location and the level, then the proposed move. Prefer "this block is one level lower than the rest of the function (`file:line`) — move it **down** into X" over "this is ugly" or "this is a request-changes". The destination layer (down into a value / parser / infra helper, or up into the edge handler) is the actionable output and the deliverable. Attach the "Quantify" numbers and any exemption flags to each finding.
## Exemption checklist
This is a lens, not a law. For each foreign concern, check whether any exemption below applies and **record the hit (yes/no) plus the reason**. The agent records exemptions as facts; a recorded exemption is then used downstream to cap the grade (e.g. to `Nit`) deterministically.
- **Tiny function:** the function is small enough that splitting would add indirection with no reader benefit.
- **Foreign concern is the single job:** the "foreign" concern is in fact the function's one purpose — a dedicated error mapper, a validator, an infra wrapper, or an index-bookkeeping helper whose low-level arithmetic *is* its level.
- **Atomicity / correctness / performance:** the steps genuinely must stay together (e.g. a re-check after an `await` to guard state that may have changed).
- **Edge-translator role:** an edge / handler function whose job is to translate an external event into internal indices; naming the wire fields is its job.
Keep a split that would make the code harder to read as a recorded candidate for downstream review. When the evidence lands on an exemption boundary, record both sides and set `confidence: low`.
## Output contract
Return, per function, items 15 only:
1. **Level statement** — one sentence: what the function is for at its own layer.
2. **Per-check results** — for each of the five review checks: `hit: yes/no`, evidence `file:line`, and (only where the check points at a construct) a raw factual count.
3. **Measurements** — the raw factual counts from "Quantify".
4. **Exemptions** — checklist hits (yes/no + reason).
5. **Proposed moves** — for each foreign concern: `file:line` → destination (down into X / up into Y). This is the actionable deliverable.
Severity grades, block/merge verdicts, and "worth splitting" calls live downstream, derived from items 14. When a consumer asks for a label, hand back items 14 and the threshold band, with `confidence: low` on boundary cases.

View file

@ -1,115 +0,0 @@
---
name: test
description: Use when writing or reviewing tests, or when asked how to write a good single test. Encodes the per-test rules behind the "test the contract / responsibility, not the implementation" principle — name and structure one behavior per `it`, drive through the public surface, stub only true external boundaries, control time and config via documented knobs, and keep tests clear, isolated, and refactor-resilient. The same rules drive both authoring (write mode) and auditing existing tests (review mode).
---
# Tests — write & review
Per-test rules that operationalize one principle: **test the contract / responsibility, not the implementation**. This is the how-to for a single `it`, and the lens for reviewing one.
## Two modes, one rule set
- **Write mode** — authoring a test. Apply the rules below to produce it.
- **Review mode** — auditing an existing test or test diff. Apply the same rules as a checklist; report each violation with `file:line`, the rule it breaks, and the fix. See "Review mode" near the end.
The rules are identical in both modes — only the posture changes (produce vs. audit).
## Test contract, not implementation
- Drive the system through its **public control plane** and assert on **observable effects** (returned values, persisted state, emitted events, injected messages), never on source details.
- Resolve collaborators through their contract — the interface plus its identifier — not the module that binds a concrete implementation.
- Do not reach into private fields or add backdoors "for testing". If you feel the need, the seam is wrong — fix the design, not the test.
## One behavior per `it`
Each `it` covers exactly one responsibility / scenario. If the name needs "and", split it.
```ts
it('returns 401 when the caller is unauthorized', ...);
it('does not double-fire when the same tick repeats', ...);
```
## Name and structure
- `describe('<slice> (<responsibilities>)'` — name the **responsibility**, not the class.
- An `it(...)` reads as a sentence, but it must still encode three things — the **behavior / method**, the **state or condition**, and the **expected outcome**: `it('<behavior> when <condition>, <outcome>')`. A name like `does X when Y` with no result is too vague to fail usefully.
- Use spaces, not the Java-style `method_state_outcome` underscores — that convention exists only because Java test methods cannot contain spaces. A string-named test reads fine as a sentence.
- Good: `it('returns 401 when the caller is unauthorized')` · `it('advances the cursor and does not double-fire on a repeat tick')`
- Bad: `it('works')` · `it('handles auth correctly')` — no condition, no outcome
- Arrange / Act / Assert. A short `// Given` `// When` `// Then` is fine when it aids reading; do not paste it mechanically on trivial tests.
## Build a small rig
When several tests share setup, write a factory (`rig()`, `createHost()`, whatever fits the codebase) that returns the **smallest surface the test needs**. Tests reach into the rig; they do not rebuild the world each time. Keep the rig dumb: wiring only, no assertions.
## Stub only the real external boundary
Default to real collaborators wired the way production wires them. Stub the **minimum seam** that is genuinely external:
- A remote / model / service boundary — spy on the contract method (the interface), and capture what the system sends across it. Do not stand up the real external thing.
- Network / other-process boundaries — stub at the boundary, not the internals.
- Time, timers, jitter — use the documented control knobs the system exposes (env, an injected clock, a manual tick). Do **not** use fake timers or real `setTimeout` to drive time.
- Env / config knobs are usually snapshotted at bootstrap — set them **before** building the system under test, and restore them in `afterEach`.
## Keep tests DAMP and keep cause next to effect
- DAMP over DRY: use **literal expected values** in assertions; do not compute the expectation with the same logic as the code under test.
- Keep the key preconditions inside the `it` (or its rig), where the reader can see cause next to effect. Reserve `beforeEach` for cross-cutting plumbing (env snapshot, cleanup), not for hiding the scenario's setup.
```ts
// Good — the expected value is a literal the reader can check.
expect(discount).toBe(15);
// Bad — re-derives the expectation; mirrors the implementation.
expect(discount).toBe(price * rate);
```
## Assert only what is relevant
Assert the effect that proves the contract. Use matchers / partial-object matching to ignore incidental fields. Do not assert internal counters, call orders, or shapes the user cannot rely on.
## Isolate and clean up (no flakes)
Every test must be hermetic and order-independent. In `afterEach`:
- restore every mock / spy
- restore every env var you touched (snapshot in `beforeEach`)
- dispose the host / container and reset its reference
No dependence on wall-clock time, run order, or leftover on-disk state — give each scenario its own isolated identity / workspace when state persists.
## Quality bar: CCCR
Before finishing, check each test against:
- **Clarity** — a stranger can tell what broke from the failure message alone.
- **Completeness** — covers the responsibility's success, error, and boundary paths.
- **Conciseness** — no duplicate or speculative cases; one scenario per `it`.
- **Resilience** — survives an internal refactor with no test change (because it asserts contract, not implementation).
## Per-file scenario header
Start each test file with a short header comment: the **scenario**, the **responsibilities** asserted, the **wiring** (which collaborators are real vs. the single stubbed boundary), and how to run it.
## Review mode — auditing existing tests
Apply the rules above as a checklist against each test in scope (a file, a diff, or a named `it`). For every hit, report `file:line` + the rule it breaks + the fix; do not rewrite unless asked. Lead with the contract question: *what observable behavior does this test prove, and would it survive a refactor?*
Check, in order:
1. **Contract, not implementation** — asserts observable effects, not private fields, call order, or internal shapes the user cannot rely on.
2. **One behavior per `it`** — the name carries behavior + condition + outcome; "and" in the name means a split is owed.
3. **Boundary discipline** — only the true external seam is stubbed; time is driven by documented knobs, not fake timers / real `setTimeout`.
4. **DAMP expectations** — expected values are literals, not re-derived by the code under test's logic.
5. **Isolation** — mocks / spies / env / host restored in `afterEach`; no wall-clock, run-order, or leftover on-disk dependence.
6. **CCCR read-through** — Clarity, Completeness (success / error / boundary), Conciseness, Resilience.
Report findings as evidence + fix, e.g. "`foo.test.ts:42` asserts on `service.internalMap` (contract) — assert the returned value instead." If a test passes the lens, say so briefly; silence on a rule means it held.
## Quick checklist (write & review)
- Resolved through the contract; no concrete-impl import
- One behavior per `it`; name carries behavior + condition + outcome; AAA
- Stubbed only the true external seam; time via knobs, not fake timers
- Literal expectations; relevant assertions only
- Mocks / env / host restored in `afterEach`; hermetic, no flakes
- CCCR read-through done

View file

@ -11,23 +11,20 @@ description: Use when generating changesets in the kimi-code repository, includi
All other `@moonshot-ai/*` packages are treated as internal packages, including `@moonshot-ai/kimi-code-sdk`, `agent-core`, `kosong`, `kaos`, `kimi-code-oauth`, `kimi-telemetry`, and `migration-legacy`.
`@moonshot-ai/pi-tui` is a special internal package: it is a private fork (`private: true`) that is never published, but it keeps its own changelog through changesets. It is an exception to Core Rule 4 — see the dedicated section below.
## Core Rules
1. **Inspect the actual changes first.** Use `git status` / `git diff --name-only` to identify which packages were actually changed.
2. **List packages that changesets can release.** If a changed package is ignored in `.changeset/config.json`, do not put that ignored package in frontmatter together with a non-ignored package; changesets rejects mixed ignored/non-ignored frontmatter.
3. **Map ignored internal changes to the affected released package.** If an ignored internal package changes CLI output or behavior, list `@moonshot-ai/kimi-code` and describe the actual user-visible or release-artifact change in the changelog text.
4. **Internal package source changes that enter the CLI bundle must manually list the CLI.** `@moonshot-ai/kimi-code` inline-bundles `@moonshot-ai/*` source, but those internal packages are devDependencies from the CLI's perspective, so changesets will not automatically propagate bumps. If a change enters the CLI output, list `@moonshot-ai/kimi-code`.
- **Web app (`@moonshot-ai/kimi-web`) changes always enter the CLI bundle.** `@moonshot-ai/kimi-web` is ignored by changesets (see `.changeset/config.json`) and cannot be mixed with `@moonshot-ai/kimi-code` in one changeset frontmatter. Describe the web change in the changelog text, but list `@moonshot-ai/kimi-code` so the CLI release carries the bundled `dist-web` output.
2. **List packages that were actually changed.** Source code, build config, package metadata, and other changes that affect a package's output or behavior need a changeset entry for that package.
3. **Do not list unchanged internal packages.** For example, if `packages/node-sdk` was not changed, do not list `@moonshot-ai/kimi-code-sdk` just because another internal package changed. The SDK follows the same rule as other internal packages: list it only when it was actually changed.
4. **Internal package source changes that enter the CLI bundle must manually list the CLI.** `@moonshot-ai/kimi-code` inline-bundles `@moonshot-ai/*` source, but those internal packages are devDependencies from the CLI's perspective, so changesets will not automatically propagate bumps. If a change enters the CLI output, also list `@moonshot-ai/kimi-code`.
5. **Docs-only and tests-only changes usually do not need a changeset.** README, internal docs, and `test/` changes that do not enter package output do not trigger a CLI bump.
6. `@moonshot-ai/vis` / `vis-server` / `vis-web` are ignored by changesets and should not be handled.
## Workflow
1. List the changed packages and check whether each one is ignored by `.changeset/config.json`.
1. List the packages that were actually changed.
2. Choose a bump level for each package.
3. If an ignored internal package change enters the CLI bundle, put `@moonshot-ai/kimi-code` in frontmatter instead of mixing the ignored package into the same changeset.
3. If an internal package change enters the CLI bundle, add `@moonshot-ai/kimi-code`.
4. Create a short kebab-case file under `.changeset/`.
5. Split unrelated changes into separate changesets; keep one logical change in one file.
@ -46,12 +43,10 @@ Format:
| Level | When to use |
|---|---|
| `patch` | Bug fixes; build/package fixes; internal refactors that do not change behavior; wording tweaks; small dependency upgrades; small improvements to existing features with limited user-facing impact (e.g. a new keyboard shortcut, a flag alias, a minor UX tweak) |
| `minor` | A substantial new user-facing feature, such as a new slash command, a new built-in tool, or a new mode |
| `patch` | Bug fixes; build/package fixes; internal refactors that do not change behavior; wording tweaks; small dependency upgrades |
| `minor` | New backwards-compatible features or capabilities |
| `major` | Breaking changes: incompatible config changes, renamed or removed commands/arguments, behavior semantics changes, and similar |
When in doubt between `patch` and `minor`: if the change improves an existing feature and the user-facing impact is small, choose `patch` even when the change is technically "new". Reserve `minor` for a substantial new capability that introduces something users could not do before.
### Major Rule
Never write `major` on your own.
@ -61,72 +56,31 @@ If you believe a change qualifies as major, stop first, explain why, and ask the
## Wording Rules
- Changelog entries **must be written in English**.
- **Keep the whole entry concise.** Aim for one short sentence that states what was done; at most a short sentence plus a one-line usage hint. Do not write a paragraph, do not pile on technical detail, and do not enumerate every sub-change.
- **For new user-facing features, append a brief usage hint** so users know how to try it. Keep it to a single short line — a command name, a subcommand, a flag, or a one-line "how to use". Do not explain design rationale or list edge cases. Skip the hint for bug fixes, internal changes, and refactors.
- Slash command: `Add the /foo slash command to list active sessions. Run /foo to see them.`
- CLI subcommand: `Add the kimi web subcommand to open the web UI. Run kimi web to launch it.`
- Flag: `Add a --bar flag to skip confirmation prompts. Pass --bar to skip.`
- Too long: `Add the /foo command to list active sessions. It accepts an optional --all flag to include background sessions, supports filtering by name with /foo <name>, and writes the result to the transcript...`
- **Keep it short — ideally a single sentence that states what was done.** Do not write a paragraph, do not pile on technical detail, and do not enumerate every sub-change.
- User-facing CLI wording should only be used when CLI users can perceive the change.
- Internal changes that do not affect CLI users can still share a changeset with the CLI, but the wording must describe the real change honestly and must not present it as a user-facing feature.
- Do not mention file names, class names, function names, PR numbers, or commit hashes.
- Do not include real internal endpoints, key names, account names, or service names. If an example is needed, use neutral placeholders such as `example.com`, `example.test`, or `YOUR_API_KEY`.
- Avoid vague words such as `refactor`, `optimize`, and `improve`. Describe the actual change, or use more specific wording.
## When You Are Unsure About a Change
Generate the changeset from what the diff clearly shows. If part of a change is unclear and you cannot confidently describe what it does for users, do not guess or pad the entry with vague wording.
1. Finish the changeset for the parts that are clear.
2. Then ask the user once, in a short list: name the specific change(s) you do not understand, and ask whether you may dig into the repository (read related source, tests, or call sites) to describe it more accurately.
3. Only read more code after the user agrees. If the user says no or does not reply, keep the concise wording you already have and do not invent detail.
## Common Examples
An internal package fixes a bug visible to CLI users:
```markdown
---
"@moonshot-ai/agent-core": patch
"@moonshot-ai/kimi-code": patch
---
Fix occasional loss of tool call results in long conversations.
```
A new user-facing slash command (note the short usage hint):
```markdown
---
"@moonshot-ai/kimi-code": minor
---
Add the /foo slash command to list active sessions. Run /foo to see them.
```
A new CLI subcommand:
```markdown
---
"@moonshot-ai/kimi-code": minor
---
Add the kimi web subcommand to open the web UI. Run kimi web to launch it.
```
A new flag on an existing command:
```markdown
---
"@moonshot-ai/kimi-code": patch
---
Add a --bar flag to skip confirmation prompts. Pass --bar to skip.
```
An internal package has an internal-only change, but it enters the CLI bundle:
```markdown
---
"@moonshot-ai/agent-core": patch
"@moonshot-ai/kimi-code": patch
---
@ -143,83 +97,12 @@ Only SDK source changed, and the CLI does not use it:
Clarify session status typing for internal SDK callers.
```
## Web app changes
`@moonshot-ai/kimi-web` is ignored by changesets and must **never** appear in a changeset frontmatter. Because the web app is bundled into the CLI release artifact, any web change that ships must list `@moonshot-ai/kimi-code` instead and describe the actual web-facing change in the text.
- Prefix the changelog entry text with `web: ` (for example `web: Fix the chat not scrolling to the bottom after sending a message.`) so the synced docs changelog can mark web UI entries. Apply this whenever the change is to the web project (`@moonshot-ai/kimi-web`).
- If a PR ships a web UI feature backed by server API changes that exist solely to power that feature, prefer a single `web:` entry describing what the web user gets. Do not add a separate server-API changeset unless the API has independent user value (a public endpoint that SDK or server consumers call directly). The docs changelog sync also deduplicates this pattern, but catching it here avoids duplicate changesets.
- Do not enumerate every micro-tweak; keep it to one sentence that captures what the web user gets.
Web-only fix:
```markdown
---
"@moonshot-ai/kimi-code": patch
---
web: Fix the chat not scrolling to the bottom after sending a message.
```
Web UI plus backing server APIs in the same PR (prefer a single `web:` entry; the API is plumbing):
```markdown
---
"@moonshot-ai/kimi-code": minor
---
web: Add the server-hosted web UI, including chat layout and session list behaviors.
```
Split into two changesets only when the API has independent user value on its own (for example, a public endpoint SDK consumers call directly). In that case add the web entry above plus a separate one such as `Add a public REST API to list archived sessions for SDK consumers.`
## `@moonshot-ai/pi-tui` changes
`@moonshot-ai/pi-tui` is a vendored fork that lives in `packages/pi-tui`. It is `private: true` and is never published, but it is **not** ignored by changesets: changesets versions it and writes `packages/pi-tui/CHANGELOG.md` so the fork keeps its own history. Because it is bundled into the CLI like other internal packages, it is an exception to Core Rule 4 — do **not** list `@moonshot-ai/kimi-code` for a change that only touches pi-tui.
- Changes that only affect pi-tui (build, package, strict-mode cleanup, renderer fixes): list `@moonshot-ai/pi-tui` only. No CLI changeset.
- If the same change is also user-visible in the CLI (for example a terminal rendering fix that CLI users can see), add a **separate** changeset that lists `@moonshot-ai/kimi-code` with CLI-focused wording, in addition to the pi-tui changeset. Do not mix both packages in one frontmatter — the two changelogs need different wording.
pi-tui-only change:
```markdown
---
"@moonshot-ai/pi-tui": patch
---
Export the package manifest so the bundled binary can locate its native assets.
```
pi-tui change that is also visible in the CLI (two separate changesets):
```markdown
---
"@moonshot-ai/pi-tui": patch
---
Clamp the differential render to the visible viewport so scrolling up during streaming no longer jumps to the top.
```
```markdown
---
"@moonshot-ai/kimi-code": patch
---
Fix the transcript jumping to the top when scrolling up through history during streaming output.
```
## Red Flags
- You are about to write `major` without asking the user.
- A new user-facing feature entry has no usage hint, or the hint runs to multiple lines and explains design rationale.
- You guessed wording for a change you do not understand instead of asking the user whether you may dig into the repo.
- Internal package source enters the CLI bundle, but `@moonshot-ai/kimi-code` is missing.
- A changeset frontmatter mixes ignored internal packages with non-ignored packages.
- `packages/node-sdk` was not changed, but `@moonshot-ai/kimi-code-sdk` was listed for "internal package sync".
- The changelog entry is in Chinese.
- The wording claims more than the diff actually did.
- The CLI wording mentions internal package names, class names, or PR numbers.
- The entry includes real internal identifiers instead of neutral placeholders.
- A change that only touches `@moonshot-ai/pi-tui` lists `@moonshot-ai/kimi-code` instead of `@moonshot-ai/pi-tui`, or mixes both packages in one frontmatter.
- A web app change entry is missing the `web: ` prefix.
- A server/API changeset exists only to back a web feature that a `web:` changeset already describes (use one `web:` entry instead, unless the API has independent user value).

View file

@ -1,67 +0,0 @@
---
name: pre-changelog
description: Use before merging a kimi-code release PR to preview the user-facing CLI changelog in Chinese. Reads the changelog that changesets pre-generated in the release PR, then reuses sync-changelog's strip / classify / translate logic to render a Chinese preview. Writes no files.
---
# Pre-Changelog
Preview the user-facing **Chinese** changelog of an open `kimi-code` release PR **before** it is merged. Read-only: this skill writes no files and commits nothing.
This skill reuses `sync-changelog`'s strip / classify / translate rules. Read `sync-changelog` first; only the data source (release PR diff instead of a published `CHANGELOG.md`) and the output (preview instead of docs files) differ.
## Workflow
### 1. Locate the release PR
```bash
gh pr list --state open --search "ci: release packages in:title" \
--json number,title,url,headRefName,baseRefName
```
Pick the one with `headRefName: changeset-release/main`; record `number`, `url` as `<RELEASE>`. If none is open, nothing to preview — stop.
### 2. Read the pre-generated CLI changelog block
changesets already pre-generates `apps/kimi-code/CHANGELOG.md` inside the release PR. Extract the new version block from the diff:
```bash
gh api repos/MoonshotAI/kimi-code/pulls/<RELEASE>/files \
--jq '.[] | select(.filename=="apps/kimi-code/CHANGELOG.md") | .patch'
```
Take the added lines (`+`) from the top `## <version>` down to (but not including) the next `## `. That is the version block to preview.
If the CLI changelog is not in the diff (for example an SDK-only release), stop and tell the user — there is no user-facing CLI changelog to preview.
### 3. Render the Chinese preview (reuse `sync-changelog`)
Process the version block exactly as `sync-changelog` does for the docs site, but only in memory:
- **Strip** (`sync-changelog` step 3): drop the H1, the `### Patch Changes` / `### Minor Changes` / `### Major Changes` subheadings, PR links, and commit-hash links; keep only each entry's body text. The `Thanks [@user](...)!` credit (including the multi-author form) must be removed every time. Within each entry, drop SDK-only and provider-internal sentences (SDK capability mapping / API exposure, provider wire-format mechanics, internal XML markers) and keep only the user-facing effect and required constraints.
- **Merge and deduplicate** (`sync-changelog` step 4): merge micro-tweaks to the same surface into one higher-level entry; when three or more fixes target the same UI area or the same class of problem, merge them into one higher-level fix entry (do not merge broad or genuinely distinct fixes); and drop a server/API entry that only backs a web feature already listed.
- **Classify** (`sync-changelog` step 4): bucket into Features / Bug Fixes / Polish / Refactors / Other; order within each section by reader value (in Polish, user-visible improvements before protocol/internal adjustments).
- **Translate** (`sync-changelog` step 6): translate entry bodies to Chinese; keep one sentence per entry with a parallel rhythm within a section; section headings become 新功能 / 修复 / 优化 / 重构 / 其他.
If an upstream entry is not in English, flag it and stop (changeset entries must be English).
### 4. Output
Print the preview directly. Use `<version>(预览)` as the heading because the version is not released yet. Write `无` for empty sections. Do not write any file.
```
发版 PR: <url>
## <version>(预览)
### 新功能
- ...
### 修复
- ...
```
## Rules
- Read-only. Never write `CHANGELOG.md`, docs files, or commit anything.
- Classification, ordering, and translation follow `sync-changelog` exactly — do not reword or reclassify beyond what it specifies.
- If the release PR has no CLI changelog diff, report it and stop.

View file

@ -1,6 +1,6 @@
---
name: sync-changelog
description: Use after a release succeeds, when maintainers need to sync apps/kimi-code/CHANGELOG.md into docs/en/release-notes/changelog.md and docs/zh/release-notes/changelog.md, then open a PR on a dedicated branch.
description: Use after a release succeeds, when maintainers need to sync apps/kimi-code/CHANGELOG.md into docs/en/release-notes/changelog.md and docs/zh/release-notes/changelog.md.
---
# Sync Changelog
@ -15,7 +15,7 @@ apps/kimi-code/CHANGELOG.md
This file is the **only upstream source** for the documentation-site changelog. Internal package changelogs such as `packages/*/CHANGELOG.md` do not go into the documentation site.
After the release flow finishes (Release PR merged → `Version Packages` completed → npm publish succeeded), maintainers manually run this skill to copy the new CLI changelog entries into the docs site, translate the English increment into Chinese, wait for an optional human review, then commit on a dedicated branch and open a PR.
After the release flow finishes (Release PR merged → `Version Packages` completed → npm publish succeeded), maintainers manually run this skill to copy the new CLI changelog entries into the docs site and translate the English increment into Chinese.
## When To Use
@ -41,65 +41,39 @@ Before editing, confirm:
- The released version exists on npm (`npm view @moonshot-ai/kimi-code versions --json`) or has a matching GitHub Release tag.
- The top of `apps/kimi-code/CHANGELOG.md` is that new version.
- The current branch is clean, or you are on a dedicated docs-sync branch.
If any condition is not true, stop and confirm with the user.
Do **not** edit or commit directly on `main`. All sync work happens on a dedicated branch created in step 1.
## Workflow
### 1. Prepare Branch
Start from an up-to-date default branch:
### 1. Find The Version Range
```bash
git fetch origin
git checkout main
git pull --ff-only origin main
```
# Upstream versions
rg '^## ' apps/kimi-code/CHANGELOG.md | head -20
Before creating the branch, peek at the version range so the branch name matches the newest version being synced:
```bash
rg '^## ' apps/kimi-code/CHANGELOG.md | head -5
# Latest version already synced into the English docs page
rg '^## ' docs/en/release-notes/changelog.md | head -5
```
Name the branch after the newest upstream version that is not yet in the English docs page:
```text
docs/changelog-sync-<newest-version>
```
Example: syncing `0.2.1` only → `docs/changelog-sync-0.2.1`.
```bash
git checkout -b docs/changelog-sync-<newest-version>
```
If the branch already exists locally or on the remote, stop and confirm with the user instead of reusing it.
### 2. Find The Version Range
Use the same version lists from step 1. Confirm:
- First sync: copy all upstream version blocks into the English page.
- Incremental sync: copy every upstream version block above the latest version already present in the English page.
Use upstream order: newest version first.
### 3. Strip Decorations And Extract Entry Text
### 2. Strip Decorations And Extract Entry Text
Upstream entries look like this:
```markdown
- [#317](https://github.com/...) [`2f51db4`](https://github.com/...) Thanks [@user](https://github.com/...)! - Clean up lint warnings ...
- [#317](https://github.com/...) [`2f51db4`](https://github.com/...) - Clean up lint warnings ...
```
Changesets may add a `Thanks ...!` credit, but it must be removed every time. Keep:
Keep:
- Version headings such as `## 0.2.0`.
- Only the body text of each entry, after the PR/hash decoration and any `Thanks ...!` credit have been removed.
- Only the body text of each entry, after the PR/hash decoration.
Remove:
@ -107,49 +81,26 @@ Remove:
- Changesets subheadings such as `### Patch Changes`, `### Minor Changes`, and `### Major Changes`.
- PR links such as `[#317](...)`.
- Commit hash links such as ``[`2f51db4`](...)``.
- The `Thanks [@user](...)!` credit, including the multi-author form `Thanks [@a](...), [@b](...)!`. Drop the whole `Thanks ...!` segment every time, regardless of whether the feature is enabled.
After stripping, each entry is `- <body text>`.
Drop SDK-only and provider-internal detail. This changelog serves `@moonshot-ai/kimi-code` CLI and web users. Within an entry, keep only what CLI/web users can perceive, and remove sentences that document internals instead of user-visible behavior. Apply this on both the English and Chinese pages:
- Drop sentences about how the SDK maps a capability, builds model aliases, or exposes a flag through an API such as `getExperimentalFeatures()` — that belongs in the SDK changelog, not here.
- Drop provider / wire-format implementation mechanics (XML markers like `<tools_added>`, protocol field explanations, "the wire protocol is unchanged", cache-hit mechanics) unless they are the behavior a user perceives.
- Keep the user-facing effect and any constraints users must follow (for example "question texts must be unique").
Do not change facts or drop a real user-facing behavior — only trim the internal-only scaffolding. For over-long, internal-heavy entries, this trim applies on the English page too, not only in translation.
Web UI prefix: if the entry is a web UI change, prefix the body text with `web: ` so readers can tell it affects the web UI:
After stripping, each entry should be only:
```markdown
- web: <body text>
- <body text>
```
An entry counts as a web UI change when its upstream commit touches `apps/kimi-web/`. Check with `git show --name-only <hash>` (the commit hash is the one stripped above). `gen-changesets` writes this prefix for web changes, so it is usually already present in upstream — preserve it when it is there, and add it when a web entry lacks it. When a commit touches both web and non-web code, use `web:` only if the user-facing change described by the entry is in the web UI. Keep the `web:` prefix on the Chinese page too — it is a scope marker, not translated text.
Upstream language rule: `gen-changesets` requires changelog entries to be English. If the upstream CLI changelog contains a non-English entry, stop and report it to the user. Do not silently rewrite it while syncing docs.
Public-text rule: do not copy real internal endpoints, key names, account names, or service names into docs changelogs. Replace examples with neutral placeholders such as `example.com`, `example.test`, or `YOUR_API_KEY` while preserving the user-visible meaning.
### 4. Merge, Deduplicate, And Classify Entries
Before classifying, merge related entries and drop redundant ones from the user-facing changelog:
- **Merge micro-tweaks to the same surface.** Collapse several small tweaks to the same UI area or feature into one concise entry at the higher level. For example, "change the composer's default height" and "change the composer's default font" merge into "Polish the composer's default styling." Use the most specific common ancestor (composer, settings page, tool card, and so on). Classify the merged entry by its combined effect, and keep the `web:` prefix if the combined change is still web-facing.
- **Merge same-surface or same-kind fixes when you have three or more.** The `Bug Fixes` section tends to accumulate many narrow UI/polish fixes that read as noise when listed one by one. When three or more fixes target the same area (for example several tool cards in the TUI, or the web session/conversation surface) or the same class of problem (for example several "jumping/flickering/collapsing during streaming" fixes), merge them into one higher-level entry. Examples:
- "Fix the Bash tool card collapsing...", "Fix the Edit tool card jumping in height...", "Fix the Edit tool card flickering while its result streams in" → "Fix several TUI tool cards jumping, flickering, or collapsing in height when results stream in or end with short output."
- "Fix the collapsed sidebar not hiding...", "Stop the chat history from replaying its entrance animation...", "Fix tool components jumping the conversation when expanded/collapsed" → "web: Fix several layout and display glitches when switching sessions, including the collapsed sidebar not hiding, the chat history replaying its entrance animation, and tool components jumping the conversation."
- Keep `web:` if the merged fixes are all web-facing. Classify as `Bug Fixes`.
- **Do not over-merge.** Leave a fix standalone when it is broad, high-value, or genuinely distinct (for example model/provider tool-calling bugs, session-list corruption, file-completion gaps). Merging is for low-reader-value, similar-shape fixes that read as a wall of similar bullets.
- **Drop server/API plumbing covered by a web entry.** If one entry adds a web UI feature (for example, an Archived sessions page) and another entry only adds the server or REST/WebSocket endpoints that exist solely to power that web feature, keep the `web:` entry and drop the API entry. CLI and web users perceive the web page; the backing API is implementation detail with no independent user value on this changelog. Keep the API entry only when it has independent user value — a new public endpoint that SDK or server consumers call directly, or a capability usable outside the web feature. When unsure, keep both and let the reviewer decide.
### 3. Classify Entries
The docs changelog uses five section types:
| English section | Chinese section | Meaning |
|---|---|---|
| `### Features` | `### 新功能` | New user-facing functionality, such as a new command, flag, mode, or capability that did not exist before |
| `### Polish` | `### 优化` | User-visible improvements to existing functionality, including UX adjustments, behavior tweaks, and performance improvements that are not fixes or new capabilities |
| `### Bug Fixes` | `### 修复` | Fixes for behavior that was broken |
| `### Polish` | `### 优化` | User-visible improvements to existing functionality, including UX adjustments, behavior tweaks, and performance improvements that are not fixes or new capabilities |
| `### Refactors` | `### 重构` | Internal changes with no user-visible behavior change, including build, CI, tests, dependency cleanup, and internal renames |
| `### Other` | `### 其他` | Anything that does not fit above, such as CDN/endpoint swaps and docs-related artifacts |
@ -163,8 +114,6 @@ Classification process:
Features vs. Polish: ask whether the entry introduces something the user could not do before. If yes (new command, flag, mode, viewer, or capability), use `Features`. If it only improves an existing surface (a UI panel that already existed, an existing prompt, an existing tool card, an existing payload pipeline), use `Polish`. Verbs like `Add` do not automatically mean `Features` — a small visual addition to an existing UI is still polish.
Default-behavior changes: changing the default value of an existing capability (for example flipping a feature on by default) is usually `Polish`, because the capability already existed. Use `Features` only when the new default materially changes the out-of-box experience for most users in a way they could not get before. When genuinely ambiguous, flag it and confirm with the reviewer rather than guessing.
Keyword hints:
- **Features**: `Add ... command/flag/option/mode/viewer`, `Introduce`, `Support`, `Allow`, `Enable`, `Implement`, `New ... command/flag/option`
@ -176,19 +125,18 @@ Keyword hints:
Within each version, section order is:
```text
Features → Polish → Bug Fixes → Refactors → Other
Features → Bug Fixes → Polish → Refactors → Other
```
Omit empty sections. Within each section, order entries by reader value, not upstream order:
1. Put the most valuable, obvious, and larger changes first.
2. Prefer broad user-visible features, workflow-changing fixes, high-frequency bugs, and large cross-cutting improvements over small polish, narrow edge cases, and internal cleanup.
3. Within `Polish`, put directly user-visible UX or performance improvements (something users can see or feel) before protocol or internal-behavior adjustments (something that makes the model or pipeline behave more reliably but is invisible to users).
4. If entries have similar value, preserve upstream order.
3. If entries have similar value, preserve upstream order.
Do not reword or exaggerate entries just to make them look more important; only reorder existing entries.
### 5. Write The English Page
### 4. Write The English Page
Never change the English page header:
@ -229,7 +177,7 @@ Example:
- Update the native release workflow to use current GitHub artifact actions.
```
### 6. Translate The Increment Into Chinese
### 5. Translate The Increment Into Chinese
After updating the English page, translate only the newly added English content into `docs/zh/release-notes/changelog.md`.
@ -257,54 +205,7 @@ Chinese page requirements:
- Translate only entry body text. Do not add entries that are not present in English.
- Follow `docs/AGENTS.md` for Chinese typography: full-width punctuation, spaces between Chinese and English, and the glossary.
#### Chinese wording style
Structural fidelity does not mean literal translation. The Chinese entries should read like a concise, idiomatic Chinese changelog. Keep the same facts as the English entry, but rephrase for natural Chinese prose.
Guidelines:
- **One entry, one sentence.** Avoid chaining multiple effects with commas or semicolons. If the English entry is long, split it into shorter sentences or keep only the most important effect.
- **Drop SDK-only and provider-internal detail.** Apply the trim from step 3 while translating: keep the user-facing effect and required constraints, drop SDK-mapping sentences, provider / wire-format mechanics, and internal XML markers. A long internal entry should collapse to one short Chinese sentence about what the user gets.
- **Prefer common changelog verbs**: 新增、支持、修复、优化、改进、调整.
- **Avoid indirect "through... make..." structures**. Do not write "通过 X使 Y"; prefer direct cause-effect or just state the result.
- Bad: `通过缓存已渲染消息行,使终端在长篇对话中保持响应。`
- Better: `缓存已渲染消息行,提升长对话下终端的响应速度。`
- **Be specific, not vague**. Prefer concrete actions over abstract quality words.
- Bad: `加固默认系统提示词和内置工具描述。`
- Better: `优化默认系统提示词与内置工具描述,避免 Agent 阻塞后台任务。`
- **Name concrete files or config keys when it helps clarity**.
- Bad: `插件现在可以在其清单中声明 hooks。`
- Better: `插件现支持在 kimi.plugin.json 中声明生命周期 hooks。`
- **Include required argument placeholders in CLI options**.
- Bad: `--allowed-host`
- Better: `--allowed-host <host>`
- **Keep usage hints to one short clause**.
- Bad: `传入 --allowed-host 以允许额外的 host。例如 ... (多句展开)`
- Better: `例如 kimi web --allowed-host example.com。`
- **Do not translate technical identifiers**: keep command names, flag names, file names, env vars, config keys, and the `web:` scope prefix as-is.
- **Keep parallel rhythm within a section.** When several entries fix similar web surfaces (layout, animation, sizing), phrase them with a consistent structure (for example 修复 <问题>,现 <行为>) so the section reads as a tidy list rather than a mix of shapes.
Example — translating a feature entry:
English source:
```markdown
- Add a --allowed-host flag to kimi web that lets extra Host header values pass the DNS-rebinding check, and include allow guidance in the 403 error message. Pass --allowed-host <host> to allow an extra host.
```
Before (literal, wordy):
```markdown
- 为 `kimi web` 新增 `--allowed-host` 标志,允许额外的 Host 请求头值通过 DNS 重绑定检查,并在 403 错误消息中包含允许指引。传入 `--allowed-host <host>` 以允许额外的 host。例如 `kimi web --allowed-host example.com`
```
After (concise, idiomatic):
```markdown
- `kimi web` 新增 `--allowed-host <host>` 选项,可将指定 Host 加入 DNS 重绑定白名单403 错误会提示如何通过 `--allowed-host``KIMI_CODE_ALLOWED_HOSTS` 放行,例如 `kimi web --allowed-host example.com`
```
### 7. Verify
### 6. Verify
Review:
@ -320,7 +221,6 @@ Check:
- Each section has the same number of entries on both pages.
- Within each section, the most valuable, obvious, and larger entries appear before smaller or narrower entries.
- PR links and commit hashes were stripped.
- No `Thanks ...!` credit remains (remove it every time).
- Real internal identifiers were replaced with neutral placeholders.
- There are no empty sections.
- Markdown indentation and blank lines are intact.
@ -331,36 +231,7 @@ Then run the docs build:
pnpm --filter docs run build
```
### 8. Human Review Checkpoint
After verification passes, **before committing**, ask the user whether they want to review the sync result. Use `AskQuestion` with options such as:
- **Review first** — show the diff and wait for the user to finish checking.
- **Skip review, commit and open PR** — proceed directly to steps 9 and 10.
If the user chooses review:
1. Show the uncommitted diff:
```bash
git diff docs/en/release-notes/changelog.md docs/zh/release-notes/changelog.md
```
2. Summarize synced versions, section counts, and anything that needed manual classification.
3. Tell the user to reply when they are done reviewing, or to ask for edits.
4. Do **not** commit, push, or open a PR until the user explicitly says review is complete, or asks to proceed.
If the user requests edits during review, make the changes, re-run verification from step 7, and return to this checkpoint.
### 9. Commit
Only run this step when the user skipped review or confirmed review is complete.
Stage only the changelog docs files:
```bash
git add docs/en/release-notes/changelog.md docs/zh/release-notes/changelog.md
```
### 7. Commit
Use a neutral docs-sync commit message:
@ -370,66 +241,12 @@ docs(changelog): sync <version range> from apps/kimi-code/CHANGELOG.md
Do **not** create a changeset for changelog docs sync. Docs sync does not enter the bundle.
### 10. Push And Open PR
Run immediately after step 9.
Push the branch:
```bash
git push -u origin HEAD
```
Create the PR with `gh pr create`. Title follows Conventional Commits:
```text
docs(changelog): sync <version range> from apps/kimi-code/CHANGELOG.md
```
Fill in `.github/pull_request_template.md`. For changelog sync PRs:
- **Related Issue**: write `N/A — post-release docs maintenance` (no issue required).
- **Problem**: the docs-site changelog is behind the published CLI release(s).
- **What changed**: list synced version(s), note English source + Chinese translation, and mention verification (`pnpm --filter docs run build`).
- **Checklist**: check CONTRIBUTING; explain no issue, no tests, no changeset, and that `gen-docs` is not needed because this is the dedicated changelog sync flow.
Example body:
```markdown
## Related Issue
N/A — post-release docs maintenance
## Problem
The docs-site changelog has not yet been synced for `<version range>` after the npm release.
## What changed
- Synced `<version range>` from `apps/kimi-code/CHANGELOG.md` into `docs/en/release-notes/changelog.md`
- Translated the new English increment into `docs/zh/release-notes/changelog.md`
- Verified with `pnpm --filter docs run build`
## Checklist
- [x] I have read the CONTRIBUTING document.
- [x] I have linked a related issue, or explained the problem above.
- [ ] I have added tests that prove my feature works. (N/A — docs-only sync)
- [x] Ran `gen-changesets` skill, or this PR needs no changeset. (No changeset — docs sync is out of bundle)
- [x] Ran `gen-docs` skill, or this PR needs no doc update. (This PR is the dedicated changelog sync)
```
Return the PR URL to the user when done.
## Rules
- The English docs changelog is the source of truth.
- Never edit upstream `apps/kimi-code/CHANGELOG.md`.
- Do not backfill unreleased `.changeset/*.md` drafts into the docs site.
- Prefix web UI entries with `web: ` (when the upstream commit touches `apps/kimi-web/`), and keep the prefix on both the English and Chinese pages.
- If upstream wording is wrong, leave upstream alone and fix it in a future changeset.
- Always sync on a `docs/changelog-sync-*` branch and open a PR; never push changelog docs sync directly to `main`.
- Wait for the human review checkpoint before committing, pushing, or opening a PR.
## Common Mistakes
@ -437,10 +254,6 @@ Return the PR URL to the user when done.
|---|---|
| Adding entries directly to the English docs page without reading upstream | Use `apps/kimi-code/CHANGELOG.md` as the source |
| Copying PR links or commit hashes into docs | Strip them; keep only body text |
| Leaving the `Thanks ...!` credit in docs | Remove it every time, including the multi-author form |
| Leaving near-duplicate micro-tweaks as separate bullets | Merge small tweaks to the same surface into one higher-level entry (e.g. composer height + font → composer's default styling) |
| Listing many narrow fixes to the same surface as separate bullets | When three or more fixes target the same UI area or the same class of problem, merge them into one higher-level fix entry; keep genuinely distinct or high-value fixes standalone |
| Listing a server/API entry that only backs a web feature already listed | Drop the API entry and keep the `web:` entry, unless the API has independent user value |
| Rewording upstream English entries | Upstream is frozen; copy the body text unless the user explicitly asks otherwise |
| Leaving English text untranslated in the Chinese page | The Chinese page must be fully Chinese except preserved technical terms |
| Editing upstream changelog text | Do not edit upstream |
@ -455,11 +268,8 @@ Return the PR URL to the user when done.
| Putting everything under Other for convenience | Classify what can be classified first |
| Translating tool names, command names, or config keys | Keep them as written |
| Creating a changeset for docs sync | Do not create one |
| Committing or pushing directly on `main` | Create `docs/changelog-sync-<version>`, commit there, then open a PR |
| Committing or opening a PR before the user skips review or confirms review is done | Wait at the human review checkpoint |
| Using curly quotes or half-width Chinese punctuation | Follow `docs/AGENTS.md` |
| Omitting the release date from a version heading, or guessing it | Add ` (YYYY-MM-DD)` (full-width `` in Chinese) taken from the published tag |
| Forgetting or translating the `web:` prefix on web UI entries | Prefix web UI entries (commit touches `apps/kimi-web/`) with `web: ` on both pages; keep the prefix as-is when translating |
## Stop Signals
@ -469,5 +279,3 @@ Return the PR URL to the user when done.
- English and Chinese versions, entry counts, or section sets do not match.
- A section is empty.
- A Chinese term is uncertain and `docs/AGENTS.md` does not answer it.
- A `docs/changelog-sync-*` branch already exists for the same version and you cannot confirm whether it is stale.
- The user asked to review but has not yet confirmed review is complete.

View file

@ -15,16 +15,11 @@ Current publishable packages:
All other workspace packages are private internal packages, are not published to npm, and are excluded via `ignore` in `.changeset/config.json`:
- `@moonshot-ai/acp-adapter`
- `@moonshot-ai/agent-core`
- `@moonshot-ai/kaos`
- `@moonshot-ai/kimi-code-oauth`
- `@moonshot-ai/kimi-telemetry`
- `@moonshot-ai/kimi-web`
- `@moonshot-ai/kaos`
- `@moonshot-ai/kosong`
- `@moonshot-ai/migration-legacy`
- `@moonshot-ai/protocol`
- `@moonshot-ai/server-e2e`
- `@moonshot-ai/vis`
- `@moonshot-ai/vis-server`
- `@moonshot-ai/vis-web`
@ -44,7 +39,6 @@ Example scenarios:
| SDK behavior change affects CLI user experience | Add changesets to both `@moonshot-ai/kimi-code-sdk` and `@moonshot-ai/kimi-code` |
| Provider abstraction change affects SDK / CLI | Add changesets to the affected `@moonshot-ai/kimi-code-sdk` and/or `@moonshot-ai/kimi-code` |
| Test-only, internal refactor, docs, or private debug tooling changes | Usually no changeset needed |
| Bundled official plugin change under `plugins/` (e.g. `kimi-datasource`) | No changeset — the plugin is versioned via its own `kimi.plugin.json` / `plugins/marketplace.json` and shipped through the marketplace CDN, not the npm package |
## Prerequisite: NPM Trusted Publishing (OIDC)
@ -144,7 +138,6 @@ The root-level `pnpm run publish` first runs typecheck, lint, sherif, test, buil
## Notes
- Every PR that affects publishable-package behavior or public API should include a corresponding changeset.
- Changes under `plugins/` (the bundled official plugins such as `kimi-datasource`) do **not** need a changeset: each plugin carries its own version in `kimi.plugin.json` and `plugins/marketplace.json` and is distributed via the marketplace CDN, separately from the `@moonshot-ai/kimi-code` npm package.
- Changeset files must be committed to the repository — release PRs are only triggered after they're merged.
- Release PRs require human review and merge; they will not publish automatically.
- Do not add release changesets for private internal packages; only select `@moonshot-ai/kimi-code` and `@moonshot-ai/kimi-code-sdk`.

View file

@ -1,5 +1,5 @@
{
"changelog": ["@changesets/changelog-github", { "repo": "MoonshotAI/kimi-code" }],
"changelog": ["@changesets/changelog-github", { "repo": "MoonshotAI/kimi-code", "disableThanks": true }],
"commit": false,
"fixed": [],
"linked": [],
@ -7,7 +7,6 @@
"baseBranch": "main",
"updateInternalDependencies": "patch",
"ignore": [
"@moonshot-ai/server-e2e",
"@moonshot-ai/vis",
"@moonshot-ai/vis-server",
"@moonshot-ai/vis-web"

View file

@ -1,5 +0,0 @@
---
"@moonshot-ai/kimi-code": patch
---
web: Fix Enter not confirming modal confirmation dialogs in dev builds, and keep the dialog open with a loading state until the confirmed action (such as archiving a session) completes.

10
.gitattributes vendored
View file

@ -1,10 +0,0 @@
# Enforce LF line endings in the working tree on every platform so that
# raw-imported text (e.g. `*.md?raw` templates) is byte-identical on Windows
# and POSIX. Without this, Git for Windows' default `core.autocrlf=true`
# checks text files out as CRLF, which shifts token-count snapshots.
* text=auto eol=lf
# Binary assets — never normalize line endings.
*.gif binary
*.ico binary
*.png binary

View file

@ -85,13 +85,6 @@ jobs:
node apps/kimi-code/scripts/update-catalog.mjs --out "$CATALOG_FILE"
echo "KIMI_CODE_BUILT_IN_CATALOG_FILE=$CATALOG_FILE" >> "$GITHUB_ENV"
- name: Build Kimi web assets
# The SEA blob step embeds apps/kimi-code/dist-web; build the web app
# and stage its assets before producing the native executable.
run: |
pnpm --filter @moonshot-ai/kimi-web run build
node apps/kimi-code/scripts/copy-web-assets.mjs
- name: Build native executable (release profile, macOS signed)
if: runner.os == 'macOS' && inputs.sign-macos
run: pnpm --filter @moonshot-ai/kimi-code run build:native:release

View file

@ -30,10 +30,6 @@ jobs:
test:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
shard: [1, 2, 3, 4, 5]
steps:
- uses: actions/checkout@v4
@ -46,46 +42,7 @@ jobs:
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm run test --shard=${{ matrix.shard }}/5
# pi-tui's suite runs on node:test (not vitest), so the root `pnpm run test`
# does not execute it; it needs its own job.
test-pi-tui:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v6
- uses: actions/setup-node@v6
with:
node-version-file: .nvmrc
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm --filter @moonshot-ai/pi-tui test
test-windows:
runs-on: windows-latest
# Temporarily disabled while Windows tests are being stabilized.
if: false
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v6
- uses: actions/setup-node@v6
with:
node-version-file: .nvmrc
cache: pnpm
- run: pnpm install --frozen-lockfile
# Windows runners are slower and run the whole suite (including
# in-process e2e tests) under more contention, so the default 5s test
# timeout causes flaky failures. Give it more headroom.
- run: pnpm run test -- --testTimeout=30000
- run: pnpm run test
lint:
runs-on: ubuntu-latest
@ -125,9 +82,3 @@ jobs:
echo "Typechecking ${config}"
pnpm dlx --package @typescript/native-preview@beta tsgo -p "${config}" --noEmit
done
- name: Typecheck kimi-web (vue-tsc)
run: pnpm --filter @moonshot-ai/kimi-web run typecheck
- name: Typecheck vis-server
run: pnpm --filter @moonshot-ai/vis-server run typecheck
- name: Typecheck vis-web
run: pnpm --filter @moonshot-ai/vis-web run typecheck

View file

@ -1,170 +0,0 @@
name: desktop-build
# Builds the Kimi Desktop (Electron) installers for macOS, Windows and Linux.
# Each runner builds the matching-platform SEA backend first, then packages it
# with electron-builder.
#
# macOS is signed with a Developer ID certificate + notarized (so it opens on
# any Mac without the "app is damaged" Gatekeeper block) when `sign-macos` is
# true and the Apple secrets are configured. Windows/Linux ship unsigned in v1.
#
# Triggered two ways:
# - workflow_dispatch: manual ad-hoc builds from the Actions tab.
# - workflow_call: called by release.yml to attach installers to a release.
on:
workflow_dispatch:
inputs:
sign-macos:
description: 'Sign + notarize macOS (needs Apple secrets)'
required: false
type: boolean
default: true
retention-days:
description: 'Artifact retention in days'
required: false
type: number
default: 5
upload-artifact-prefix:
description: 'Prefix for uploaded artifact name'
required: false
type: string
default: 'kimi-desktop'
workflow_call:
inputs:
sign-macos:
description: 'Sign + notarize macOS (needs Apple secrets)'
required: false
type: boolean
default: false
retention-days:
description: 'Artifact retention in days'
required: false
type: number
default: 7
upload-artifact-prefix:
description: 'Prefix for uploaded artifact name'
required: false
type: string
default: 'kimi-desktop'
secrets:
APPLE_CERTIFICATE_P12:
required: false
APPLE_CERTIFICATE_PASSWORD:
required: false
APPLE_NOTARIZATION_KEY_P8:
required: false
APPLE_NOTARIZATION_KEY_ID:
required: false
APPLE_NOTARIZATION_ISSUER_ID:
required: false
permissions:
contents: read
jobs:
desktop:
name: Desktop installer (${{ matrix.target }})
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
include:
- os: macos-15
target: darwin-arm64
- os: macos-15-intel
target: darwin-x64
- os: windows-2025-vs2026
target: win32-x64
- os: ubuntu-24.04
target: linux-x64
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Setup pnpm
uses: pnpm/action-setup@v6
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version-file: .nvmrc
cache: 'pnpm'
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Build Kimi web assets
# The SEA blob embeds apps/kimi-code/dist-web; build the web app and
# stage its assets before producing the native executable.
# KIMI_WEB_DESKTOP=1 bakes the internal-build banner into the web bundle
# (see apps/kimi-web/src/components/InternalBuildBanner.vue); only the
# desktop sets this flag, so the CLI `kimi web` stays banner-free.
env:
KIMI_WEB_DESKTOP: '1'
run: |
pnpm --filter @moonshot-ai/kimi-web run build
node apps/kimi-code/scripts/copy-web-assets.mjs
- name: Build native executable (local profile)
# The Electron app signs the SEA itself (electron-builder, inside-out),
# so the native build stays unsigned here.
run: pnpm --filter @moonshot-ai/kimi-code run build:native:sea
- name: Setup macOS keychain (Developer ID)
if: runner.os == 'macOS' && inputs.sign-macos
uses: ./.github/actions/macos-keychain-setup
with:
certificate-p12: ${{ secrets.APPLE_CERTIFICATE_P12 }}
certificate-password: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
- name: Prepare CSC_NAME for electron-builder (macOS)
if: runner.os == 'macOS' && inputs.sign-macos
shell: bash
run: |
# electron-builder rejects the "Developer ID Application: " prefix in
# CSC_NAME; strip it so the certificate matches by team name + ID.
name="${APPLE_SIGNING_IDENTITY}"
name="${name#Developer ID Application: }"
echo "CSC_NAME=$name" >> "$GITHUB_ENV"
- name: Prepare notarization API key (macOS)
if: runner.os == 'macOS' && inputs.sign-macos
shell: bash
env:
APPLE_NOTARIZATION_KEY_P8: ${{ secrets.APPLE_NOTARIZATION_KEY_P8 }}
run: |
set -euo pipefail
key_path="$RUNNER_TEMP/notary-AuthKey.p8"
printf '%s' "$APPLE_NOTARIZATION_KEY_P8" | base64 -d > "$key_path"
echo "APPLE_API_KEY=$key_path" >> "$GITHUB_ENV"
- name: Build & package desktop app
shell: bash
env:
# macOS signing is driven by env: when sign-macos, electron-builder
# signs with the keychain's Developer ID and notarizes via the notary
# API key; otherwise it builds unsigned.
CSC_IDENTITY_AUTO_DISCOVERY: ${{ (runner.os == 'macOS' && inputs.sign-macos) && 'true' || 'false' }}
CSC_KEYCHAIN: ${{ env.APPLE_KEYCHAIN_PATH }}
KIMI_DESKTOP_NOTARIZE: ${{ (runner.os == 'macOS' && inputs.sign-macos) && 'true' || 'false' }}
APPLE_API_KEY_ID: ${{ secrets.APPLE_NOTARIZATION_KEY_ID }}
APPLE_API_ISSUER: ${{ secrets.APPLE_NOTARIZATION_ISSUER_ID }}
run: pnpm --filter @moonshot-ai/kimi-desktop run dist
- name: Cleanup macOS keychain
if: always() && runner.os == 'macOS' && inputs.sign-macos
uses: ./.github/actions/macos-keychain-cleanup
- name: Upload installers
uses: actions/upload-artifact@v7
with:
name: ${{ inputs.upload-artifact-prefix }}-${{ matrix.target }}
retention-days: ${{ inputs.retention-days }}
path: |
apps/kimi-desktop/dist-app/*.dmg
apps/kimi-desktop/dist-app/*.zip
apps/kimi-desktop/dist-app/*.exe
apps/kimi-desktop/dist-app/*.AppImage
apps/kimi-desktop/dist-app/*.deb
if-no-files-found: ignore

View file

@ -36,9 +36,6 @@ jobs:
- name: Build package dependencies
run: pnpm run build:packages
- name: Build Kimi web assets
run: pnpm --filter @moonshot-ai/kimi-web run build
- name: Generate Kimi Code built-in catalog
shell: bash
run: |

View file

@ -37,7 +37,7 @@ jobs:
registry-url: "https://registry.npmjs.org"
- name: Upgrade npm for Trusted Publishing
run: npm install -g npm@11
run: npm install -g npm@latest
- name: Install dependencies
run: pnpm install --frozen-lockfile
@ -97,22 +97,6 @@ jobs:
APPLE_NOTARIZATION_KEY_ID: ${{ secrets.APPLE_NOTARIZATION_KEY_ID }}
APPLE_NOTARIZATION_ISSUER_ID: ${{ secrets.APPLE_NOTARIZATION_ISSUER_ID }}
desktop-artifacts:
name: Desktop release artifact
needs: release
if: needs.release.outputs.kimi_native_release == 'true'
uses: ./.github/workflows/desktop-build.yml
with:
upload-artifact-prefix: kimi-desktop
retention-days: 7
sign-macos: true
secrets:
APPLE_CERTIFICATE_P12: ${{ secrets.APPLE_CERTIFICATE_P12 }}
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
APPLE_NOTARIZATION_KEY_P8: ${{ secrets.APPLE_NOTARIZATION_KEY_P8 }}
APPLE_NOTARIZATION_KEY_ID: ${{ secrets.APPLE_NOTARIZATION_KEY_ID }}
APPLE_NOTARIZATION_ISSUER_ID: ${{ secrets.APPLE_NOTARIZATION_ISSUER_ID }}
publish-native-assets:
name: Publish native release assets
needs:

31
.gitignore vendored
View file

@ -1,43 +1,14 @@
node_modules/
dist/
dist-web/
dist-single/
dist-native/
.tmp-api-extractor/
.contract-types-tmp/
.local/
coverage/
*.tsbuildinfo
.vitest-results/
.vite/
.DS_Store
.playwright-mcp/
.claude
.conductor
.kimi-stash-dir
plugins/cdn/
.worktrees/
.kimi-code/local.toml
.kimi-sandbox/
.vscode/
Dockerfile
docker-compose.yml
.dockerignore
docs/superpowers/
reports/
.superpowers/
/plan/
# Agent scratch / throwaway files - do not commit
.tmp/
HANDOVER*.md
HANDOFF*.md
handoff.md
handover.md
*-designs.html
*-design.html
*-mockup.html
*-demo.html
*-demos.html
superpowers

View file

@ -150,7 +150,7 @@
"node_modules/",
"apps/*/scripts/",
"docs/smoke-archive/",
"packages/pi-tui/",
"plugins/curated/superpowers/",
"*.generated.ts"
]
}

View file

@ -15,16 +15,13 @@ This is a TypeScript monorepo built for agent-assisted development. Keep the roo
## Project Map
- `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.
- `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`: the unified agent engine, including Agent, Session, profile, skills, tools, plan, permission, background, records, and other core capabilities.
- `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/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` and the native `/api/v2` RPC surface); bootstrapped from `src/start.ts` and consumed by `apps/kimi-code`.
- `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`.
## Environment Requirements
@ -35,11 +32,9 @@ This is a TypeScript monorepo built for agent-assisted development. Keep the roo
## Monorepo Workspace Maintenance
- `pnpm-workspace.yaml` is the source of truth for workspace membership, but `flake.nix` also contains **hardcoded** `workspacePaths` and `workspaceNames` lists.
- **Whenever you add or remove a workspace package, you MUST update both `pnpm-workspace.yaml` and `flake.nix` — for every package, including leaf / test / e2e packages that nothing depends on.**
- `pnpm-workspace.yaml` uses globs (`packages/*`, `apps/*`), so most packages land there automatically; `flake.nix` is fully manual and is where omissions happen.
- **Whenever you add or remove a workspace package, you MUST update both `pnpm-workspace.yaml` and `flake.nix`.**
- Missing a path in `flake.nix`'s `workspacePaths` will silently drop files from the Nix build's `src` fileset.
- Missing a name in `flake.nix`'s `workspaceNames` will break `pnpmConfigHook` because dependencies for that workspace will not be fetched.
- The automated "Check flake.nix workspace sync" (`scripts/check-nix-workspace.mjs`) only validates the transitive dependency **closure of `@moonshot-ai/kimi-code`**. A leaf package outside that closure (e.g. an e2e package nobody imports) slips through even when it is missing from `flake.nix`. A green check is therefore NOT proof that `flake.nix` is fully in sync — keep it updated by hand on every add/remove, do not rely on the check to catch omissions.
## General Coding Rules
@ -77,7 +72,3 @@ This is a TypeScript monorepo built for agent-assisted development. Keep the roo
- After finishing a task and before submitting a PR, you must run the `gen-changesets` skill (see `.agents/skills/gen-changesets/SKILL.md`) and generate a changeset under `.changeset/` according to its rules.
- When generating a changeset, **never** decide on a `major` bump on your own. When you judge a change to meet the major criteria (breaking changes, incompatible user configuration, renamed or removed commands/arguments, changed behavior semantics, etc.), you must stop and explain it to the user and ask for confirmation. **Only write `major` after the user has explicitly agreed.** Otherwise default to `minor` (and fall back to `patch` if `minor` is unclear). See the "Hard rule: confirm with the user before writing `major`" section in `.agents/skills/gen-changesets/SKILL.md` for details.
- Prefer importing via `import ... from '#/...'`, which serves the same purpose as `import ... from '@/...'`.
- Do not commit throwaway scratch or exploratory files. Never stage:
- Agent working notes or handoff/summary documents (e.g. `HANDOVER-*.md`, `HANDOFF-*.md`, `handoff.md`).
- Throwaway UI/UX prototypes or design mockups (e.g. `*-designs.html`, `*-mockup.html`, `*-demo(s).html`) at the repo root or under a `design/` folder. The only tracked `.html` files should be Vite `index.html` entrypoints.
Before committing or opening a PR, run `git status` and `git diff --staged --stat` and remove anything matching these patterns. Put scratch work under `.tmp/` (gitignored) instead of the repo root or the source tree.

View file

@ -1 +0,0 @@
AGENTS.md

231
GOAL.md
View file

@ -1,231 +0,0 @@
# Goal 功能拆分
本文把 agent-core 中 goal mode 的能力拆成三部分:
1. 核心工作流:没有它就不能运行 goal。
2. 统计 / token 数限制:让 goal 可度量、可限额、可审计。
3. 用户交互相关:让用户可以安全启动、理解、控制和恢复 goal。
## 1. 核心工作流
核心工作流是 goal mode 的运行骨架。它负责创建结构化目标、维护状态机、把普通 turn 串成自治多轮执行,并让模型用机器可读状态结束或停放目标。
### 目标状态
同一个 main agent 同时最多只有一个当前 goal。goal 不是普通聊天文本,而是 runtime 持有的结构化状态,至少包含目标、可选完成标准、当前状态、停止原因和运行统计。
状态分为四类:
- `active`:正在被 goal driver 推进。只有这个状态会自动运行下一轮。
- `paused`暂停但保留目标。通常来自用户暂停、中断、进程恢复后降级、provider 或 runtime 错误。可以恢复。
- `blocked`目标遇到真实阻塞但保留目标。通常来自模型判断需要外部输入、目标无法按当前表述完成、预算达到、prompt hook 阻止。可以恢复。
- `complete`瞬时完成状态。runtime 发出完成事件后立即清除 goal不长期持久化。
没有 `cancelled` 状态。取消就是清除 goal并提醒模型忽略之前关于该目标的 active reminder。
### 创建和替换
创建 goal 时runtime 需要校验目标不能为空、不能过长。已有 active、paused 或 blocked goal 时,默认拒绝创建新 goal防止静默覆盖。只有用户或调用方明确要求替换时才先清除旧 goal再创建新 goal。
新 goal 创建后进入 `active`,写入持久记录,并发出 goal 更新事件。
### 多轮驱动
goal driver 的职责是把一个 active goal 推进成连续的普通 turn
- turn 开始时如果 goal 已经是 `active`,进入 goal driver。
- 普通 turn 中如果模型创建了 goal或把 paused/blocked goal 恢复成 active当前 turn 结束后 goal driver 接管继续执行。
- driver 每次只运行一个普通 turn。
- 每个 turn 结束后读取 goal 状态。
- goal 仍是 `active`runtime 自动追加 continuation prompt 并启动下一轮。
- goal 变成 `paused``blocked` 或被清除时driver 停止。
模型如果不调用状态更新工具,且 goal 仍是 activeruntime 会继续下一轮。模型不能只靠自然语言说“完成了”来结束 goal必须给出结构化状态信号。
### Goal 注入
每个 goal turn 的边界runtime 会把当前 goal 状态注入上下文。注入内容包括:
- 当前正在 goal mode。
- 目标和完成标准是什么。
- 目标文本是用户提供的数据,不能覆盖 system/developer 指令、工具 schema、权限规则或 host 控制。
- 当前状态和进度。
- 模型应该做简短自审,然后推进一个连贯工作切片。
- 简单、已完成、不可能、不安全、矛盾的目标,应在同一轮内直接标记 complete 或 blocked。
- 只有全部要求完成、验证通过、没有下一步有用动作时,才能标记 complete。
- 外部条件或用户输入阻塞时,应标记 blocked。
- 不要只做了计划、总结、第一版或部分结果就标记 complete。
goal 注入只在 turn / continuation 边界做,不在每个 model step 都做,避免上下文重复膨胀,也有利于 prompt cache。
paused 和 blocked goal 的注入更轻:
- paused提醒模型目标存在但当前不应自治推进除非用户明确要求继续。
- blocked提醒模型目标被阻塞且当前不自治推进除非用户要求处理或恢复。
### Continuation prompt
当 goal 仍是 activeruntime 会追加一个系统触发输入,含义相当于“继续朝当前 active goal 工作”。它不只是简单续跑,还要求模型每轮重新判断:
- 是否已经完成。
- 是否遇到真实阻塞。
- 是否应该只推进一个合理切片后继续下一轮。
- 是否应该避免发散或启动无关工作。
- 除非真实阻塞,否则不要向用户要输入。
### 完成、阻塞和暂停
模型通过结构化状态更新控制 goal 生命周期:
- `complete`目标已满足runtime 发出完成事件并清除 goal。
- `blocked`遇到真实阻塞runtime 保留 goal 并停止自治推进。
- `paused`:暂时放下 goalruntime 保留 goal 并停止自治推进。
- `active`:恢复 paused 或 blocked goal。
状态更新工具的输入应保持窄,只表达机器状态。完成总结或阻塞原因由模型随后给用户说明。
当模型标记 complete 后runtime 应再给模型一次收尾机会,生成简短最终回复,说明 goal 已完成、主要做了什么、跑了什么验证。
当模型标记 blocked 后runtime 应再给模型一次收尾机会,说明具体阻塞、需要什么输入或变化才能继续。
如果当前 turn 已经没有 step 预算,不应为了收尾总结强行再跑一步,避免把“没法写总结”变成 turn 失败。
### 错误停车
goal mode 把技术运行失败视为可恢复停车:
- 用户中断当前 turngoal 变 paused。
- provider rate limitgoal 变 paused。
- provider 连接错误、认证错误、API 错误goal 变 paused。
- 模型配置错误goal 变 paused。
- runtime 异常goal 变 paused。
- provider safety filtergoal 变 paused。
业务、规则或外部条件阻塞则变 blocked
- prompt hook 阻止目标。
- 模型判断无法继续。
- 预算达到。
- 需要用户或外部系统提供新条件。
### 持久化和恢复
goal 的创建、更新、完成、阻塞、清除应写入可恢复记录。session 恢复时runtime 用记录重建 goal。
恢复时如果发现 goal 原来是 active不应自动继续跑而是降级为 paused。因为旧进程中的 active turn 不可能还活着,自动继续会造成重启后偷偷消耗资源。
paused 和 blocked 原样保留。complete 理论上不长期存在,因为完成后会清除。
fork session 时不继承源 session 的 goal并提醒模型不要继续源 session 的旧目标。
## 2. 统计 / token 数限制
这一部分让 goal 可度量、可限额、可审计。没有它goal 仍然可以运行,但不可控。
### 运行统计
goal 统计包括:
- continuation turn 数。
- token 数。
- active wall-clock 时间。
统计只在 goal 是 `active` 时增长。paused 和 blocked 期间不继续计数。
turn 统计在每个 goal turn 准备运行时增加,因此模型在某一轮里标记 complete 时,这一轮也计入最终统计。
token 统计在 model step 结束后累计。没有 active goal 时,不记入 goal。token 统计应以静默更新为主,不应每一步都刷 UI。
时间统计只计算 active pursuit 时间。进入 active 时开启计时区间,离开 active 时折算进累计时间pause/resume 会形成新的 active 区间。
### 预算
goal 预算包括:
- turn budget。
- token budget。
- wall-clock budget。
默认没有预算。只有用户明确给出硬限制时才设置,例如“最多 20 轮”“不超过 500k token”“30 分钟内”。模糊表达如“尽快”“别花太久”不能设置预算,模型也不能自行发明预算。
时间预算需要合理范围。过短或过长应拒绝。turn 和 token 预算应规范化为正整数。
### 预算硬停
预算检查应发生在 goal turn 开始前和结束后。token budget 还应在 model step 后触发停止,避免超额后继续下一步。
一旦达到预算runtime 应直接把 goal 标记为 blocked原因是配置预算已达到。这个 blocked 仍可恢复,但如果预算不变,恢复后可能立刻再次 blocked。
### 预算引导和最终统计
当预算未接近时,模型提示应鼓励稳定推进。当任一预算达到 75% 以上时,提示应转为收敛,避免启动新的可选工作。
complete 和 blocked 的最终回复提示应包含 worked turns、elapsed time、tokens used 等统计信息。UI 事件也应带当前 snapshot 和变化类型。
telemetry 可以记录 goal 创建、预算设置、continuation、状态变化、清除等事件但不应包含目标文本、停止原因等敏感内容。
## 3. 用户交互相关
这一部分让用户可以安全启动、理解、控制和恢复 goal。没有它runtime 仍可能运行,但交互体验和安全边界不足。
### 生命周期控制
用户可以直接控制 goal
- 创建。
- 查看。
- 暂停。
- 恢复。
- 取消。
这些操作可以不经过模型 turn。pause 把 active goal 变 pausedresume 把 paused 或 blocked goal 变 activecancel 直接清除当前 goal。
resume 会清除旧停止原因表示开始新的尝试。paused/blocked goal 不会因为用户发普通消息就自动继续。
### 模型发起 goal 的确认
模型可以代表用户创建 goal但只有在用户明确要求启动 goal、自治工作或宿主 goal-intake 提示要求时才应该这样做。普通请求不能被模型擅自升级成 goal。
模型发起 CreateGoal 时,非 auto 权限模式下应触发用户确认。确认菜单允许用户选择本次 goal 的运行权限模式。用户拒绝则 goal 不创建。
`GetGoal``SetGoalBudget``UpdateGoal` 只改 goal runtime 状态,默认可以更容易批准。真正写文件、跑 shell、访问敏感路径等仍走普通权限系统。
### 暂停、阻塞和取消后的提示
paused goal 的上下文提示应说明目标存在但当前不应继续做,除非用户明确要求继续。
blocked goal 的上下文提示应说明目标被阻塞且当前不自治推进,可以在用户要求时帮助解阻,否则正常处理当前请求。
cancel 后应追加提醒,让模型忽略旧 goal 的 active reminder避免旧上下文诱导模型继续已经取消的目标。
### 完成和阻塞的用户回复
complete 后goal 被清除,模型应给用户一条简短完成总结,说明完成了什么、做了什么验证。
blocked 后goal 保留,模型应给用户一条简短阻塞说明,说明具体阻塞和继续所需输入、权限、外部条件或变更。
### Tool 暴露和隔离
goal 工具只给 main agent。subagent 不应直接创建、恢复、结束主 goal。
没有 goal 时,模型不应看到 `UpdateGoal``SetGoalBudget`。有 goal 时才暴露这些控制工具。
goal ID 不应暴露给模型,因为它只是 runtime/UI 内部标识,没有用户语义。
### 辅助写 goal
`write-goal` 类能力用于帮助用户把粗糙意图整理成适合 goal mode 的完成契约。好的 goal 应明确:
- end state什么条件必须变成真。
- proof用什么可观察证据证明完成。
- boundaries工作范围和禁止触碰的内容。
- loop如何迭代推进。
- stop rule什么情况下停止并报告而不是强行继续。
预算是 opt-in不应默认加入也不应把 turn cap 写进目标文本。
### UI 和会话语义
goal 创建、暂停、恢复、阻塞、完成、清除都应发出 goal updated 事件。lifecycle 变化和 completion 变化应区分。completion 是一次终局事件,然后 snapshot 变 null。blocked/paused 保留 snapshotUI 可以继续展示可恢复 goal。
session 恢复时active goal 会变 paused避免重启后自动继续。fork session 时不继承 goal并提醒模型不要继续源 session 的目标。

View file

@ -1,11 +1,2 @@
# Copied from packages/kimi-core at build time
agents/
# Generated at build time by scripts/build-vis-asset.mjs.
# Only the ~150KB base64 VALUE file is ignored; the committed `.d.ts` stub
# next to it keeps `#/generated/vis-web-asset` type-resolvable on a fresh
# clone (before any build has produced the `.ts`).
src/generated/vis-web-asset.ts
# Copied from packages/pi-tui/native at build time by scripts/copy-native-assets.mjs
native/

File diff suppressed because it is too large Load diff

View file

@ -1,6 +1,6 @@
{
"name": "@moonshot-ai/kimi-code",
"version": "0.24.2",
"version": "0.13.0",
"description": "The Starting Point for Next-Gen Agents",
"license": "MIT",
"author": "Moonshot AI",
@ -27,8 +27,6 @@
},
"files": [
"dist",
"dist-web",
"native",
"scripts/postinstall.mjs",
"scripts/postinstall",
"README.md"
@ -36,22 +34,17 @@
"type": "module",
"imports": {
"#/tui/theme": "./src/tui/theme/index.ts",
"#/tui/commands": "./src/tui/commands/index.ts",
"#/cli/sub/server": "./src/cli/sub/server/index.ts",
"#/cli/sub/server/*": "./src/cli/sub/server/*.ts",
"#/generated/vis-web-asset": [
"./src/generated/vis-web-asset.ts",
"./src/generated/vis-web-asset.d.ts"
],
"#/*": "./src/*.ts"
"#/*": [
"./src/*.ts",
"./src/*/index.ts"
]
},
"publishConfig": {
"access": "public",
"provenance": true
},
"scripts": {
"build": "pnpm -C ../kimi-web run build && tsdown && node scripts/copy-native-assets.mjs && node scripts/copy-web-assets.mjs",
"prebuild": "node scripts/build-vis-asset.mjs",
"build": "tsdown",
"catalog:update": "node scripts/update-catalog.mjs --out dist/built-in-catalog.json",
"smoke": "node scripts/smoke.mjs",
"build:native:js": "node scripts/native/01-bundle.mjs",
@ -63,10 +56,6 @@
"test:native:smoke": "node scripts/native/smoke.mjs",
"dev": "node scripts/dev.mjs",
"dev:cli-only": "tsx --import ../../build/register-raw-text-loader.mjs ./src/main.ts",
"dev:server": "tsx --tsconfig ./tsconfig.dev.json --import ../../build/register-raw-text-loader.mjs ./src/main.ts server run --foreground",
"dev:kap-server": "tsx --tsconfig ./tsconfig.dev.json --import ../../build/register-raw-text-loader.mjs ./src/main.ts server run --foreground",
"dev:kap-server:multi": "KIMI_CODE_EXPERIMENTAL_MULTI_SERVER=1 tsx --tsconfig ./tsconfig.dev.json --import ../../build/register-raw-text-loader.mjs ./src/main.ts server run --foreground",
"dev:server:restart": "node scripts/dev-server-restart.mjs",
"dev:plugin-marketplace": "node scripts/dev-plugin-marketplace-server.mjs",
"build:plugin-marketplace": "node scripts/build-plugin-marketplace-cdn.mjs",
"dev:prod": "node dist/main.mjs",
@ -77,36 +66,29 @@
"e2e:real": "pnpm -w run build:packages && KIMI_E2E_REAL=1 vitest run test/e2e/real-llm-smoke.e2e.test.ts",
"postinstall": "node scripts/postinstall.mjs"
},
"optionalDependencies": {
"@mariozechner/clipboard": "^0.3.9",
"node-pty": "^1.1.0"
},
"devDependencies": {
"@moonshot-ai/acp-adapter": "workspace:^",
"@moonshot-ai/agent-core-v2": "workspace:^",
"@moonshot-ai/kap-server": "workspace:^",
"@moonshot-ai/kimi-code-oauth": "workspace:^",
"@moonshot-ai/kimi-code-sdk": "workspace:^",
"@moonshot-ai/kimi-telemetry": "workspace:^",
"@moonshot-ai/kimi-web": "workspace:^",
"@moonshot-ai/migration-legacy": "workspace:^",
"@moonshot-ai/pi-tui": "workspace:^",
"@moonshot-ai/vis-server": "workspace:^",
"@moonshot-ai/vis-web": "workspace:*",
"@types/semver": "^7.7.0",
"@types/yazl": "^2.4.6",
"dependencies": {
"@earendil-works/pi-tui": "^0.74.0",
"@mariozechner/clipboard": "^0.3.2",
"chalk": "^5.4.1",
"cli-highlight": "^2.1.11",
"commander": "^13.1.0",
"jimp": "^1.6.1",
"pathe": "^2.0.3",
"postject": "1.0.0-alpha.6",
"semver": "^7.7.4",
"smol-toml": "^1.6.1",
"tsx": "^4.21.0",
"yazl": "^3.3.1",
"zod": "^4.3.6"
},
"devDependencies": {
"@moonshot-ai/acp-adapter": "workspace:^",
"@moonshot-ai/kimi-code-oauth": "workspace:^",
"@moonshot-ai/kimi-code-sdk": "workspace:^",
"@moonshot-ai/kimi-telemetry": "workspace:^",
"@moonshot-ai/migration-legacy": "workspace:^",
"@types/semver": "^7.7.0",
"@types/yazl": "^2.4.6",
"postject": "1.0.0-alpha.6",
"tsx": "^4.21.0",
"yazl": "^3.3.1"
},
"engines": {
"node": ">=22.19.0"
}

View file

@ -1,54 +0,0 @@
// Builds the vis web single-file bundle, gzips it, and writes a generated
// TS module that embeds it as base64 so tsdown can later bundle it into
// dist/main.mjs (works identically for the npm package and the native SEA
// binary).
import { execSync } from 'node:child_process';
import { gzipSync } from 'node:zlib';
import { readFileSync, mkdirSync, writeFileSync } from 'node:fs';
import { dirname, join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
const here = dirname(fileURLToPath(import.meta.url));
const repoRoot = resolve(here, '..', '..', '..');
const visWeb = join(repoRoot, 'apps', 'vis', 'web');
const out = join(here, '..', 'src', 'generated', 'vis-web-asset.ts');
console.log('[build-vis-asset] building vis web single-file bundle…');
try {
// Run vite with VIS_SINGLEFILE set on the spawn so the build is
// cross-platform (Node sets the env, not a POSIX-only inline-env shell
// prefix). `pnpm --filter X exec` runs in X's package dir, so vite picks up
// vis-web's vite.config.ts, which gates the single-file output on
// `process.env.VIS_SINGLEFILE === '1'`.
// execSync runs through the platform shell, which is required on Windows:
// pnpm's launcher is `pnpm.cmd`, which a bare argv exec cannot resolve (no
// PATHEXT without a shell). The win32 native binary IS built on Windows
// runners (.github/workflows/_native-build.yml), which run this generator.
// A single command string (not an args array) avoids the args+shell
// deprecation; the command is static (no injection surface).
execSync('pnpm --filter @moonshot-ai/vis-web exec vite build', {
stdio: 'inherit',
cwd: repoRoot,
env: { ...process.env, VIS_SINGLEFILE: '1' },
});
} catch (err) {
throw new Error(
`[build-vis-asset] failed to run the vis-web single-file build via pnpm (is pnpm on PATH?): ${err instanceof Error ? err.message : String(err)}`,
);
}
const html = readFileSync(join(visWeb, 'dist-single', 'index.html'));
if (html.length < 1024 || !html.toString('utf8', 0, 256).toLowerCase().includes('<!doctype html')) {
throw new Error(
`[build-vis-asset] dist-single/index.html looks invalid (${html.length} bytes) — the web build may have failed`,
);
}
const b64 = gzipSync(html, { level: 9 }).toString('base64');
mkdirSync(dirname(out), { recursive: true });
writeFileSync(
out,
`// GENERATED by scripts/build-vis-asset.mjs — do not edit.\n` +
`export const VIS_WEB_GZIP_B64 = ${JSON.stringify(b64)};\n`,
);
console.log(`[build-vis-asset] wrote ${out} (${(b64.length / 1024).toFixed(0)} KB base64)`);

View file

@ -1,38 +0,0 @@
import { cp, mkdir, rm, stat } from 'node:fs/promises';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
const appRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..');
const repoRoot = resolve(appRoot, '../..');
const source = resolve(repoRoot, 'packages/pi-tui/native');
const target = resolve(appRoot, 'native');
// pi-tui ships platform-specific native helpers only for darwin/win32;
// Linux has no native helper, so there is nothing to copy for it.
const PLATFORMS = ['darwin', 'win32'];
async function assertPrebuilds(platform) {
const dir = resolve(source, platform, 'prebuilds');
try {
const info = await stat(dir);
if (!info.isDirectory()) {
throw new Error('not a directory');
}
} catch {
throw new Error(
`pi-tui native prebuilds were not found at ${dir}. Build or restore packages/pi-tui first.`,
);
}
return dir;
}
await rm(target, { recursive: true, force: true });
await mkdir(target, { recursive: true });
for (const platform of PLATFORMS) {
const srcPrebuilds = await assertPrebuilds(platform);
const dstPrebuilds = resolve(target, platform, 'prebuilds');
await cp(srcPrebuilds, dstPrebuilds, { recursive: true });
}
console.log(`Copied pi-tui native prebuilds to ${target}`);

View file

@ -1,27 +0,0 @@
import { cp, rm, stat } from 'node:fs/promises';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
const appRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..');
const repoRoot = resolve(appRoot, '../..');
const source = resolve(repoRoot, 'apps/kimi-web/dist');
const target = resolve(appRoot, 'dist-web');
async function assertBuiltWeb() {
try {
const info = await stat(resolve(source, 'index.html'));
if (!info.isFile()) {
throw new Error('index.html is not a file');
}
} catch {
throw new Error(
`Kimi web build output was not found at ${source}. Run \`pnpm --filter @moonshot-ai/kimi-web run build\` first.`,
);
}
}
await assertBuiltWeb();
await rm(target, { recursive: true, force: true });
await cp(source, target, { recursive: true });
console.log(`Copied Kimi web assets to ${target}`);

View file

@ -1,127 +0,0 @@
#!/usr/bin/env node
// Press-Enter-to-restart wrapper for the local server. No file watcher.
//
// Spawns `tsx ./src/main.ts server run …extraArgs` once, then on each newline
// read from stdin SIGTERMs the child and respawns after it has cleanly exited.
// SIGTERM triggers the server's own `shutdown()` handler
// (apps/kimi-code/src/cli/sub/server/run.ts) which releases the port lock and
// closes WS conns before exit, so a fresh start can re-acquire 58627 without a
// stale-lock fight.
//
// CLI args after `--` (or any extras) are passed straight through, so:
// pnpm dev:server:restart -- --host 0.0.0.0 --port 58627 --log-level debug
// is equivalent to `pnpm dev:server` with that arg list, but with the restart
// loop on top.
import { spawn } from 'node:child_process';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url));
const APP_ROOT = resolve(SCRIPT_DIR, '..');
const tsxBin = process.platform === 'win32' ? 'tsx.cmd' : 'tsx';
const cliArgs = process.argv.slice(2);
if (cliArgs[0] === '--') cliArgs.shift();
const tsxArgs = [
'--tsconfig',
'./tsconfig.dev.json',
'--import',
'../../build/register-raw-text-loader.mjs',
'./src/main.ts',
'server',
'run',
...cliArgs,
];
let child = null;
let restarting = false;
let shuttingDown = false;
let killTimer = null;
function start() {
console.error('[dev:server:restart] starting server…');
child = spawn(tsxBin, tsxArgs, {
cwd: APP_ROOT,
env: process.env,
// Server does not read stdin; keep ours free for the Enter trigger.
stdio: ['ignore', 'inherit', 'inherit'],
});
child.on('error', (err) => {
console.error(`[dev:server:restart] spawn error: ${err.message}`);
});
child.on('exit', (code, signal) => {
if (killTimer !== null) {
clearTimeout(killTimer);
killTimer = null;
}
const prev = child;
child = null;
if (shuttingDown) {
process.exit(code ?? 0);
return;
}
if (restarting) {
restarting = false;
start();
return;
}
// Server died on its own (port conflict, runtime error, etc.). Stay alive
// so the user can fix the issue and press Enter to retry.
const tag = signal !== null ? `signal=${signal}` : `code=${code}`;
console.error(
`[dev:server:restart] server exited (${tag}). Press Enter to restart, Ctrl+C to quit.`,
);
void prev; // silence unused warning
});
}
function restart() {
if (shuttingDown) return;
if (child === null) {
// Previous run already exited; just spin up a new one.
start();
return;
}
if (restarting) return; // debounce — multiple Enters during shutdown collapse
restarting = true;
console.error('[dev:server:restart] restarting…');
child.kill('SIGTERM');
// Safety net: if the child ignores SIGTERM, force-kill after 5s so the
// restart loop doesn't wedge.
killTimer = setTimeout(() => {
if (child !== null && child.exitCode === null && child.signalCode === null) {
console.error('[dev:server:restart] SIGTERM timed out, sending SIGKILL');
child.kill('SIGKILL');
}
}, 5000);
}
process.stdin.setEncoding('utf8');
process.stdin.on('data', (chunk) => {
// Any newline (Enter on most terminals) triggers a restart. Empty Enter is
// the canonical signal; typing `r<Enter>` works too.
if (chunk.includes('\n') || chunk.includes('\r')) {
restart();
}
});
const onShutdownSignal = (signal) => {
if (shuttingDown) return;
shuttingDown = true;
if (child !== null) {
child.kill(signal);
// Give the server a moment to flush logs / release the lock.
setTimeout(() => process.exit(0), 1000).unref();
} else {
process.exit(0);
}
};
process.on('SIGINT', () => onShutdownSignal('SIGINT'));
process.on('SIGTERM', () => onShutdownSignal('SIGTERM'));
start();

View file

@ -2,42 +2,22 @@
import { spawn } from 'node:child_process';
import { createRequire } from 'node:module';
import { dirname, resolve } from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
import { fileURLToPath } from 'node:url';
import { startPluginMarketplaceServer } from './dev-plugin-marketplace-server.mjs';
const require = createRequire(import.meta.url);
const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url));
const APP_ROOT = resolve(SCRIPT_DIR, '..');
// Monorepo root. Used as the dev CLI's working directory so `make dev` opens
// the whole repo instead of just apps/kimi-code.
const REPO_ROOT = resolve(APP_ROOT, '../..');
// Runtime variable the CLI reads to locate the marketplace JSON.
const MARKETPLACE_ENV = 'KIMI_CODE_PLUGIN_MARKETPLACE_URL';
// Opt-in for dev: point this run at an external marketplace instead of a local one.
const EXTERNAL_MARKETPLACE_ENV = 'KIMI_CODE_DEV_MARKETPLACE_URL';
let marketplaceServer;
const env = { ...process.env };
const externalUrl = process.env[EXTERNAL_MARKETPLACE_ENV]?.trim();
if (externalUrl !== undefined && externalUrl.length > 0) {
// Explicitly asked to use an external marketplace; don't start a local server.
env[MARKETPLACE_ENV] = externalUrl;
console.error(`Using external plugin marketplace: ${externalUrl}`);
} else {
// Default: every `pnpm run dev:cli` runs its own isolated marketplace server on a
// random port, so multiple concurrent dev instances never collide. Overwrite any
// inherited MARKETPLACE_ENV so a stale URL from a dead instance can't break this run.
const inherited = process.env[MARKETPLACE_ENV]?.trim();
if (env[MARKETPLACE_ENV] === undefined || env[MARKETPLACE_ENV]?.trim().length === 0) {
marketplaceServer = await startPluginMarketplaceServer();
env[MARKETPLACE_ENV] = marketplaceServer.marketplaceUrl;
console.error(`Plugin marketplace dev server: ${marketplaceServer.marketplaceUrl}`);
if (inherited !== undefined && inherited.length > 0 && inherited !== marketplaceServer.marketplaceUrl) {
console.error(
`(ignored inherited ${MARKETPLACE_ENV}=${inherited}; set ${EXTERNAL_MARKETPLACE_ENV} to use an external marketplace)`,
);
}
}
const tsxCli = require.resolve('tsx/cli');
@ -45,20 +25,9 @@ const cliArgs = process.argv.slice(2);
if (cliArgs[0] === '--') cliArgs.shift();
const child = spawn(
process.execPath,
[
tsxCli,
// Use the dev tsconfig whose `include` covers packages/*/src, so tsx's
// esbuild transform sees `experimentalDecorators: true` for DI parameter
// decorators in agent-core. Mirrors `dev:server` in package.json.
'--tsconfig',
resolve(APP_ROOT, 'tsconfig.dev.json'),
'--import',
pathToFileURL(resolve(REPO_ROOT, 'build/register-raw-text-loader.mjs')).href,
resolve(APP_ROOT, 'src/main.ts'),
...cliArgs,
],
[tsxCli, '--import', '../../build/register-raw-text-loader.mjs', './src/main.ts', ...cliArgs],
{
cwd: REPO_ROOT,
cwd: APP_ROOT,
env,
stdio: 'inherit',
},

View file

@ -6,14 +6,8 @@ import { run } from './exec.mjs';
const requireFromScript = createRequire(import.meta.url);
const tsdownCliPath = requireFromScript.resolve('tsdown/run');
const checkBundlePath = resolve(import.meta.dirname, 'check-bundle.mjs');
const buildVisAssetPath = resolve(import.meta.dirname, '..', 'build-vis-asset.mjs');
export async function runBundleStep() {
// Generate the embedded `kimi vis` web asset before bundling. The native
// tsdown run here never goes through the npm `prebuild` lifecycle, so the
// generated module must be produced explicitly first or the bundle would
// miss it (npm builds get it via the `prebuild` script).
await run(process.execPath, [buildVisAssetPath]);
await run(process.execPath, [tsdownCliPath, '--config', 'tsdown.native.config.ts']);
await run(process.execPath, [checkBundlePath]);
}

View file

@ -16,7 +16,6 @@ import {
nativeSeaConfigPath,
targetTriple,
} from './paths.mjs';
import { collectWebAssets, webAssetManifestKey } from './web-assets.mjs';
async function ensureBundleExists() {
try {
@ -32,19 +31,13 @@ async function writeSeaConfig(target) {
appRoot,
target,
});
const web = await collectWebAssets({ appRoot, target });
const manifestPath = resolve(nativeManifestDir(target), 'manifest.json');
const webManifestPath = resolve(nativeIntermediatesDir(), 'web-assets', target, 'manifest.json');
await mkdir(dirname(manifestPath), { recursive: true });
await mkdir(dirname(webManifestPath), { recursive: true });
await writeFile(manifestPath, manifestJson);
await writeFile(webManifestPath, web.manifestJson);
const seaAssets = {
[nativeAssetManifestKey(target)]: manifestPath,
[webAssetManifestKey(target)]: webManifestPath,
...assets,
...web.assets,
};
const config = {
main: nativeJsBundlePath(),
@ -62,9 +55,6 @@ async function writeSeaConfig(target) {
for (const line of nativeAssetSummary(manifest)) {
console.log(`- ${line}`);
}
console.log(
`Collected web assets for ${web.manifest.target}: ${web.manifest.files.length} files`,
);
}
export async function runSeaBlobStep() {

View file

@ -17,7 +17,9 @@ export const NATIVE_TARGETS = Object.freeze(
SUPPORTED_TARGETS.map((t) => {
const deps = resolveTargetDeps(t);
const clipboardTarget = deps.find((d) => d.id === 'clipboard-target')?.resolvedName;
return [t, { clipboardPackage: clipboardTarget }];
const koffiNativeFile = deps.find((d) => d.id === 'koffi')?.nativeFileRelatives?.[0];
const koffiTriplet = koffiNativeFile?.match(/koffi\/([^/]+)\/koffi\.node$/)?.[1] ?? null;
return [t, { clipboardPackage: clipboardTarget, koffiTriplet }];
}),
),
);
@ -159,19 +161,16 @@ async function collectPackageFiles({
packageName,
packageRoot,
includeNativeFiles,
includeEntryJs = true,
nativeFileRelatives = [],
}) {
const packageJsonPath = join(packageRoot, 'package.json');
const packageJson = await readJson(packageJsonPath);
const selected = new Set([packageJsonPath]);
if (includeEntryJs) {
const entry = resolvePackageEntry(packageRoot, packageJson);
if (entry !== null) {
selected.add(entry);
await addRuntimeDependencyFiles(packageRoot, entry, selected);
}
const entry = resolvePackageEntry(packageRoot, packageJson);
if (entry !== null) {
selected.add(entry);
await addRuntimeDependencyFiles(packageRoot, entry, selected);
}
for (const nativeFileRelative of nativeFileRelatives) {
@ -251,7 +250,6 @@ export async function collectNativeAssets({ appRoot, target }) {
packageName: dep.resolvedName,
packageRoot,
includeNativeFiles: dep.collect === 'native-files',
includeEntryJs: dep.collect !== 'native-file-only',
nativeFileRelatives: dep.nativeFileRelatives,
});
const result = await packageManifestEntries({

View file

@ -18,12 +18,10 @@ const optionalRuntimeRequires = new Set([
'canvas',
'chokidar',
'cpu-features',
'fast-json-stringify/lib/serializer',
'fast-json-stringify/lib/validator',
'utf-8-validate',
]);
const optionalRelativeRuntimeRequires = new Set(['./crypto/build/Release/sshcrypto.node']);
const handledNativeRuntimeRequires = new Set();
const handledNativeRuntimeRequires = new Set(['koffi']);
function isAllowedSpecifier(specifier) {
if (builtins.has(specifier) || specifier.startsWith('node:')) return true;
@ -46,7 +44,7 @@ function executableLines() {
}
for (const line of executableLines()) {
for (const match of line.matchAll(/(?<![.\w])require\(\s*["']([^"']+)["']\s*\)/g)) {
for (const match of line.matchAll(/\brequire\(\s*["']([^"']+)["']\s*\)/g)) {
const specifier = match[1];
if (specifier.startsWith('.') || specifier.startsWith('/')) {
if (optionalRelativeRuntimeRequires.has(specifier)) continue;

View file

@ -1,5 +1,4 @@
export const NATIVE_ASSET_MANIFEST_VERSION = 1;
export const WEB_ASSET_MANIFEST_VERSION = 1;
export function buildManifestKey(target) {
return `native/${target}/manifest.json`;
@ -12,11 +11,3 @@ export function isManifestVersionSupported(version) {
export function buildAssetKey(target, packageRoot, relativePath) {
return `native/${target}/${packageRoot}/${relativePath}`;
}
export function buildWebManifestKey(target) {
return `web/${target}/manifest.json`;
}
export function buildWebAssetKey(target, relativePath) {
return `web/${target}/dist-web/${relativePath}`;
}

View file

@ -27,16 +27,13 @@ const clipboardSubpackageByTarget = Object.freeze({
'win32-x64': '@mariozechner/clipboard-win32-x64-msvc',
});
// pi-tui ships platform-specific native helpers (no Linux build):
// - darwin: Shift-modifier detection for Terminal.app Shift+Enter
// - win32: enable ENABLE_VIRTUAL_TERMINAL_INPUT so Shift+Tab is distinguishable
const piTuiNativeFileByTarget = Object.freeze({
'darwin-arm64': ['native/darwin/prebuilds/darwin-arm64/darwin-modifiers.node'],
'darwin-x64': ['native/darwin/prebuilds/darwin-x64/darwin-modifiers.node'],
'linux-arm64': [],
'linux-x64': [],
'win32-arm64': ['native/win32/prebuilds/win32-arm64/win32-console-mode.node'],
'win32-x64': ['native/win32/prebuilds/win32-x64/win32-console-mode.node'],
const koffiTripletByTarget = Object.freeze({
'darwin-arm64': 'darwin_arm64',
'darwin-x64': 'darwin_x64',
'linux-arm64': 'linux_arm64',
'linux-x64': 'linux_x64',
'win32-arm64': 'win32_arm64',
'win32-x64': 'win32_x64',
});
export function isSupportedTarget(target) {
@ -48,15 +45,13 @@ export function isSupportedTarget(target) {
* @property {string} id stable internal id used for parent refs
* @property {(target: string) => string} name
* npm package name (may depend on target)
* @property {'js-only'|'native-files'|'js-and-native-file'|'native-file-only'|'virtual'} collect
* @property {'js-only'|'native-files'|'js-and-native-file'|'virtual'} collect
* @property {string|null} parent
* id of another registered dep this nests under (for pnpm),
* or null for top-level (resolvable from app root)
* @property {(target: string) => string[]} [nativeFileRelatives]
* explicit list of .node files relative to package root
* (used by 'js-and-native-file' and 'native-file-only';
* native-files mode auto-scans *.node). 'native-file-only' collects
* package.json + these .node files but skips the package entry JS.
* (used by 'js-and-native-file'; native-files mode auto-scans *.node)
*/
/** @type {readonly NativeDepDescriptor[]} */
@ -75,14 +70,18 @@ export const nativeDeps = Object.freeze([
},
{
id: 'pi-tui',
name: () => '@moonshot-ai/pi-tui',
// pi-tui's JS is bundled into main.cjs, so only the platform-specific
// native helper (.node under native/) ships alongside the binary — its
// dist/ JS is intentionally NOT collected (it stays in the bundle). This
// keeps the SEA native-asset payload small. Linux has no native helper.
collect: 'native-file-only',
name: () => '@earendil-works/pi-tui',
// pi-tui is bundled into main.cjs at build time — we don't collect it as
// a native dep, only register it so koffi can declare it as parent.
collect: 'virtual',
parent: null,
nativeFileRelatives: (target) => piTuiNativeFileByTarget[target] ?? [],
},
{
id: 'koffi',
name: () => 'koffi',
collect: 'js-and-native-file',
parent: 'pi-tui',
nativeFileRelatives: (target) => [`build/koffi/${koffiTripletByTarget[target]}/koffi.node`],
},
]);

View file

@ -1,118 +0,0 @@
import { createHash } from 'node:crypto';
import { existsSync } from 'node:fs';
import { readdir, readFile, stat } from 'node:fs/promises';
import { join, relative, resolve } from 'node:path';
import {
WEB_ASSET_MANIFEST_VERSION,
buildWebAssetKey,
buildWebManifestKey,
} from './manifest.mjs';
export { WEB_ASSET_MANIFEST_VERSION };
const WEB_ASSETS_DIR = 'dist-web';
function toPosixPath(path) {
return path.split('\\').join('/');
}
function sha256(bytes) {
return createHash('sha256').update(bytes).digest('hex');
}
async function listFiles(root) {
const files = [];
async function walk(dir) {
const entries = await readdir(dir, { withFileTypes: true });
for (const entry of entries) {
const path = join(dir, entry.name);
if (entry.isDirectory()) {
await walk(path);
continue;
}
if (entry.isFile()) {
files.push(path);
}
}
}
await walk(root);
return files;
}
async function assertBuiltAssetRoot({ assetRoot, requiredFile, message }) {
const requiredPath = join(assetRoot, requiredFile);
try {
const info = await stat(requiredPath);
if (!info.isFile()) {
throw new Error(`${requiredFile} is not a file`);
}
} catch {
throw new Error(message);
}
}
export function webAssetManifestKey(target) {
return buildWebManifestKey(target);
}
export function webAssetKey(target, relativePath) {
return buildWebAssetKey(target, relativePath);
}
async function collectAssetRoot({
appRoot,
target,
root,
requiredFile,
missingMessage,
assetKey,
}) {
const assetRoot = resolve(appRoot, ...root.split('/'));
await assertBuiltAssetRoot({ assetRoot, requiredFile, message: missingMessage });
const files = (await listFiles(assetRoot)).sort((a, b) => a.localeCompare(b));
const manifestFiles = [];
const assets = {};
for (const file of files) {
if (!existsSync(file)) continue;
const bytes = await readFile(file);
const relativePath = toPosixPath(relative(assetRoot, file));
const key = assetKey(target, relativePath);
manifestFiles.push({
assetKey: key,
relativePath,
sha256: sha256(bytes),
});
assets[key] = file;
}
const manifest = {
version: WEB_ASSET_MANIFEST_VERSION,
target,
root,
files: manifestFiles,
};
return {
manifest,
manifestJson: `${JSON.stringify(manifest, null, 2)}\n`,
assets,
};
}
export async function collectWebAssets({ appRoot, target }) {
const buildCommand =
'pnpm --filter @moonshot-ai/kimi-web run build && pnpm --filter @moonshot-ai/kimi-code run build';
return collectAssetRoot({
appRoot,
target,
root: WEB_ASSETS_DIR,
requiredFile: 'index.html',
missingMessage: `Kimi web build output was not found at ${resolve(appRoot, WEB_ASSETS_DIR)}. Run \`${buildCommand}\` before building native SEA assets. App root: ${appRoot}`,
assetKey: webAssetKey,
});
}

View file

@ -7,7 +7,6 @@ import { promisify } from 'node:util';
const execFileAsync = promisify(execFile);
const appRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..');
const bundlePath = resolve(appRoot, 'dist', 'main.mjs');
const webIndexPath = resolve(appRoot, 'dist-web', 'index.html');
const packageJson = JSON.parse(await readFile(resolve(appRoot, 'package.json'), 'utf-8'));
const expectedVersion = packageJson.version;
@ -24,14 +23,6 @@ async function ensureBundleExists() {
}
}
async function ensureRuntimeAssetsExist() {
try {
await stat(webIndexPath);
} catch {
fail(`Runtime asset not found at ${webIndexPath}. Run \`pnpm build\` first.`);
}
}
async function runBundle(args) {
try {
const { stdout, stderr } = await execFileAsync(process.execPath, [bundlePath, ...args], {
@ -54,7 +45,6 @@ function assertIncludes(output, expected, command) {
}
await ensureBundleExists();
await ensureRuntimeAssetsExist();
const versionOutput = await runBundle(['--version']);
assertIncludes(versionOutput, expectedVersion, '--version');
@ -65,7 +55,4 @@ assertIncludes(helpOutput, 'Usage: kimi', '--help');
const exportHelpOutput = await runBundle(['export', '--help']);
assertIncludes(exportHelpOutput, 'Usage: kimi export', 'export --help');
const webHelpOutput = await runBundle(['web', '--help']);
assertIncludes(webHelpOutput, 'Usage: kimi web', 'web --help');
console.log(`Bundle smoke passed: ${bundlePath}`);

View file

@ -24,10 +24,6 @@ const KEEP_MODEL = new Set([
"reasoning",
"interleaved",
"modalities",
// Message-level tool declarations capability — kosong's
// catalogModelToCapability reads it; stripping it here would silently
// disable tool-select for catalog-imported aliases.
"dynamically_loaded_tools",
]);
function resolveOutputFile(args) {

View file

@ -8,8 +8,6 @@ import { registerDoctorCommand } from './sub/doctor';
import { registerExportCommand } from './sub/export';
import { registerLoginCommand } from './sub/login';
import { registerProviderCommand } from './sub/provider';
import { registerServerCommand } from './sub/server';
import { registerVisCommand } from './sub/vis';
export type MainCommandHandler = (opts: CLIOptions) => void;
export type MigrateCommandHandler = () => void;
@ -44,8 +42,7 @@ export function createProgram(
.hideHelp()
.argParser((val: string | boolean) => (val === true ? '' : (val as string))),
)
.option('-c, --continue', 'Continue the previous session for the working directory.', false)
.addOption(new Option('-C').hideHelp().default(false))
.option('-C, --continue', 'Continue the previous session for the working directory.', false)
.option('-y, --yolo', 'Automatically approve all actions.', false)
.option('--auto', 'Start in auto permission mode.', false)
.addOption(
@ -74,14 +71,6 @@ export function createProgram(
.argParser((value: string, previous: string[] | undefined) => [...(previous ?? []), value])
.default([]),
)
.addOption(
new Option(
'--add-dir <dir>',
'Add an additional workspace directory for this session. Can be repeated.',
)
.argParser((value: string, previous: string[] | undefined) => [...(previous ?? []), value])
.default([]),
)
.addOption(new Option('--yes').hideHelp().default(false))
.addOption(new Option('--auto-approve').hideHelp().default(false))
.option('--plan', 'Start in plan mode.', false);
@ -89,14 +78,11 @@ export function createProgram(
registerExportCommand(program);
registerProviderCommand(program);
registerAcpCommand(program);
registerServerCommand(program);
registerLoginCommand(program);
registerDoctorCommand(program);
registerVisCommand(program);
registerMigrateCommand(program, onMigrate);
program
.command('upgrade')
.alias('update')
.description('Upgrade Kimi Code to the latest version.')
.action(async () => {
await onUpgrade();
@ -125,7 +111,7 @@ export function createProgram(
const opts: CLIOptions = {
session: sessionValue,
continue: raw['continue'] === true || raw['C'] === true,
continue: raw['continue'] as boolean,
yolo: yoloValue,
auto: autoValue,
plan: raw['plan'] as boolean,
@ -133,7 +119,6 @@ export function createProgram(
outputFormat: raw['outputFormat'] as CLIOptions['outputFormat'],
prompt: raw['prompt'] as string | undefined,
skillsDirs: raw['skillsDir'] as string[],
addDirs: raw['addDir'] as string[],
};
onMain(opts);

View file

@ -1,29 +0,0 @@
/**
* Experimental agent-core-v2 engine gate for `kimi -p` (print mode).
*
* When the master switch `KIMI_CODE_EXPERIMENTAL_FLAG` is truthy, print mode
* routes to the native agent-core-v2 runner instead of the default v1
* harness (see `run-prompt.ts`). Read directly from the env (matching
* `cli/update/rollout.ts`) because the CLI must not depend on the core flag
* registry. Unset / any non-truthy value keeps the v1 harness.
*
* Note: `kimi server run` always boots kap-server (the agent-core-v2 engine
* server) it no longer consults this switch.
*/
export const KIMI_V2_ENV = 'KIMI_CODE_EXPERIMENTAL_FLAG';
const TRUTHY_VALUES = new Set(['1', 'true', 'yes', 'on']);
function isTruthyEnv(
key: string,
env: Readonly<Record<string, string | undefined>>,
): boolean {
return TRUTHY_VALUES.has((env[key] ?? '').trim().toLowerCase());
}
export function isKimiV2Enabled(
env: Readonly<Record<string, string | undefined>> = process.env,
): boolean {
return isTruthyEnv(KIMI_V2_ENV, env);
}

View file

@ -46,18 +46,13 @@ const GOAL_PREFIX = /^\/goal(\s|$)/;
* Parses a headless prompt into a goal-create request, or `undefined` when the
* prompt is not a `/goal` create command (so the caller runs it as a normal
* prompt). Non-create goal subcommands are not supported headless and fall
* through to normal prompt handling. Malformed create commands throw instead of
* falling through, so validation errors are reported before anything is sent to
* the model.
* through to normal prompt handling.
*/
export function parseHeadlessGoalCreate(prompt: string): HeadlessGoalCreate | undefined {
const trimmed = prompt.trim();
if (!GOAL_PREFIX.test(trimmed)) return undefined;
const args = trimmed.replace(/^\/goal/, '').trim();
const parsed = parseGoalCommand(args);
if (parsed.kind === 'error') {
throw new Error(parsed.message);
}
if (parsed.kind !== 'create') return undefined;
return { objective: parsed.objective, replace: parsed.replace };
}

View file

@ -1,96 +0,0 @@
import type { Writable } from 'node:stream';
import { HEADLESS_FORCE_EXIT_GRACE_MS, HEADLESS_STDIO_DRAIN_TIMEOUT_MS } from '#/constant/app';
/** Minimal process surface needed to force a headless run to terminate. */
export interface ExitableProcess {
exit(code?: number): void;
}
/**
* Schedule a best-effort force-exit for a completed headless (`kimi -p`) run.
*
* Print mode does not call `process.exit()`; it relies on the Node event loop
* draining once the run is done. If a stray ref'd handle survives shutdown a
* lingering socket (e.g. a connection blackholed by a restrictive firewall, or
* an HTTP/2 session kept alive by PING), an un-cleared timer, or a child whose
* pipes stay open the loop never empties and the process hangs until an
* external timeout kills it.
*
* This arms an **unref'd** fallback timer: a healthy run drains and exits
* naturally before it fires (so behaviour is unchanged), and the timer itself
* never keeps the loop alive. It only force-exits a run whose loop is already
* wedged. The exit code is read lazily at fire time so callers may set
* `process.exitCode` after scheduling (e.g. a goal turn mapping its terminal
* status to a non-zero code).
*
* Returns the timer handle so callers/tests can `clearTimeout` it.
*/
export function scheduleHeadlessForceExit(
proc: ExitableProcess,
getExitCode: () => number,
graceMs: number = HEADLESS_FORCE_EXIT_GRACE_MS,
): NodeJS.Timeout {
const timer = setTimeout(() => {
proc.exit(getExitCode());
}, graceMs);
timer.unref?.();
return timer;
}
/** Resolve once a stream's currently-buffered writes have flushed to its sink. */
function flushStream(stream: Writable): Promise<void> {
return new Promise<void>((resolve) => {
try {
// An empty write's callback fires after all previously-queued writes have
// been flushed (writes are ordered), which is the documented way to know a
// stream's buffer has drained.
stream.write('', () => resolve());
} catch {
resolve();
}
});
}
/**
* Wait for buffered output on the given streams to flush, bounded by `timeoutMs`.
*
* A slow or piped consumer that hasn't read all of stdout/stderr yet leaves the
* pipe as a legitimate ref'd handle keeping the loop alive. Flushing before any
* force-exit prevents truncating output from an otherwise-successful run. The
* wait is bounded so a permanently-stuck consumer can't re-introduce the hang.
*/
export async function drainStdio(
streams: readonly Writable[],
timeoutMs: number = HEADLESS_STDIO_DRAIN_TIMEOUT_MS,
): Promise<void> {
let timer: NodeJS.Timeout | undefined;
const timeout = new Promise<void>((resolve) => {
timer = setTimeout(resolve, timeoutMs);
timer.unref?.();
});
try {
await Promise.race([Promise.all(streams.map(flushStream)).then(() => undefined), timeout]);
} finally {
if (timer !== undefined) clearTimeout(timer);
}
}
/**
* Finalize a completed headless run: flush stdio, then arm the force-exit
* backstop.
*
* Draining first means in-flight legitimate output is fully written before the
* backstop can fire, and since drained stdio no longer holds the loop only a
* genuinely leaked handle can keep it alive afterwards, which is exactly what
* the backstop is for.
*/
export async function finalizeHeadlessRun(
proc: ExitableProcess,
streams: readonly Writable[],
getExitCode: () => number,
options: { drainTimeoutMs?: number; graceMs?: number } = {},
): Promise<void> {
await drainStdio(streams, options.drainTimeoutMs ?? HEADLESS_STDIO_DRAIN_TIMEOUT_MS);
scheduleHeadlessForceExit(proc, getExitCode, options.graceMs);
}

View file

@ -1,39 +1,6 @@
export type UIMode = 'shell' | 'print';
export type PromptOutputFormat = 'text' | 'stream-json';
/** Environment variable that sets the default `-p` output format (flag wins). */
export const OUTPUT_FORMAT_ENV = 'KIMI_MODEL_OUTPUT_FORMAT';
const OUTPUT_FORMATS = ['text', 'stream-json'] as const;
function isOutputFormat(value: string): value is PromptOutputFormat {
return (OUTPUT_FORMATS as readonly string[]).includes(value);
}
/**
* Resolve the effective `-p` output format.
*
* Precedence: explicit `--output-format` flag `KIMI_MODEL_OUTPUT_FORMAT` env
* (prompt mode only) `text`. The env var is ignored outside prompt mode so an
* ambient value never affects interactive `kimi`. An invalid env value fails
* fast via `OptionConflictError`.
*/
export function resolveOutputFormat(
opts: Pick<CLIOptions, 'prompt' | 'outputFormat'>,
env: Readonly<Record<string, string | undefined>> = process.env,
): PromptOutputFormat {
if (opts.outputFormat !== undefined) return opts.outputFormat;
if (opts.prompt === undefined) return 'text';
const raw = (env[OUTPUT_FORMAT_ENV] ?? '').trim();
if (raw.length === 0) return 'text';
if (!isOutputFormat(raw)) {
throw new OptionConflictError(
`Invalid ${OUTPUT_FORMAT_ENV} value "${raw}". Expected one of: text, stream-json.`,
);
}
return raw;
}
export interface CLIOptions {
session: string | undefined;
continue: boolean;
@ -44,7 +11,6 @@ export interface CLIOptions {
outputFormat: PromptOutputFormat | undefined;
prompt: string | undefined;
skillsDirs: string[];
addDirs?: string[];
}
export interface ValidatedOptions {
@ -59,10 +25,7 @@ export class OptionConflictError extends Error {
}
}
export function validateOptions(
opts: CLIOptions,
env: Readonly<Record<string, string | undefined>> = process.env,
): ValidatedOptions {
export function validateOptions(opts: CLIOptions): ValidatedOptions {
const prompt = opts.prompt;
const promptMode = prompt !== undefined;
if (promptMode && prompt.trim().length === 0) {
@ -92,8 +55,14 @@ export function validateOptions(
if (opts.yolo && opts.auto) {
throw new OptionConflictError('Cannot combine --yolo with --auto.');
}
// Validate `KIMI_MODEL_OUTPUT_FORMAT` eagerly in prompt mode so a typo fails
// fast through the friendly `error:` path instead of mid-run.
if (promptMode) resolveOutputFormat(opts, env);
if (!promptMode && (opts.continue || opts.session !== undefined) && opts.yolo) {
throw new OptionConflictError('Cannot combine --yolo with --continue or --session.');
}
if (!promptMode && (opts.continue || opts.session !== undefined) && opts.auto) {
throw new OptionConflictError('Cannot combine --auto with --continue or --session.');
}
if (!promptMode && (opts.continue || opts.session !== undefined) && opts.plan) {
throw new OptionConflictError('Cannot combine --plan with --continue or --session.');
}
return { options: opts, uiMode: promptMode ? 'print' : 'shell' };
}

View file

@ -1,409 +0,0 @@
/**
* Output rendering for `kimi -p` (print mode) shared by the v1 driver
* (`run-prompt.ts`) and the native v2 runner (`v2/run-v2-print.ts`).
*
* Both engines feed the same writer classes: v1 via the SDK `Event` stream, v2
* via the main agent's native `IEventBus` (whose `DomainEvent` payloads are
* already v1-protocol-shaped). Keeping the writers here lets v2 reuse them
* without re-implementing rendering, while v1's `runPromptTurn` keeps its own
* event-filtering / completion flow intact.
*/
import type { PromptOutputFormat } from './options';
/**
* Structural hook-result shape the renderer reads. Both the v1 SDK
* `HookResultEvent` and the v2 native `hook.result` `DomainEvent` satisfy it,
* so the renderer stays engine-agnostic without depending on either event
* definition.
*/
interface HookResultEventLike {
readonly hookEvent: string;
readonly content: string;
readonly blocked?: boolean;
}
/**
* Structural retry shape the renderer reads. Mirrors the v1 SDK
* `turn.step.retrying` event fields the stream-json meta line surfaces. Only
* the v1 driver forwards retries to `writeRetrying`; the v2 runner currently
* just discards the failed attempt's partial output and stays silent.
*/
interface RetryingEventLike {
readonly failedAttempt: number;
readonly nextAttempt: number;
readonly maxAttempts: number;
readonly delayMs: number;
readonly errorName: string;
readonly errorMessage: string;
readonly statusCode?: number;
}
export interface PromptOutput {
readonly columns?: number | undefined;
write(chunk: string): boolean;
}
const PROMPT_BLOCK_BULLET = '• ';
const PROMPT_BLOCK_INDENT = ' ';
export interface PromptTurnWriter {
writeAssistantDelta(delta: string): void;
writeHookResult(event: HookResultEventLike): void;
writeThinkingDelta(delta: string): void;
writeToolCall(toolCallId: string, name: string, args: unknown): void;
writeToolCallDelta(
toolCallId: string,
name: string | undefined,
argumentsPart: string | undefined,
): void;
writeToolResult(toolCallId: string, output: unknown): void;
writeRetrying(event: RetryingEventLike): void;
flushAssistant(): void;
discardAssistant(): void;
finish(): void;
}
interface PromptJsonToolCall {
type: 'function';
id: string;
function: {
name: string;
arguments: string;
};
}
interface PromptJsonAssistantMessage {
role: 'assistant';
content?: string;
tool_calls?: PromptJsonToolCall[];
}
interface PromptJsonToolMessage {
role: 'tool';
tool_call_id: string;
content: string;
}
interface PromptJsonRetryMetaMessage {
role: 'meta';
type: 'turn.step.retrying';
failed_attempt: number;
next_attempt: number;
max_attempts: number;
delay_ms: number;
error_name: string;
error_message: string;
status_code?: number;
}
export class PromptTranscriptWriter implements PromptTurnWriter {
private readonly assistantWriter: PromptBlockWriter;
private readonly thinkingWriter: PromptBlockWriter;
constructor(stdout: PromptOutput, stderr: PromptOutput) {
this.assistantWriter = new PromptBlockWriter(stdout);
this.thinkingWriter = new PromptBlockWriter(stderr);
}
writeAssistantDelta(delta: string): void {
this.thinkingWriter.finish();
this.assistantWriter.write(delta);
}
writeHookResult(event: HookResultEventLike): void {
this.thinkingWriter.finish();
this.assistantWriter.finish();
this.assistantWriter.write(formatHookResultPlain(event));
this.assistantWriter.finish();
}
writeThinkingDelta(delta: string): void {
this.thinkingWriter.write(delta);
}
writeToolCall(): void {}
writeToolCallDelta(): void {}
writeToolResult(): void {}
// Text `-p` keeps retries silent: only the failed attempt's partial assistant
// text is discarded (handled by the caller). No human-readable retry line is
// emitted, matching the prior behavior.
writeRetrying(): void {}
flushAssistant(): void {
this.assistantWriter.finish();
}
discardAssistant(): void {}
finish(): void {
this.thinkingWriter.finish();
this.assistantWriter.finish();
}
}
export class PromptJsonWriter implements PromptTurnWriter {
private assistantText = '';
private readonly toolCalls: PromptJsonToolCall[] = [];
constructor(private readonly stdout: PromptOutput) {}
writeAssistantDelta(delta: string): void {
this.assistantText += delta;
}
writeHookResult(event: HookResultEventLike): void {
this.flushAssistant();
this.writeJsonLine({
role: 'assistant',
content: formatHookResultPlain(event),
});
}
writeThinkingDelta(): void {}
writeToolCall(toolCallId: string, name: string, args: unknown): void {
const existing = this.toolCalls.find((toolCall) => toolCall.id === toolCallId);
if (existing !== undefined) {
existing.function.name = name;
existing.function.arguments = stringifyJsonValue(args);
return;
}
this.toolCalls.push({
type: 'function',
id: toolCallId,
function: {
name,
arguments: stringifyJsonValue(args),
},
});
}
writeToolCallDelta(
toolCallId: string,
name: string | undefined,
argumentsPart: string | undefined,
): void {
const toolCall = this.findOrCreateToolCall(toolCallId, name ?? '');
if (name !== undefined) {
toolCall.function.name = name;
}
if (argumentsPart !== undefined) {
toolCall.function.arguments += argumentsPart;
}
}
writeToolResult(toolCallId: string, output: unknown): void {
this.flushAssistant();
this.writeJsonLine({
role: 'tool',
tool_call_id: toolCallId,
content: stringifyToolOutput(output),
});
}
writeRetrying(event: RetryingEventLike): void {
// Emit a machine-readable meta line so stream-json consumers can observe
// provider retries. The failed attempt's partial assistant text was already
// discarded by the caller, so no half-formed assistant message leaks.
const message: PromptJsonRetryMetaMessage = {
role: 'meta',
type: 'turn.step.retrying',
failed_attempt: event.failedAttempt,
next_attempt: event.nextAttempt,
max_attempts: event.maxAttempts,
delay_ms: event.delayMs,
error_name: event.errorName,
error_message: event.errorMessage,
status_code: event.statusCode,
};
this.writeJsonLine(message);
}
flushAssistant(): void {
if (this.assistantText.length === 0 && this.toolCalls.length === 0) return;
const message: PromptJsonAssistantMessage = {
role: 'assistant',
content: this.assistantText.length > 0 ? this.assistantText : undefined,
tool_calls: this.toolCalls.length > 0 ? [...this.toolCalls] : undefined,
};
this.writeJsonLine(message);
this.discardAssistant();
}
discardAssistant(): void {
this.assistantText = '';
this.toolCalls.length = 0;
}
finish(): void {
this.flushAssistant();
}
private findOrCreateToolCall(toolCallId: string, name: string): PromptJsonToolCall {
const existing = this.toolCalls.find((toolCall) => toolCall.id === toolCallId);
if (existing !== undefined) return existing;
const toolCall: PromptJsonToolCall = {
type: 'function',
id: toolCallId,
function: {
name,
arguments: '',
},
};
this.toolCalls.push(toolCall);
return toolCall;
}
private writeJsonLine(
message: PromptJsonAssistantMessage | PromptJsonToolMessage | PromptJsonRetryMetaMessage,
): void {
this.stdout.write(`${JSON.stringify(message)}\n`);
}
}
class PromptBlockWriter {
private started = false;
private atLineStart = false;
private lineWidth = 0;
private readonly wrapWidth: number | undefined;
constructor(private readonly output: PromptOutput) {
this.wrapWidth =
typeof output.columns === 'number' && output.columns > PROMPT_BLOCK_INDENT.length + 1
? output.columns
: undefined;
}
write(chunk: string): void {
if (chunk.length === 0) return;
let rendered = this.start();
for (const char of chunk) {
if (this.atLineStart && char !== '\n') {
rendered += PROMPT_BLOCK_INDENT;
this.atLineStart = false;
this.lineWidth = PROMPT_BLOCK_INDENT.length;
}
const charWidth = visibleCharWidth(char);
if (
this.wrapWidth !== undefined &&
!this.atLineStart &&
char !== '\n' &&
this.lineWidth + charWidth > this.wrapWidth
) {
rendered += `\n${PROMPT_BLOCK_INDENT}`;
this.lineWidth = PROMPT_BLOCK_INDENT.length;
}
rendered += char;
if (char === '\n') {
this.atLineStart = true;
this.lineWidth = 0;
} else {
this.lineWidth += charWidth;
}
}
this.output.write(rendered);
}
finish(): void {
if (!this.started) return;
this.output.write(this.atLineStart ? '\n' : '\n\n');
this.started = false;
this.atLineStart = false;
this.lineWidth = 0;
}
private start(): string {
if (this.started) return '';
this.started = true;
this.atLineStart = false;
this.lineWidth = PROMPT_BLOCK_BULLET.length;
return PROMPT_BLOCK_BULLET;
}
}
function visibleCharWidth(char: string): number {
return char === '\t' ? 4 : 1;
}
function formatHookResultPlain(event: HookResultEventLike): string {
return `${formatHookResultTitle(event)}\n\n${formatHookResultBody(event)}`;
}
function formatHookResultTitle(event: HookResultEventLike): string {
return `${event.hookEvent} hook${event.blocked === true ? ' blocked' : ''}`;
}
function formatHookResultBody(event: HookResultEventLike): string {
const content = event.content.trim();
return content.length === 0 ? '(empty)' : content;
}
function stringifyJsonValue(value: unknown): string {
if (typeof value === 'string') return value;
const json = JSON.stringify(value);
return json ?? '';
}
function stringifyToolOutput(output: unknown): string {
if (typeof output === 'string') return output;
const json = JSON.stringify(output);
return json ?? String(output);
}
interface PromptJsonResumeMetaMessage {
role: 'meta';
type: 'session.resume_hint';
session_id: string;
command: string;
content: string;
}
interface PromptJsonVersionMetaMessage {
role: 'meta';
type: 'system.version';
version: string;
}
export function writeExperimentalVersion(
version: string,
outputFormat: PromptOutputFormat,
stdout: PromptOutput,
stderr: PromptOutput,
): void {
if (outputFormat === 'stream-json') {
const message: PromptJsonVersionMetaMessage = {
role: 'meta',
type: 'system.version',
version,
};
stdout.write(`${JSON.stringify(message)}\n`);
return;
}
stderr.write(`kimi version ${version}\n`);
}
export function writeResumeHint(
sessionId: string,
outputFormat: PromptOutputFormat,
stdout: PromptOutput,
stderr: PromptOutput,
): void {
const command = `kimi -r ${sessionId}`;
const content = `To resume this session: ${command}`;
if (outputFormat === 'stream-json') {
const message: PromptJsonResumeMetaMessage = {
role: 'meta',
type: 'session.resume_hint',
session_id: sessionId,
command,
content,
};
stdout.write(`${JSON.stringify(message)}\n`);
return;
}
stderr.write(`${content}\n`);
}

View file

@ -1,66 +0,0 @@
/**
* Minimal harness/session surface consumed by `kimi -p` (print mode).
*
* `run-prompt.ts` only needs a small subset of the SDK `KimiHarness` / `Session`
* API. Coding the print-mode driver against these narrow interfaces instead of
* the concrete SDK classes lets the same driver run on either the v1 engine
* (`createKimiHarness`, the default) or the experimental agent-core-v2 engine
* (`createPromptHarnessV2`, gated by `KIMI_CODE_EXPERIMENTAL_FLAG`). Both the
* v1 `KimiHarness` / `Session` and the v2 harness structurally satisfy these
* interfaces, so no adapter wrappers are needed on the v1 path.
*/
import type {
ApprovalHandler,
ConfigDiagnostics,
CreateGoalInput,
CreateSessionOptions,
Event,
GetCronTasksResult,
GoalSnapshot,
GoalToolResult,
KimiAuthFacade,
KimiConfig,
ListSessionsOptions,
PermissionMode,
PromptInput,
QuestionHandler,
ResumeSessionInput,
SessionStatus,
SessionSummary,
TelemetryProperties,
Unsubscribe,
} from '@moonshot-ai/kimi-code-sdk';
export interface PromptHarness {
readonly homeDir: string;
readonly auth: KimiAuthFacade;
track(event: string, properties?: TelemetryProperties): void;
ensureConfigFile(): Promise<void>;
getConfig(): Promise<Pick<KimiConfig, 'defaultModel' | 'telemetry'>>;
getConfigDiagnostics(): Promise<ConfigDiagnostics>;
listSessions(options: ListSessionsOptions): Promise<readonly SessionSummary[]>;
createSession(options: CreateSessionOptions): Promise<PromptSession>;
resumeSession(input: ResumeSessionInput): Promise<PromptSession>;
close(): Promise<void>;
}
export interface PromptSession {
readonly id: string;
readonly workDir: string;
getStatus(): Promise<SessionStatus>;
setModel(model: string): Promise<void>;
setPermission(mode: PermissionMode): Promise<void>;
setApprovalHandler(handler: ApprovalHandler | undefined): void;
setQuestionHandler(handler: QuestionHandler | undefined): void;
onEvent(listener: (event: Event) => void): Unsubscribe;
prompt(input: string | PromptInput): Promise<void>;
waitForBackgroundTasksOnPrint(): Promise<void>;
handlePrintMainTurnCompleted?(): Promise<'finish' | 'continue'>;
createGoal(input: CreateGoalInput): Promise<GoalSnapshot>;
getGoal(): Promise<GoalToolResult>;
getCronTasks(): Promise<GetCronTasksResult>;
}

View file

@ -11,15 +11,16 @@ import {
log,
type Event,
type GoalSnapshot,
type HookResultEvent,
type KimiHarness,
type Session,
type SessionStatus,
type TelemetryClient,
} from '@moonshot-ai/kimi-code-sdk';
import { resolve } from 'pathe';
import { CLI_SHUTDOWN_TIMEOUT_MS, PROMPT_CLEANUP_TIMEOUT_MS } from '#/constant/app';
import { CLI_SHUTDOWN_TIMEOUT_MS } from '#/constant/app';
import { isKimiV2Enabled } from './experimental-v2';
import { resolveOutputFormat } from './options';
import type { CLIOptions, PromptOutputFormat } from './options';
import {
formatGoalSummaryText,
@ -28,64 +29,21 @@ import {
parseHeadlessGoalCreate,
type HeadlessGoalCreate,
} from './goal-prompt';
import type { PromptHarness, PromptSession } from './prompt-session';
import { PromptJsonWriter, PromptTranscriptWriter, writeResumeHint } from './prompt-render';
import { createCliTelemetryBootstrap, initializeCliTelemetry } from './telemetry';
import { createKimiCodeHostIdentity } from './version';
/**
* Await `promise`, but stop waiting after `timeoutMs`.
*
* The timeout only bounds how long we WAIT it does not change the outcome:
* - if `promise` settles first, its result is propagated (a rejection throws),
* so a cleanup step that actually fails in time still surfaces;
* - if the timeout wins, we resolve (give up waiting) and swallow the abandoned
* promise's eventual late rejection so it can't surface as an unhandled
* rejection.
*
* Used to bound shutdown so a wedged cleanup step can't keep a completed
* headless run alive, without silently swallowing a cleanup that fails fast. The
* timer stays ref'd so a cleanup step that suspends on an unref'd handle (e.g.
* telemetry's retry backoff when the network is blocked) can't drain the event
* loop and exit 0 before the rejection propagates the timer keeps the loop
* alive until it fires, then gives the rejection a chance to surface. A wedged
* cleanup is still bounded by `timeoutMs`, so this can't hang the run forever.
*/
export async function raceWithTimeout(promise: Promise<void>, timeoutMs: number): Promise<void> {
let timedOut = false;
let timer: ReturnType<typeof setTimeout> | undefined;
// Attach the catch eagerly (synchronously) so `promise` is always consumed and
// a late rejection can never become an unhandled rejection. Before the timeout
// wins, the handler rethrows so a real cleanup failure still propagates.
const guarded = promise.catch((error: unknown) => {
if (timedOut) return;
throw error;
});
const timedOutSignal = new Promise<void>((resolve) => {
timer = setTimeout(() => {
timedOut = true;
resolve();
}, timeoutMs);
});
try {
await Promise.race([guarded, timedOutSignal]);
} finally {
if (timer !== undefined) clearTimeout(timer);
}
}
interface PromptOutput {
readonly columns?: number | undefined;
write(chunk: string): boolean;
}
export interface PromptRunIO {
interface PromptRunIO {
readonly stdout?: PromptOutput;
readonly stderr?: PromptOutput;
readonly process?: PromptProcess;
}
export interface PromptProcess {
interface PromptProcess {
once(signal: NodeJS.Signals, listener: () => Promise<void>): unknown;
off(signal: NodeJS.Signals, listener: () => Promise<void>): unknown;
exit(code?: number): never | void;
@ -93,27 +51,18 @@ export interface PromptProcess {
const PROMPT_UI_MODE = 'print';
const PROMPT_MAIN_AGENT_ID = 'main';
const PROMPT_BLOCK_BULLET = '• ';
const PROMPT_BLOCK_INDENT = ' ';
export async function runPrompt(
opts: CLIOptions,
version: string,
io: PromptRunIO = {},
): Promise<void> {
if (isKimiV2Enabled()) {
// The experimental agent-core-v2 engine runs on its own native DI service
// runtime (see v2/run-v2-print.ts); it does not share the v1 PromptHarness
// path below. Loaded lazily so the v2 module graph stays off the default
// (v1) path.
const { runV2Print } = await import('./v2/run-v2-print');
await runV2Print(opts, version, io);
return;
}
const startedAt = Date.now();
const stdout = io.stdout ?? process.stdout;
const stderr = io.stderr ?? process.stderr;
const promptProcess = io.process ?? process;
const outputFormat = resolveOutputFormat(opts);
const workDir = process.cwd();
const telemetryBootstrap = createCliTelemetryBootstrap();
const telemetryClient: TelemetryClient = {
@ -121,7 +70,7 @@ export async function runPrompt(
withContext: withTelemetryContext,
setContext: setTelemetryContext,
};
const harness = await createPromptHarness({
const harness = createKimiHarness({
homeDir: telemetryBootstrap.homeDir,
identity: createKimiCodeHostIdentity(version),
uiMode: PROMPT_UI_MODE,
@ -129,12 +78,11 @@ export async function runPrompt(
telemetry: telemetryClient,
onOAuthRefresh: (outcome) => {
if (outcome.success) {
track('oauth_refresh', { outcome: 'success' });
track('oauth_refresh', { success: true });
return;
}
track('oauth_refresh', { outcome: 'error', reason: outcome.reason });
track('oauth_refresh', { success: false, reason: outcome.reason });
},
sessionStartedProperties: { yolo: false, plan: false, afk: true },
});
log.info('kimi-code starting', {
version,
@ -147,7 +95,7 @@ export async function runPrompt(
let removeTerminationCleanup: (() => void) | undefined;
let cleanupPromise: Promise<void> | undefined;
const cleanupPromptRun = async (): Promise<void> => {
const pending = (cleanupPromise ??= (async () => {
cleanupPromise ??= (async () => {
removeTerminationCleanup?.();
setCrashPhase('shutdown');
try {
@ -156,23 +104,15 @@ export async function runPrompt(
await shutdownTelemetry({ timeoutMs: CLI_SHUTDOWN_TIMEOUT_MS });
await harness.close();
}
})());
// Bound cleanup so a wedged shutdown step (e.g. a SessionEnd hook, MCP
// shutdown, or a connection blackholed by a restrictive firewall) cannot
// keep a completed headless run alive forever. The cleanup keeps running in
// the background if it overruns; the caller (`kimi -p`) force-exits shortly
// after, so any straggling work is torn down with the process.
await raceWithTimeout(pending, PROMPT_CLEANUP_TIMEOUT_MS);
})();
await cleanupPromise;
};
removeTerminationCleanup = installPromptTerminationCleanup(promptProcess, cleanupPromptRun);
try {
await harness.ensureConfigFile();
const config = await harness.getConfig();
for (const warning of (await harness.getConfigDiagnostics()).warnings) {
stderr.write(`Warning: ${warning}\n`);
}
const { session, restorePermission, telemetryModel, goalModel } =
const { session, resumed, restorePermission, telemetryModel, goalModel } =
await resolvePromptSession(
harness,
opts,
@ -192,10 +132,17 @@ export async function runPrompt(
version,
uiMode: PROMPT_UI_MODE,
model: telemetryModel,
sessionId: session.id,
});
setCrashPhase('runtime');
withTelemetryContext({ sessionId: session.id }).track('started', {
resumed,
yolo: false,
plan: false,
afk: true,
});
const outputFormat = opts.outputFormat ?? 'text';
// Headless goal mode: `kimi -p "/goal <objective>"`. The goal driver keeps
// the turn-run alive across continuation turns, so the normal prompt-turn
// waiter blocks until the goal is terminal; we then emit a summary and set a
@ -204,34 +151,20 @@ export async function runPrompt(
if (goalCreate !== undefined) {
await runHeadlessGoal(session, goalCreate, goalModel, outputFormat, stdout, stderr);
} else {
await runPromptTurn(
session as PrintTurnSession,
opts.prompt!,
outputFormat,
stdout,
stderr,
);
await runPromptTurn(session, opts.prompt!, outputFormat, stdout, stderr);
}
writeResumeHint(session.id, outputFormat, stdout, stderr);
withTelemetryContext({ sessionId: session.id }).track('exit', {
duration_ms: Date.now() - startedAt,
duration_s: (Date.now() - startedAt) / 1000,
});
} finally {
await cleanupPromptRun();
}
}
async function createPromptHarness(
options: Parameters<typeof createKimiHarness>[0],
): Promise<PromptHarness> {
// The v2 engine is dispatched earlier in `runPrompt` (see the
// `isKimiV2Enabled()` branch) and never reaches here; this is the v1 path.
return createKimiHarness(options);
}
async function runHeadlessGoal(
session: PromptSession,
session: Session,
goal: HeadlessGoalCreate,
model: string | undefined,
outputFormat: PromptOutputFormat,
@ -257,13 +190,7 @@ async function runHeadlessGoal(
try {
// The objective is sent as the normal prompt; goal continuation keeps the
// turn alive until a terminal state is reached.
await runPromptTurn(
session as PrintTurnSession,
goal.objective,
outputFormat,
stdout,
stderr,
);
await runPromptTurn(session, goal.objective, outputFormat, stdout, stderr);
} finally {
unsubscribeGoalEvents();
const snapshot = completedSnapshot ?? (await session.getGoal()).goal;
@ -281,7 +208,7 @@ async function runHeadlessGoal(
}
interface ResolvedPromptSession {
readonly session: PromptSession;
readonly session: Session;
readonly resumed: boolean;
readonly restorePermission: () => Promise<void>;
readonly telemetryModel?: string;
@ -289,7 +216,7 @@ interface ResolvedPromptSession {
}
async function resolvePromptSession(
harness: PromptHarness,
harness: KimiHarness,
opts: CLIOptions,
workDir: string,
defaultModel: string | undefined,
@ -313,10 +240,7 @@ async function resolvePromptSession(
`Session "${opts.session}" was created under a different directory.`,
);
}
const session = await harness.resumeSession({
id: opts.session,
additionalDirs: opts.addDirs?.length ? opts.addDirs : undefined,
});
const session = await harness.resumeSession({ id: opts.session });
const status = await session.getStatus();
const restorePermission = await forcePromptPermission(
session,
@ -340,10 +264,7 @@ async function resolvePromptSession(
const sessions = await harness.listSessions({ workDir });
const previous = sessions[0];
if (previous !== undefined) {
const session = await harness.resumeSession({
id: previous.id,
additionalDirs: opts.addDirs?.length ? opts.addDirs : undefined,
});
const session = await harness.resumeSession({ id: previous.id });
const status = await session.getStatus();
const restorePermission = await forcePromptPermission(
session,
@ -366,13 +287,7 @@ async function resolvePromptSession(
}
const model = requireConfiguredModel(opts.model, defaultModel);
const session = await harness.createSession({
workDir,
model,
permission: 'auto',
additionalDirs: opts.addDirs?.length ? opts.addDirs : undefined,
drainAgentTasksOnStop: true,
});
const session = await harness.createSession({ workDir, model, permission: 'auto' });
installHeadlessHandlers(session);
return {
session,
@ -384,7 +299,7 @@ async function resolvePromptSession(
}
async function forcePromptPermission(
session: PromptSession,
session: Session,
previousPermission: SessionStatus['permission'],
setRestorePermission: (restorePermission: () => Promise<void>) => void,
): Promise<() => Promise<void>> {
@ -403,7 +318,7 @@ async function forcePromptPermission(
return restorePermission;
}
export function requireConfiguredModel(...models: readonly (string | undefined)[]): string {
function requireConfiguredModel(...models: readonly (string | undefined)[]): string {
const model = configuredModel(...models);
if (model === undefined) {
throw new Error(
@ -413,16 +328,16 @@ export function requireConfiguredModel(...models: readonly (string | undefined)[
return model;
}
export function configuredModel(...models: readonly (string | undefined)[]): string | undefined {
function configuredModel(...models: readonly (string | undefined)[]): string | undefined {
return models.find((model) => model !== undefined && model.trim().length > 0);
}
function installHeadlessHandlers(session: PromptSession): void {
function installHeadlessHandlers(session: Session): void {
session.setApprovalHandler(() => ({ decision: 'approved' }));
session.setQuestionHandler(() => null);
}
export function installPromptTerminationCleanup(
function installPromptTerminationCleanup(
promptProcess: PromptProcess,
cleanup: () => Promise<void>,
): () => void {
@ -438,28 +353,20 @@ export function installPromptTerminationCleanup(
};
const onSigint = () => exitAfterCleanup('SIGINT');
const onSigterm = () => exitAfterCleanup('SIGTERM');
const onSighup = () => exitAfterCleanup('SIGHUP');
promptProcess.once('SIGINT', onSigint);
promptProcess.once('SIGTERM', onSigterm);
promptProcess.once('SIGHUP', onSighup);
return () => {
promptProcess.off('SIGINT', onSigint);
promptProcess.off('SIGTERM', onSigterm);
promptProcess.off('SIGHUP', onSighup);
};
}
export function signalExitCode(signal: NodeJS.Signals): number {
if (signal === 'SIGINT') return 130;
if (signal === 'SIGHUP') return 129;
return 143;
function signalExitCode(signal: NodeJS.Signals): number {
return signal === 'SIGINT' ? 130 : 143;
}
type PrintTurnSession = PromptSession &
Required<Pick<PromptSession, 'handlePrintMainTurnCompleted'>>;
function runPromptTurn(
session: PrintTurnSession,
session: Session,
prompt: string,
outputFormat: PromptOutputFormat,
stdout: PromptOutput,
@ -473,28 +380,11 @@ function runPromptTurn(
: new PromptTranscriptWriter(stdout, stderr);
let settled = false;
let unsubscribe: (() => void) | undefined;
// A `kimi -p` run is not done just because the model ended a turn: an active
// goal drives continuation turns on its own, and a scheduled cron task fires
// later from an idle session — both trigger new turns after `end_turn`. While
// either is pending, something must keep the event loop alive: the cron
// scheduler's tick is deliberately unref'd, so without a ref'd handle the
// process would drain and exit before the next turn is ever triggered. This
// no-op interval is that handle; finish() always clears it.
let keepAliveTimer: NodeJS.Timeout | undefined;
const holdEventLoop = (): void => {
keepAliveTimer ??= setInterval(() => {}, 60_000);
};
const releaseEventLoop = (): void => {
if (keepAliveTimer === undefined) return;
clearInterval(keepAliveTimer);
keepAliveTimer = undefined;
};
return new Promise<void>((resolve, reject) => {
const finish = (error?: Error): void => {
if (settled) return;
settled = true;
releaseEventLoop();
unsubscribe?.();
outputWriter.finish();
if (error !== undefined) {
@ -504,36 +394,6 @@ function runPromptTurn(
resolve();
};
// Re-evaluates whether the run can settle now that the main agent is idle.
// The run outlives a completed turn while a goal is still active (the goal
// driver launches the next continuation turn itself) or while cron tasks
// with a future fire remain (their fire steers a fresh turn when idle).
// Called on turn.ended and on a terminal goal.updated — the latter covers
// the driver blocking a goal on a hard budget, which emits no further
// turn.ended. Only when neither is pending do we drain background tasks
// and settle.
const evaluateRunCompletion = async (): Promise<void> => {
try {
const { goal } = await session.getGoal();
if (settled || activeTurnId !== undefined) return;
if (goal?.status === 'active') {
holdEventLoop();
return;
}
const { tasks } = await session.getCronTasks();
if (settled || activeTurnId !== undefined) return;
// A task whose expression has no future fire can never trigger a
// turn; don't hold the run open for it.
if (tasks.some((task) => task.nextFireAt !== null)) {
holdEventLoop();
return;
}
await finishCompletedTurn();
} catch (error) {
finish(error instanceof Error ? error : new Error(String(error)));
}
};
unsubscribe = session.onEvent((event) => {
if (event.type === 'error') {
if (event.agentId !== PROMPT_MAIN_AGENT_ID) {
@ -542,7 +402,7 @@ function runPromptTurn(
finish(new Error(`${event.code}: ${event.message}`));
return;
}
if (event.type === 'turn.started') {
if (event.type === 'turn.started' && activeTurnId === undefined) {
if (event.agentId !== PROMPT_MAIN_AGENT_ID) {
return;
}
@ -550,16 +410,6 @@ function runPromptTurn(
activeAgentId = event.agentId;
return;
}
if (
event.type === 'goal.updated' &&
event.agentId === PROMPT_MAIN_AGENT_ID &&
activeTurnId === undefined &&
event.snapshot !== null &&
event.snapshot.status !== 'active'
) {
void evaluateRunCompletion();
return;
}
if (
activeTurnId === undefined ||
activeAgentId === undefined ||
@ -576,7 +426,6 @@ function runPromptTurn(
return;
case 'turn.step.retrying':
outputWriter.discardAssistant();
outputWriter.writeRetrying(event);
return;
case 'assistant.delta':
outputWriter.writeAssistantDelta(event.delta);
@ -605,10 +454,7 @@ function runPromptTurn(
return;
case 'turn.ended':
if (event.reason === 'completed') {
outputWriter.flushAssistant();
activeTurnId = undefined;
activeAgentId = undefined;
void evaluateRunCompletion();
finish();
return;
}
finish(new Error(formatTurnEndedFailure(event)));
@ -631,6 +477,7 @@ function runPromptTurn(
case 'subagent.started':
case 'subagent.suspended':
case 'tool.list.updated':
case 'turn.started':
case 'turn.step.completed':
case 'warning':
return;
@ -640,41 +487,311 @@ function runPromptTurn(
session.prompt(prompt).catch((error: unknown) => {
finish(error instanceof Error ? error : new Error(String(error)));
});
async function finishCompletedTurn(): Promise<void> {
// Flush the buffered assistant message before the end-of-turn policy
// runs: in stream-json mode the final message is only emitted by
// finish(), so a long drain/steer wait would otherwise withhold the main
// turn's result until the run exits.
outputWriter.flushAssistant();
try {
const action = await session.handlePrintMainTurnCompleted();
if (action === 'continue') {
// Stay alive: a still-pending background task will, on completion,
// steer the main agent into a new turn whose events we keep mapping.
// Do not finish yet.
holdEventLoop();
return;
}
} catch (error) {
log.warn('handlePrintMainTurnCompleted failed', { error });
}
finish();
}
});
}
interface PromptTurnWriter {
writeAssistantDelta(delta: string): void;
writeHookResult(event: HookResultEvent): void;
writeThinkingDelta(delta: string): void;
writeToolCall(toolCallId: string, name: string, args: unknown): void;
writeToolCallDelta(
toolCallId: string,
name: string | undefined,
argumentsPart: string | undefined,
): void;
writeToolResult(toolCallId: string, output: unknown): void;
flushAssistant(): void;
discardAssistant(): void;
finish(): void;
}
class PromptTranscriptWriter implements PromptTurnWriter {
private readonly assistantWriter: PromptBlockWriter;
private readonly thinkingWriter: PromptBlockWriter;
constructor(stdout: PromptOutput, stderr: PromptOutput) {
this.assistantWriter = new PromptBlockWriter(stdout);
this.thinkingWriter = new PromptBlockWriter(stderr);
}
writeAssistantDelta(delta: string): void {
this.thinkingWriter.finish();
this.assistantWriter.write(delta);
}
writeHookResult(event: HookResultEvent): void {
this.thinkingWriter.finish();
this.assistantWriter.finish();
this.assistantWriter.write(formatHookResultPlain(event));
this.assistantWriter.finish();
}
writeThinkingDelta(delta: string): void {
this.thinkingWriter.write(delta);
}
writeToolCall(): void {}
writeToolCallDelta(): void {}
writeToolResult(): void {}
flushAssistant(): void {}
discardAssistant(): void {}
finish(): void {
this.thinkingWriter.finish();
this.assistantWriter.finish();
}
}
interface PromptJsonToolCall {
type: 'function';
id: string;
function: {
name: string;
arguments: string;
};
}
interface PromptJsonAssistantMessage {
role: 'assistant';
content?: string;
tool_calls?: PromptJsonToolCall[];
}
interface PromptJsonToolMessage {
role: 'tool';
tool_call_id: string;
content: string;
}
interface PromptJsonResumeMetaMessage {
role: 'meta';
type: 'session.resume_hint';
session_id: string;
command: string;
content: string;
}
function writeResumeHint(
sessionId: string,
outputFormat: PromptOutputFormat,
stdout: PromptOutput,
stderr: PromptOutput,
): void {
const command = `kimi -r ${sessionId}`;
const content = `To resume this session: ${command}`;
if (outputFormat === 'stream-json') {
const message: PromptJsonResumeMetaMessage = {
role: 'meta',
type: 'session.resume_hint',
session_id: sessionId,
command,
content,
};
stdout.write(`${JSON.stringify(message)}\n`);
return;
}
stderr.write(`${content}\n`);
}
class PromptJsonWriter implements PromptTurnWriter {
private assistantText = '';
private readonly toolCalls: PromptJsonToolCall[] = [];
constructor(private readonly stdout: PromptOutput) {}
writeAssistantDelta(delta: string): void {
this.assistantText += delta;
}
writeHookResult(event: HookResultEvent): void {
this.flushAssistant();
this.writeJsonLine({
role: 'assistant',
content: formatHookResultPlain(event),
});
}
writeThinkingDelta(): void {}
writeToolCall(toolCallId: string, name: string, args: unknown): void {
const existing = this.toolCalls.find((toolCall) => toolCall.id === toolCallId);
if (existing !== undefined) {
existing.function.name = name;
existing.function.arguments = stringifyJsonValue(args);
return;
}
this.toolCalls.push({
type: 'function',
id: toolCallId,
function: {
name,
arguments: stringifyJsonValue(args),
},
});
}
writeToolCallDelta(
toolCallId: string,
name: string | undefined,
argumentsPart: string | undefined,
): void {
const toolCall = this.findOrCreateToolCall(toolCallId, name ?? '');
if (name !== undefined) {
toolCall.function.name = name;
}
if (argumentsPart !== undefined) {
toolCall.function.arguments += argumentsPart;
}
}
writeToolResult(toolCallId: string, output: unknown): void {
this.flushAssistant();
this.writeJsonLine({
role: 'tool',
tool_call_id: toolCallId,
content: stringifyToolOutput(output),
});
}
flushAssistant(): void {
if (this.assistantText.length === 0 && this.toolCalls.length === 0) return;
const message: PromptJsonAssistantMessage = {
role: 'assistant',
content: this.assistantText.length > 0 ? this.assistantText : undefined,
tool_calls: this.toolCalls.length > 0 ? [...this.toolCalls] : undefined,
};
this.writeJsonLine(message);
this.discardAssistant();
}
discardAssistant(): void {
this.assistantText = '';
this.toolCalls.length = 0;
}
finish(): void {
this.flushAssistant();
}
private findOrCreateToolCall(toolCallId: string, name: string): PromptJsonToolCall {
const existing = this.toolCalls.find((toolCall) => toolCall.id === toolCallId);
if (existing !== undefined) return existing;
const toolCall: PromptJsonToolCall = {
type: 'function',
id: toolCallId,
function: {
name,
arguments: '',
},
};
this.toolCalls.push(toolCall);
return toolCall;
}
private writeJsonLine(message: PromptJsonAssistantMessage | PromptJsonToolMessage): void {
this.stdout.write(`${JSON.stringify(message)}\n`);
}
}
class PromptBlockWriter {
private started = false;
private atLineStart = false;
private lineWidth = 0;
private readonly wrapWidth: number | undefined;
constructor(private readonly output: PromptOutput) {
this.wrapWidth =
typeof output.columns === 'number' && output.columns > PROMPT_BLOCK_INDENT.length + 1
? output.columns
: undefined;
}
write(chunk: string): void {
if (chunk.length === 0) return;
let rendered = this.start();
for (const char of chunk) {
if (this.atLineStart && char !== '\n') {
rendered += PROMPT_BLOCK_INDENT;
this.atLineStart = false;
this.lineWidth = PROMPT_BLOCK_INDENT.length;
}
const charWidth = visibleCharWidth(char);
if (
this.wrapWidth !== undefined &&
!this.atLineStart &&
char !== '\n' &&
this.lineWidth + charWidth > this.wrapWidth
) {
rendered += `\n${PROMPT_BLOCK_INDENT}`;
this.lineWidth = PROMPT_BLOCK_INDENT.length;
}
rendered += char;
if (char === '\n') {
this.atLineStart = true;
this.lineWidth = 0;
} else {
this.lineWidth += charWidth;
}
}
this.output.write(rendered);
}
finish(): void {
if (!this.started) return;
this.output.write(this.atLineStart ? '\n' : '\n\n');
this.started = false;
this.atLineStart = false;
this.lineWidth = 0;
}
private start(): string {
if (this.started) return '';
this.started = true;
this.atLineStart = false;
this.lineWidth = PROMPT_BLOCK_BULLET.length;
return PROMPT_BLOCK_BULLET;
}
}
function visibleCharWidth(char: string): number {
return char === '\t' ? 4 : 1;
}
function formatHookResultPlain(event: HookResultEvent): string {
return `${formatHookResultTitle(event)}\n\n${formatHookResultBody(event)}`;
}
function formatHookResultTitle(event: HookResultEvent): string {
return `${event.hookEvent} hook${event.blocked === true ? ' blocked' : ''}`;
}
function formatHookResultBody(event: HookResultEvent): string {
const content = event.content.trim();
return content.length === 0 ? '(empty)' : content;
}
function stringifyJsonValue(value: unknown): string {
if (typeof value === 'string') return value;
const json = JSON.stringify(value);
return json ?? '';
}
function stringifyToolOutput(output: unknown): string {
if (typeof output === 'string') return output;
const json = JSON.stringify(output);
return json ?? String(output);
}
function hasTurnId(event: Event): event is Event & { readonly turnId: number } {
return 'turnId' in event;
}
function formatTurnEndedFailure(event: Extract<Event, { type: 'turn.ended' }>): string {
if (event.error?.code === 'provider.filtered') {
return 'Provider safety policy blocked the response.';
}
if (event.error !== undefined) return `${event.error.code}: ${event.error.message}`;
if (event.reason === 'blocked') {
return 'Prompt hook blocked the request.';
}
return `Prompt turn ended with reason: ${event.reason}`;
}

View file

@ -1,13 +1,7 @@
import { execSync, spawnSync } from 'node:child_process';
import { execSync } from 'node:child_process';
import { homedir } from 'node:os';
import { join } from 'node:path';
import {
createKimiHarness,
log,
type KimiHarness,
type TelemetryClient,
} from '@moonshot-ai/kimi-code-sdk';
import {
setCrashPhase,
setTelemetryContext,
@ -15,6 +9,12 @@ import {
track,
withTelemetryContext,
} from '@moonshot-ai/kimi-telemetry';
import {
createKimiHarness,
log,
type KimiHarness,
type TelemetryClient,
} from '@moonshot-ai/kimi-code-sdk';
import { CLI_SHUTDOWN_TIMEOUT_MS, CLI_UI_MODE } from '#/constant/app';
import { detectPendingMigration } from '#/migration/index';
@ -23,9 +23,6 @@ import { loadTuiConfig, TuiConfigParseError } from '#/tui/config';
import { CHROME_GUTTER } from '#/tui/constant/rendering';
import { KimiTUI } from '#/tui/index';
import { currentTheme, getColorPalette } from '#/tui/theme';
import { combineStartupNotice } from '#/tui/utils/startup';
import { toTerminalHyperlink } from '#/utils/terminal-hyperlink';
import { restoreTerminalModes } from '#/utils/terminal-restore';
import type { CLIOptions } from './options';
import { createCliTelemetryBootstrap, initializeCliTelemetry } from './telemetry';
@ -62,19 +59,17 @@ export async function runShell(
const harness = createKimiHarness({
homeDir: telemetryBootstrap.homeDir,
identity: createKimiCodeHostIdentity(version),
skillDirs: opts.skillsDirs,
telemetry: telemetryClient,
onOAuthRefresh: (outcome) => {
if (outcome.success) {
track('oauth_refresh', { outcome: 'success' });
track('oauth_refresh', { success: true });
return;
}
track('oauth_refresh', {
outcome: 'error',
success: false,
reason: outcome.reason,
});
},
sessionStartedProperties: { yolo: opts.yolo, auto: opts.auto, plan: opts.plan, afk: false },
});
log.info('kimi-code starting', {
version,
@ -96,13 +91,9 @@ export async function runShell(
return;
}
const config = await harness.getConfig();
for (const warning of (await harness.getConfigDiagnostics()).warnings) {
configWarning = combineStartupNotice(configWarning, warning);
}
const configMs = Date.now() - configStartedAt;
const tui = new KimiTUI(harness, {
cliOptions: opts,
additionalDirs: opts.addDirs?.length ? opts.addDirs : undefined,
tuiConfig,
version,
workDir,
@ -120,6 +111,7 @@ export async function runShell(
});
setCrashPhase('runtime');
const resumed = opts.continue || opts.session !== undefined;
const trackLifecycleForSession = (
sessionId: string,
event: string,
@ -135,85 +127,35 @@ export async function runShell(
trackLifecycleForSession(tui.getCurrentSessionId(), event, properties);
};
let savedStty: string | undefined;
try {
// stty operates on the terminal behind stdin, so stdin must be the TTY —
// piping /dev/null (ignore) makes stty fail with "not a tty".
const saved = execSync('stty -g', {
encoding: 'utf8',
stdio: ['inherit', 'pipe', 'ignore'],
});
savedStty = typeof saved === 'string' ? saved.trim() : undefined;
execSync('stty -ixon', { stdio: ['inherit', 'ignore', 'ignore'] });
} catch {
/* ignore */
}
const restoreStty = (): void => {
if (savedStty === undefined) return;
const args = savedStty.split(/\s+/).filter((arg) => arg.length > 0);
if (args.length === 0) return;
spawnSync('stty', args, { stdio: ['inherit', 'ignore', 'ignore'] });
};
// If we crash without going through KimiTUI.stop(), the terminal is left in
// raw mode with a hidden cursor and XON/XOFF flow control disabled. Restore
// both before exiting so the user's shell is usable afterwards.
const emergencyExit = (exitCode: number): void => {
restoreTerminalModes();
restoreStty();
process.exit(exitCode);
};
const onUncaughtException = (error: unknown): void => {
try {
log.error('uncaughtException, restoring terminal and exiting', { error: String(error) });
} catch {
/* ignore */
}
emergencyExit(1);
};
const onUnhandledRejection = (reason: unknown): void => {
try {
log.error('unhandledRejection, restoring terminal and exiting', { reason: String(reason) });
} catch {
/* ignore */
}
emergencyExit(1);
};
process.on('uncaughtException', onUncaughtException);
process.on('unhandledRejection', onUnhandledRejection);
// Remove the crash handlers once the TUI exits cleanly so repeated runShell()
// calls in the same process (e.g. tests) don't accumulate process listeners.
const removeCrashHandlers = (): void => {
process.off('uncaughtException', onUncaughtException);
process.off('unhandledRejection', onUnhandledRejection);
};
tui.onExit = async (exitCode = 0) => {
const sessionId = tui.getCurrentSessionId();
const hasContent = tui.hasSessionContent();
setCrashPhase('shutdown');
trackLifecycle('exit', { duration_ms: Date.now() - startedAt });
trackLifecycle('exit', { duration_s: (Date.now() - startedAt) / 1000 });
await shutdownTelemetry({ timeoutMs: CLI_SHUTDOWN_TIMEOUT_MS });
const gutter = ' '.repeat(CHROME_GUTTER);
process.stdout.write(`${gutter}Bye!\n`);
const hints: string[] = [];
if (sessionId !== '' && hasContent) {
hints.push(`${gutter}To resume this session: kimi -r ${sessionId}`);
process.stderr.write(`\n${gutter}To resume this session: kimi -r ${sessionId}\n`);
}
if (tui.exitOpenUrl !== undefined) {
hints.push(`${gutter}open ${toTerminalHyperlink(tui.exitOpenUrl, tui.exitOpenUrl)}`);
}
if (hints.length > 0) {
process.stderr.write(`\n${hints.join('\n')}\n`);
}
removeCrashHandlers();
restoreStty();
process.exit(exitCode);
};
try {
execSync('stty -ixon', { stdio: 'ignore' });
} catch {
/* ignore */
}
try {
const initStartedAt = Date.now();
await tui.start();
const initMs = Date.now() - initStartedAt;
trackLifecycle('started', {
resumed,
yolo: opts.yolo,
auto: opts.auto,
plan: opts.plan,
afk: false,
});
const startupSessionId = tui.getCurrentSessionId();
const mcpMs = await tui.getStartupMcpMs();
trackLifecycleForSession(startupSessionId, 'startup_perf', {
@ -223,9 +165,8 @@ export async function runShell(
mcp_ms: mcpMs,
});
} catch (error) {
removeCrashHandlers();
setCrashPhase('shutdown');
trackLifecycle('exit', { duration_ms: Date.now() - startedAt });
trackLifecycle('exit', { duration_s: (Date.now() - startedAt) / 1000 });
await shutdownTelemetry({ timeoutMs: CLI_SHUTDOWN_TIMEOUT_MS });
await harness.close();
throw error;

View file

@ -7,9 +7,8 @@
*
* `add` writes the same `source = { kind: 'apiJson', url, apiKey }` blob the
* TUI does; the next launch's `refreshAllProviderModels`
* (apps/kimi-code/src/tui/utils/refresh-providers.ts) groups by URL, retries
* available API-key candidates, and re-fetches the model list, so periodic
* refresh is automatic.
* (apps/kimi-code/src/tui/utils/refresh-providers.ts) groups by `{url, apiKey}`
* and re-fetches the model list, so periodic refresh is automatic.
*/
import {
@ -35,7 +34,7 @@ import {
} from '@moonshot-ai/kimi-code-sdk';
import type { Command } from 'commander';
import { createKimiCodeHostIdentity, createKimiCodeUserAgent } from '#/cli/version';
import { createKimiCodeHostIdentity } from '#/cli/version';
interface WritableLike {
write(chunk: string): boolean;
@ -99,7 +98,7 @@ export async function handleProviderAdd(
let entries: Awaited<ReturnType<typeof fetchCustomRegistry>>;
try {
entries = await fetchCustomRegistry(source, { userAgent: createKimiCodeUserAgent() });
entries = await fetchCustomRegistry(source);
} catch (error) {
const suffix = error instanceof CustomRegistryApiError ? ` (HTTP ${String(error.status)})` : '';
deps.stderr.write(`Failed to fetch registry${suffix}: ${errorMessage(error)}\n`);
@ -340,7 +339,7 @@ export async function handleCatalogAdd(
// already-configured provider would lose the user's previously-set default
// even when `--default-model` is not supplied.
const previousDefaultModel = config.defaultModel;
const previousThinking = config.thinking;
const previousDefaultThinking = config.defaultThinking;
if (config.providers[providerId] !== undefined) {
config = await harness.removeProvider(providerId);
@ -348,7 +347,7 @@ export async function handleCatalogAdd(
const baseUrl = catalogBaseUrl(entry, wire);
// `applyCatalogProvider` always overwrites both `defaultModel` and
// `[thinking]`. The values we pass here are temporary; we restore
// `defaultThinking`. The values we pass here are temporary; we restore
// a consistent state in the post-apply block below.
applyCatalogProvider(config, {
providerId,
@ -373,18 +372,18 @@ export async function handleCatalogAdd(
config.defaultModel = stillResolves ? previousDefaultModel : undefined;
}
// Always restore `[thinking]` from what was there before — including
// `undefined`. Persisting `enabled: false` when the user never set it would
// make `resolveThinkingEffort` (agent-core/src/agent/config/thinking.ts) treat
// it as an explicit "off" request and silently disable thinking, even for
// thinking-capable models.
config.thinking = previousThinking;
// Always restore `defaultThinking` from what was there before — including
// `undefined`. Persisting `false` when the user never set it would make
// `resolveThinkingLevel` (agent-core/src/agent/config/thinking.ts) treat
// it as an explicit "off" request and silently disable thinking, even
// for thinking-capable models.
config.defaultThinking = previousDefaultThinking;
await harness.setConfig({
providers: config.providers,
models: config.models,
defaultModel: config.defaultModel,
thinking: config.thinking,
defaultThinking: config.defaultThinking,
});
const displayName = entry.name ?? providerId;
@ -398,7 +397,7 @@ export async function handleCatalogAdd(
async function loadCatalogOrExit(deps: ProviderDeps, url: string): Promise<Catalog> {
try {
return await fetchCatalog(url, { userAgent: createKimiCodeUserAgent() });
return await fetchCatalog(url);
} catch (error) {
const suffix = error instanceof CatalogFetchError ? ` (HTTP ${String(error.status)})` : '';
deps.stderr.write(`Failed to fetch catalog from ${url}${suffix}: ${errorMessage(error)}\n`);
@ -411,26 +410,13 @@ export function registerProviderCommand(parent: Command, deps?: Partial<Provider
.command('provider')
.description('Manage LLM providers non-interactively.');
// Last-resort boundary: handlers report expected failures themselves, but
// anything that escapes (e.g. a config write rejected because config.toml
// is invalid) must end as a one-line error + exit 1, not an unhandled
// rejection dumping a stack trace.
const runAction = async (resolved: ProviderDeps, run: () => Promise<void>): Promise<void> => {
try {
await run();
} catch (error) {
resolved.stderr.write(`${errorMessage(error)}\n`);
resolved.exit(1);
}
};
provider
.command('add <url>')
.description('Import every provider listed in a custom registry (api.json).')
.option('--api-key <key>', 'Registry API key. Falls back to KIMI_REGISTRY_API_KEY.')
.action(async (url: string, options: { apiKey?: string }) => {
const resolved = resolveDeps(deps);
await runAction(resolved, () => handleProviderAdd(resolved, url, { apiKey: options.apiKey }));
await handleProviderAdd(resolved, url, { apiKey: options.apiKey });
});
provider
@ -438,7 +424,7 @@ export function registerProviderCommand(parent: Command, deps?: Partial<Provider
.description('Remove a provider and every model alias that referenced it.')
.action(async (providerId: string) => {
const resolved = resolveDeps(deps);
await runAction(resolved, () => handleProviderRemove(resolved, providerId));
await handleProviderRemove(resolved, providerId);
});
provider
@ -447,7 +433,7 @@ export function registerProviderCommand(parent: Command, deps?: Partial<Provider
.option('--json', 'Emit the raw providers/models config as JSON.', false)
.action(async (options: { json?: boolean }) => {
const resolved = resolveDeps(deps);
await runAction(resolved, () => handleProviderList(resolved, { json: options.json === true }));
await handleProviderList(resolved, { json: options.json === true });
});
const catalog = provider
@ -466,13 +452,11 @@ export function registerProviderCommand(parent: Command, deps?: Partial<Provider
options: { filter?: string; url?: string; json?: boolean },
) => {
const resolved = resolveDeps(deps);
await runAction(resolved, () =>
handleCatalogList(resolved, providerId, {
json: options.json === true,
...(options.filter === undefined ? {} : { filter: options.filter }),
...(options.url === undefined ? {} : { url: options.url }),
}),
);
await handleCatalogList(resolved, providerId, {
json: options.json === true,
...(options.filter === undefined ? {} : { filter: options.filter }),
...(options.url === undefined ? {} : { url: options.url }),
});
},
);
@ -488,13 +472,11 @@ export function registerProviderCommand(parent: Command, deps?: Partial<Provider
options: { apiKey?: string; defaultModel?: string; url?: string },
) => {
const resolved = resolveDeps(deps);
await runAction(resolved, () =>
handleCatalogAdd(resolved, providerId, {
...(options.apiKey === undefined ? {} : { apiKey: options.apiKey }),
...(options.defaultModel === undefined ? {} : { defaultModel: options.defaultModel }),
...(options.url === undefined ? {} : { url: options.url }),
}),
);
await handleCatalogAdd(resolved, providerId, {
...(options.apiKey === undefined ? {} : { apiKey: options.apiKey }),
...(options.defaultModel === undefined ? {} : { defaultModel: options.defaultModel }),
...(options.url === undefined ? {} : { url: options.url }),
});
},
);
}

View file

@ -1,85 +0,0 @@
/**
* Build the clickable/copyable access URLs for the running server.
*
* Shared by the `server run` ready banner and `server rotate-token` so both
* show the same Local/Network links. When a token is known it rides in the
* `#token=` fragment (never sent to the server, so never logged), letting a
* user open the link on another device and be authenticated automatically.
*/
import { formatHostForUrl, listNetworkAddresses, type NetworkAddress } from './networks';
/**
* Build a directly-openable server URL. When the token is known it is appended
* as `#token=<token>`; otherwise the bare origin (with a trailing slash) is
* returned.
*/
export function buildOpenableUrl(bareOrigin: string, token: string | undefined): string {
const base = bareOrigin.endsWith('/') ? bareOrigin.slice(0, -1) : bareOrigin;
return token === undefined ? `${base}/` : `${base}/#token=${token}`;
}
/**
* Split a full URL into the part before `#token=` and the `#token=…` fragment
* itself, so callers can render the fragment in a de-emphasized color. Returns
* `[fullUrl, '']` when there is no token fragment.
*/
export function splitTokenFragment(fullUrl: string): [string, string] {
const marker = '#token=';
const idx = fullUrl.indexOf(marker);
return idx < 0 ? [fullUrl, ''] : [fullUrl.slice(0, idx), fullUrl.slice(idx)];
}
export interface AccessUrlLine {
/** Fixed-width label including trailing padding, e.g. `"Local: "`. */
label: string;
/** Full URL, carrying `#token=` when a token is known. */
url: string;
}
function isWildcard(host: string): boolean {
return host === '' || host === '0.0.0.0' || host === '::';
}
/** True when `host` is a loopback address (this host only). */
export function isLoopbackHost(host: string): boolean {
return host === 'localhost' || host === '127.0.0.1' || host === '::1';
}
function hostOrigin(host: string, port: number): string {
const family = host.includes(':') ? 'IPv6' : 'IPv4';
return `http://${formatHostForUrl(host, family)}:${port}`;
}
/**
* Compute the access-URL lines for a bind host/port.
*
* - wildcard (`0.0.0.0` / `::` / empty): a `Local:` line (localhost) plus one
* `Network:` line per non-loopback interface.
* - loopback: a single `Local:` line.
* - specific host: a single `URL:` line.
*/
export function accessUrlLines(
host: string,
port: number,
token: string | undefined,
networkAddresses?: NetworkAddress[],
): AccessUrlLine[] {
if (isWildcard(host)) {
const lines: AccessUrlLine[] = [
{ label: 'Local: ', url: buildOpenableUrl(`http://localhost:${port}`, token) },
];
const addrs = networkAddresses ?? listNetworkAddresses();
for (const addr of addrs) {
lines.push({
label: 'Network: ',
url: buildOpenableUrl(`http://${formatHostForUrl(addr.address, addr.family)}:${port}`, token),
});
}
return lines;
}
if (isLoopbackHost(host)) {
return [{ label: 'Local: ', url: buildOpenableUrl(hostOrigin(host, port), token) }];
}
return [{ label: 'URL: ', url: buildOpenableUrl(hostOrigin(host, port), token) }];
}

View file

@ -1,412 +0,0 @@
/**
* `kimi web` daemon orchestration parent (spawner) side.
*
* Ensures a single background server daemon exists for this device, then
* returns its origin so the caller can open the web UI. The flow:
*
* 1. Read `~/.kimi-code/server/lock`. If it names a *live* daemon, reuse it
* (wait for it to be healthy) never spawn a second one.
* 2. Otherwise pick a free port (preferred port when available, else an
* OS-assigned one) and spawn `kimi server run --daemon` as a detached
* child whose stdio is redirected to the server log.
* 3. Poll the lock until *some* live daemon (ours, or a concurrent racer's
* that won the lock) is healthy, then return its origin.
*
* The child side (`startServerDaemon`) lives in `./run.ts` next to the
* foreground runner so it can share the same bootstrap helpers.
*/
import { spawn, type ChildProcess } from 'node:child_process';
import { appendFileSync, closeSync, mkdirSync, openSync, readFileSync } from 'node:fs';
import { createRequire } from 'node:module';
import { createServer } from 'node:net';
import { dirname, isAbsolute, join, resolve } from 'node:path';
import { DEFAULT_LOCK_DIR, getLiveLock, type LockContents } from '@moonshot-ai/kap-server';
import {
DEFAULT_SERVER_HOST,
DEFAULT_SERVER_PORT,
LOCAL_SERVER_HOST,
isServerHealthy,
serverOrigin,
waitForServerHealthy,
} from './shared';
const SERVER_LOG_FILENAME = 'server.log';
/** How long to wait for an already-running daemon to answer `/healthz`. */
const REUSE_HEALTH_TIMEOUT_MS = 15_000;
/** How long to wait for a freshly-spawned daemon to come up. */
const SPAWN_TIMEOUT_MS = 20_000;
/** Poll cadence while waiting for the daemon to appear in the lock + healthz. */
const POLL_INTERVAL_MS = 200;
/** Default log level for a daemon spawned without an explicit `--log-level`. */
const DEFAULT_DAEMON_LOG_LEVEL = 'info';
export interface EnsureDaemonOptions {
/** Bind host for the spawned daemon (default `127.0.0.1`). */
host?: string;
/** Preferred port; on conflict a free port is chosen automatically. */
port?: number;
/** Pino log level for the spawned daemon (defaults to `info`). */
logLevel?: string;
/** Mount `/api/v1/debug/*` routes on the spawned daemon. */
debugEndpoints?: boolean;
/** Allow a non-loopback bind without a TLS-terminating reverse proxy. */
insecureNoTls?: boolean;
/** Keep `POST /api/v1/shutdown` enabled on a non-loopback bind. */
allowRemoteShutdown?: boolean;
/** Keep the PTY `/api/v1/terminals/*` routes enabled on a non-loopback bind. */
allowRemoteTerminals?: boolean;
/** Disable bearer-token auth on every route (`--dangerous-bypass-auth`). */
dangerousBypassAuth?: boolean;
/** Keep the daemon alive instead of idle-killing it (`--keep-alive`). */
keepAlive?: boolean;
/** Extra `Host` header values to allow through the DNS-rebinding check. */
allowedHosts?: readonly string[];
/** Idle-shutdown grace in ms for the spawned daemon (daemon mode only). */
idleGraceMs?: number;
}
export interface EnsureDaemonResult {
readonly origin: string;
/** True when an already-running daemon was reused (no new server started). */
readonly reused: boolean;
/** Bind host the running daemon is actually listening on (from the lock). */
readonly host: string;
/** Port the running daemon is actually listening on (from the lock). */
readonly port: number;
}
/** Path of the daemon log file (shared with the OS-service log location). */
export function daemonLogPath(): string {
return join(DEFAULT_LOCK_DIR, SERVER_LOG_FILENAME);
}
export function lockConnectHost(lock: LockContents): string {
const host = lock.host ?? LOCAL_SERVER_HOST;
return host === '0.0.0.0' ? LOCAL_SERVER_HOST : host;
}
/** True when `host:port` is currently free to bind (nothing listening). */
function canBind(host: string, port: number): Promise<boolean> {
return new Promise((resolvePromise) => {
const probe = createServer();
probe.once('error', () => resolvePromise(false));
probe.listen({ host, port }, () => {
probe.close(() => resolvePromise(true));
});
});
}
/** Ask the OS for an ephemeral free port on `host`. */
function getFreePort(host: string): Promise<number> {
return new Promise((resolvePromise, reject) => {
const probe = createServer();
probe.once('error', reject);
probe.listen({ host, port: 0 }, () => {
const address = probe.address();
if (address === null || typeof address === 'string') {
probe.close(() => reject(new Error('failed to allocate a free port')));
return;
}
const { port } = address;
probe.close(() => resolvePromise(port));
});
});
}
/**
* How many consecutive `preferred + n` ports to probe before giving up and
* asking the OS for any free port. Mirrors `PORT_RETRY_LIMIT` in the server's
* own bind retry so the spawner and the daemon agree on the policy.
*/
export const DAEMON_PORT_SCAN_LIMIT = 100;
/**
* Pick a port for a new daemon: prefer `preferred` when it is free, otherwise
* walk `preferred + 1`, `+ 2`, upward and take the first free one. Only when
* the whole scan window is saturated do we fall back to an OS-assigned free
* port.
*
* Reusing an already-live daemon is handled by `ensureDaemon` before this runs,
* so a busy port here is held by a third-party process bumping by one (rather
* than jumping to a random ephemeral port) keeps the URL predictable, matching
* the server's own "port busy ⇒ +1" bind retry.
*/
export async function resolveDaemonPort(
host: string = DEFAULT_SERVER_HOST,
preferred: number = DEFAULT_SERVER_PORT,
): Promise<number> {
for (
let candidate = preferred;
candidate < preferred + DAEMON_PORT_SCAN_LIMIT && candidate <= 65535;
candidate++
) {
if (await canBind(host, candidate)) return candidate;
}
return getFreePort(host);
}
interface NodeSeaModule {
isSea(): boolean;
}
const nodeRequire = createRequire(import.meta.url);
let cachedSea: NodeSeaModule | null | undefined;
function loadSeaModule(): NodeSeaModule | null {
if (cachedSea !== undefined) return cachedSea;
try {
cachedSea = nodeRequire('node:sea') as NodeSeaModule;
} catch {
cachedSea = null;
}
return cachedSea;
}
/** True when running as a compiled single-executable (SEA / native) binary. */
function detectSea(): boolean {
const sea = loadSeaModule();
if (sea === null) return false;
try {
return sea.isSea();
} catch {
return false;
}
}
/**
* Absolute path to the CLI entry that should be re-execed to run the daemon.
* Mirrors `resolveSupervisorProgram` in `packages/kap-server/src/svc/program.ts`:
* when the CLI is a compiled single binary, `argv[1]` is the invoked command
* name (e.g. `kimi`) or the first user argument never a script path so we
* must re-exec `process.execPath` itself.
*/
export function resolveDaemonProgram(
argv: readonly string[] = process.argv,
cwd: string = process.cwd(),
execPath: string = process.execPath,
isSea: boolean = detectSea(),
): string {
// In a SEA binary `argv[1]` is not a script path, so resolving it against
// `cwd` would produce a bogus path (e.g. `<cwd>/kimi`) and crash the spawn
// with ENOENT. Always re-exec the binary itself.
if (isSea) return execPath;
const candidate = argv[1] === 'server' ? execPath : (argv[1] ?? execPath);
return isAbsolute(candidate) ? candidate : resolve(cwd, candidate);
}
interface SpawnDaemonChildOptions {
host?: string;
port: number;
logLevel: string;
debugEndpoints?: boolean;
insecureNoTls?: boolean;
allowRemoteShutdown?: boolean;
allowRemoteTerminals?: boolean;
dangerousBypassAuth?: boolean;
keepAlive?: boolean;
allowedHosts?: readonly string[];
idleGraceMs?: number;
}
export function spawnDaemonChild(options: SpawnDaemonChildOptions): ChildProcess {
const program = resolveDaemonProgram();
const logPath = daemonLogPath();
const logDir = dirname(logPath);
mkdirSync(logDir, { recursive: true });
const args = [
'server',
'run',
'--daemon',
'--port',
String(options.port),
'--log-level',
options.logLevel,
];
if (options.host !== undefined) {
args.push('--host', options.host);
}
if (options.debugEndpoints === true) {
args.push('--debug-endpoints');
}
if (options.insecureNoTls === true) {
args.push('--insecure-no-tls');
}
if (options.allowRemoteShutdown === true) {
args.push('--allow-remote-shutdown');
}
if (options.allowRemoteTerminals === true) {
args.push('--allow-remote-terminals');
}
if (options.dangerousBypassAuth === true) {
args.push('--dangerous-bypass-auth');
}
if (options.keepAlive === true) {
args.push('--keep-alive');
}
if (options.idleGraceMs !== undefined) {
args.push('--idle-grace-ms', String(options.idleGraceMs));
}
if (options.allowedHosts !== undefined && options.allowedHosts.length > 0) {
args.push('--allowed-host', ...options.allowedHosts);
}
// On Windows `.mjs` files are not executable PE binaries, so we must run
// the script through the Node binary rather than spawning it directly. In
// SEA mode or when re-spawning from an already-running daemon, `program` is
// `process.execPath` itself, so no script argument is needed.
const execPath = process.execPath;
const spawnArgs = program === execPath ? args : [program, ...args];
const logFd = openSync(logPath, 'a');
try {
const child = spawn(execPath, spawnArgs, {
detached: true,
// Run from the server log directory instead of inheriting the caller's
// cwd, so the long-lived daemon does not pin the directory it was
// launched from (notably blocking its deletion on Windows).
cwd: logDir,
stdio: ['ignore', logFd, logFd],
});
child.once('error', (error) => {
// A spawn failure (e.g. ENOENT) surfaces asynchronously on the child,
// not as a thrown error. Without a listener Node would crash the parent
// with an unhandled 'error' event; record it instead and let the polling
// loop in `ensureDaemon` report the timeout.
try {
appendFileSync(logPath, `[spawner] failed to launch daemon: ${error.message}\n`);
} catch {
// Best-effort; the log directory may already be gone.
}
});
child.unref();
return child;
} finally {
// `spawn` dups the fd into the child; the parent must not keep it open.
closeSync(logFd);
}
}
function sleep(ms: number): Promise<void> {
return new Promise((resolvePromise) => {
setTimeout(resolvePromise, ms);
});
}
/**
* Ensure a daemon is running and return its origin. Non-blocking for the
* caller beyond the short health wait the server itself keeps running in a
* detached process after this returns.
*/
export async function ensureDaemon(options: EnsureDaemonOptions = {}): Promise<EnsureDaemonResult> {
const host = options.host ?? DEFAULT_SERVER_HOST;
const preferred = options.port ?? DEFAULT_SERVER_PORT;
const logLevel = options.logLevel ?? DEFAULT_DAEMON_LOG_LEVEL;
// 1. Reuse an already-live daemon if one holds the lock.
const existing = getLiveLock();
if (existing) {
const origin = serverOrigin(lockConnectHost(existing), existing.port);
if (await waitForServerHealthy(origin, REUSE_HEALTH_TIMEOUT_MS)) {
return {
origin,
reused: true,
host: existing.host ?? DEFAULT_SERVER_HOST,
port: existing.port,
};
}
// Live pid but not responding (wedged or mid-boot failure). Fall through
// and spawn: if it is truly wedged our child loses the lock race and we
// reconnect below; if it died, stale takeover lets our child claim it.
}
// 2. No reusable daemon — pick a free port and spawn one detached.
const port = await resolveDaemonPort(host, preferred);
const child = spawnDaemonChild({
host,
port,
logLevel,
debugEndpoints: options.debugEndpoints,
insecureNoTls: options.insecureNoTls,
allowRemoteShutdown: options.allowRemoteShutdown,
allowRemoteTerminals: options.allowRemoteTerminals,
dangerousBypassAuth: options.dangerousBypassAuth,
keepAlive: options.keepAlive,
allowedHosts: options.allowedHosts,
idleGraceMs: options.idleGraceMs,
});
// Watch for an early exit so a boot failure (e.g. the non-loopback TLS gate,
// a config error, or a lost lock race with no other daemon to fall back to)
// surfaces the real error immediately instead of waiting out the full spawn
// timeout. The exit code/signal plus a tail of the daemon log is what tells
// the operator *why* it failed.
let childExit: { code: number | null; signal: NodeJS.Signals | null } | undefined;
child.once('exit', (code, signal) => {
childExit = { code, signal };
});
child.once('error', () => {
// Spawn failure (ENOENT etc.) is already recorded in the log by
// spawnDaemonChild; treat it as an early exit here.
childExit = { code: -1, signal: null };
});
// 3. Wait until some live daemon (ours, or a racer that won the lock) is up.
const deadline = Date.now() + SPAWN_TIMEOUT_MS;
while (Date.now() < deadline) {
const live = getLiveLock();
if (live) {
const origin = serverOrigin(lockConnectHost(live), live.port);
if (await isServerHealthy(origin, 500)) {
return {
origin,
reused: false,
host: live.host ?? DEFAULT_SERVER_HOST,
port: live.port,
};
}
}
if (childExit !== undefined && !live) {
// Our child exited and no other live daemon holds the lock to fall back
// to — this is a real boot failure, not a lost race.
throw new Error(formatDaemonBootFailure(childExit, daemonLogPath()));
}
await sleep(POLL_INTERVAL_MS);
}
throw new Error(
`Kimi server daemon failed to start within ${String(SPAWN_TIMEOUT_MS)}ms.\n\n` +
formatLogTail(daemonLogPath()),
);
}
function formatDaemonBootFailure(
exit: { code: number | null; signal: NodeJS.Signals | null },
logPath: string,
): string {
const reason =
exit.signal === null
? `exited with code ${String(exit.code)}`
: `was terminated by signal ${exit.signal}`;
return `Kimi server daemon ${reason} during startup.\n\n${formatLogTail(logPath)}`;
}
function formatLogTail(logPath: string): string {
const tail = tailFile(logPath, 30);
if (tail.length === 0) {
return `Check the log for details: ${logPath}`;
}
return `Last log lines (${logPath}):\n${tail}`;
}
function tailFile(filePath: string, maxLines: number): string {
try {
const content = readFileSync(filePath, 'utf8');
const lines = content.split('\n').filter((line) => line.length > 0);
return lines.slice(-maxLines).join('\n');
} catch {
return '';
}
}

View file

@ -1,49 +0,0 @@
/**
* `kimi server` parent command. Mounts:
* - `server run` (background daemon by default; `--foreground` to attach; the
* detached daemon child runs the same command with `--daemon`)
*
* The OS service-manager subcommands (`install/uninstall/start/stop/restart/
* status`) are temporarily NOT registered — see the commented
* `addLifecycleCommands(server)` below. Their implementation is preserved in
* `./lifecycle.ts` + `packages/kap-server/src/svc/*` for later re-exposure.
*
* The top-level `kimi web` alias is registered separately via
* `registerWebAliasCommand` so it stays at the program root.
*/
import type { Command } from 'commander';
import { registerPsCommand } from './ps';
import { registerKillCommand } from './kill';
import { buildRunCommand } from './run';
import { registerRotateTokenCommand } from './rotate-token';
import { registerWebAliasCommand } from './web-alias';
export function registerServerCommand(program: Command): void {
const server = program
.command('server')
.description('Run the local Kimi server (REST + WebSocket + web UI).');
buildRunCommand(
server.command('run').description('Start the Kimi server (background daemon; use --foreground to attach).'),
{ defaultOpen: false },
);
registerPsCommand(server);
registerKillCommand(server);
registerRotateTokenCommand(server);
// OS service-manager commands (`install/uninstall/start/stop/restart/status`)
// are temporarily hidden — the product now favors the on-demand background
// daemon (`kimi web`) over service-ization. The implementation still lives in
// `./lifecycle.ts` + `packages/kap-server/src/svc/*`; re-import
// `addLifecycleCommands` and call it here to re-expose.
// addLifecycleCommands(server);
registerWebAliasCommand(program);
}
export { registerWebAliasCommand };

View file

@ -1,167 +0,0 @@
/**
* `kimi server kill` terminate the running server.
*
* Combines two independent mechanisms so the server dies even if one path
* fails:
*
* 1. API path `POST /api/v1/shutdown` for a graceful, in-process shutdown
* (best-effort; older builds or a wedged server may not answer).
* 2. PID path signal the pid recorded in the lock (SIGTERM wait
* SIGKILL). SIGKILL / TerminateProcess is the hard guarantee:
* it cannot be caught or ignored.
*
* The only honest failure mode is insufficient permissions (a process owned by
* another user), which surfaces as an error rather than a silent miss.
*/
import type { Command } from 'commander';
import { getLiveLock, type LockContents } from '@moonshot-ai/kap-server';
import { getDataDir } from '#/utils/paths';
import { lockConnectHost } from './daemon';
import { authHeaders, serverOrigin, tryResolveServerToken } from './shared';
/** How long to wait for the graceful API shutdown request. */
const API_TIMEOUT_MS = 2000;
/** Grace period after SIGTERM before escalating to SIGKILL. */
const TERM_GRACE_MS = 3000;
/** Grace period after SIGKILL before giving up. */
const KILL_GRACE_MS = 2000;
/** Poll cadence while waiting for the pid to exit. */
const POLL_INTERVAL_MS = 100;
export interface KillCommandDeps {
getLiveLock(): LockContents | undefined;
requestShutdown(origin: string, token: string | undefined): Promise<void>;
/** Best-effort read of the persistent bearer token; undefined on miss. */
resolveToken(): string | undefined;
signalPid(pid: number, signal: NodeJS.Signals): boolean;
pidAlive(pid: number): boolean;
sleep(ms: number): Promise<void>;
stdout: Pick<NodeJS.WriteStream, 'write'>;
now(): number;
}
export function registerKillCommand(server: Command): void {
server
.command('kill')
.description('Stop the running Kimi server (graceful API + forced PID kill).')
.action(async () => {
try {
await handleKillCommand(DEFAULT_KILL_DEPS);
} catch (error) {
process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
process.exit(1);
}
});
}
export async function handleKillCommand(deps: KillCommandDeps): Promise<void> {
const lock = deps.getLiveLock();
if (!lock) {
deps.stdout.write('No running Kimi server.\n');
return;
}
const { pid } = lock;
const origin = serverOrigin(lockConnectHost(lock), lock.port);
// 1. API path — best-effort graceful shutdown. Ignore every outcome: the
// server may be an older build without the route, already wedged, or may
// drop the connection as it exits. The bearer token (M5.1) is best-effort
// too: if it can't be read the API call 401s and the PID path below still
// guarantees the kill.
const token = deps.resolveToken();
await deps.requestShutdown(origin, token).catch(() => {});
// 2. PID path — SIGTERM, wait, then SIGKILL.
deps.signalPid(pid, 'SIGTERM');
if (await waitForExit(pid, TERM_GRACE_MS, deps)) {
deps.stdout.write(`Kimi server (pid ${String(pid)}) stopped.\n`);
return;
}
deps.signalPid(pid, 'SIGKILL');
if (await waitForExit(pid, KILL_GRACE_MS, deps)) {
deps.stdout.write(`Kimi server (pid ${String(pid)}) killed.\n`);
return;
}
throw new Error(
`Failed to stop Kimi server (pid ${String(pid)}); insufficient permissions?`,
);
}
async function waitForExit(
pid: number,
timeoutMs: number,
deps: Pick<KillCommandDeps, 'pidAlive' | 'sleep' | 'now'>,
): Promise<boolean> {
const deadline = deps.now() + timeoutMs;
do {
if (!deps.pidAlive(pid)) return true;
await deps.sleep(POLL_INTERVAL_MS);
} while (deps.now() < deadline);
return !deps.pidAlive(pid);
}
/** `process.kill(pid, 0)` probe — true if the pid exists, false on ESRCH. */
export function pidAlive(pid: number): boolean {
try {
process.kill(pid, 0);
return true;
} catch (error) {
const code = (error as NodeJS.ErrnoException).code;
if (code === 'ESRCH') return false;
// EPERM = process exists but we can't signal it. Treat as alive.
return true;
}
}
/** Send `signal` to `pid`. Returns false if the signal could not be sent. */
export function signalPid(pid: number, signal: NodeJS.Signals): boolean {
try {
process.kill(pid, signal);
return true;
} catch {
return false;
}
}
/** POST the shutdown endpoint; resolves once the request completes or times out. */
export async function requestShutdownViaApi(
origin: string,
token: string | undefined,
): Promise<void> {
const controller = new AbortController();
const timeout = setTimeout(() => {
controller.abort();
}, API_TIMEOUT_MS);
try {
await fetch(`${origin}/api/v1/shutdown`, {
method: 'POST',
headers: token !== undefined ? authHeaders(token) : undefined,
signal: controller.signal,
});
} finally {
clearTimeout(timeout);
}
}
const DEFAULT_KILL_DEPS: KillCommandDeps = {
getLiveLock,
requestShutdown: requestShutdownViaApi,
resolveToken: () => tryResolveServerToken(getDataDir()),
signalPid,
pidAlive,
sleep: (ms) =>
new Promise((resolve) => {
setTimeout(resolve, ms);
}),
stdout: process.stdout,
now: () => Date.now(),
};

View file

@ -1,254 +0,0 @@
/**
* `kimi server install/uninstall/start/stop/restart/status`.
*
* The lifecycle calls into the platform service manager from
* `@moonshot-ai/kap-server` (`src/svc/*`).
*
* The Commander wiring here mirrors `addGatewayServiceCommands` from
* `../openclaw/src/cli/daemon-cli/register-service-commands.ts:58`.
*/
import type { Command } from 'commander';
import {
ServiceUnavailableError,
ServiceUnsupportedError,
resolveServiceManager,
type InstallArgs,
type ServiceManager,
type ServiceStatus,
} from '@moonshot-ai/kap-server';
import { openUrl as defaultOpenUrl } from '#/utils/open-url';
import {
DEFAULT_LOG_LEVEL,
DEFAULT_SERVER_HOST,
DEFAULT_SERVER_PORT,
LOCAL_SERVER_HOST,
parseLogLevel,
parsePort,
serverOrigin,
VALID_LOG_LEVELS,
} from './shared';
export interface InstallCliOptions {
port?: string;
logLevel?: string;
force?: boolean;
open?: boolean;
json?: boolean;
}
export interface JsonCliOptions {
json?: boolean;
}
export interface LifecycleCommandDeps {
resolveManager(): ServiceManager;
openUrl(url: string): void;
stdout: Pick<NodeJS.WriteStream, 'write'>;
stderr: Pick<NodeJS.WriteStream, 'write'>;
}
const DEFAULT_DEPS: LifecycleCommandDeps = {
resolveManager: resolveServiceManager,
openUrl: defaultOpenUrl,
stdout: process.stdout,
stderr: process.stderr,
};
/** Mount install/uninstall/start/stop/restart/status under a parent command. */
export function addLifecycleCommands(parent: Command, deps: LifecycleCommandDeps = DEFAULT_DEPS): void {
parent
.command('install')
.description('Install the Kimi server as an OS-managed service (launchd/systemd/schtasks).')
.option('--port <port>', `Bind port (default ${DEFAULT_SERVER_PORT})`, String(DEFAULT_SERVER_PORT))
.option(
'--log-level <level>',
`Log level: ${VALID_LOG_LEVELS.join('|')} (default ${DEFAULT_LOG_LEVEL})`,
DEFAULT_LOG_LEVEL,
)
.option('--force', 'Reinstall and overwrite if already installed', false)
.option('--no-open', 'Do not open the web UI after install.', true)
.option('--json', 'Output JSON', false)
.action(async (opts: InstallCliOptions) => {
await runLifecycle(deps, opts.json === true, async (mgr) => {
const args: InstallArgs = {
host: DEFAULT_SERVER_HOST,
port: parsePort(opts.port, '--port', DEFAULT_SERVER_PORT),
logLevel: parseLogLevel(opts.logLevel),
force: opts.force === true,
};
const result = await mgr.install(args);
const status = await readStatus(mgr);
const enriched = withStatusDetails({
ok: true,
action: 'install',
status: result.status,
plistPath: result.plistPath,
unitPath: result.unitPath,
taskName: result.taskName,
message: result.message,
}, status, args);
if (opts.json !== true && opts.open !== false && enriched.running === true && typeof enriched.url === 'string') {
deps.openUrl(enriched.url);
}
return enriched;
});
});
parent
.command('uninstall')
.description('Uninstall the Kimi server service.')
.option('--json', 'Output JSON', false)
.action(async (opts: JsonCliOptions) => {
await runLifecycle(deps, opts.json === true, async (mgr) => {
const result = await mgr.uninstall();
return { ok: result.ok, action: 'uninstall', message: result.message };
});
});
parent
.command('start')
.description('Start the Kimi server service.')
.option('--json', 'Output JSON', false)
.action(async (opts: JsonCliOptions) => {
await runLifecycle(deps, opts.json === true, async (mgr) => {
const result = await mgr.start();
const status = await readStatus(mgr);
return withStatusDetails({ ok: result.ok, action: 'start', message: result.message }, status);
});
});
parent
.command('stop')
.description('Stop the Kimi server service.')
.option('--json', 'Output JSON', false)
.action(async (opts: JsonCliOptions) => {
await runLifecycle(deps, opts.json === true, async (mgr) => {
const result = await mgr.stop();
return { ok: result.ok, action: 'stop', message: result.message };
});
});
parent
.command('restart')
.description('Restart the Kimi server service.')
.option('--json', 'Output JSON', false)
.action(async (opts: JsonCliOptions) => {
await runLifecycle(deps, opts.json === true, async (mgr) => {
const result = await mgr.restart();
const status = await readStatus(mgr);
return withStatusDetails({ ok: result.ok, action: 'restart', message: result.message }, status);
});
});
parent
.command('status')
.description('Show Kimi server service status and connectivity.')
.option('--json', 'Output JSON', false)
.action(async (opts: JsonCliOptions) => {
await runLifecycle(deps, opts.json === true, async (mgr) => {
const status: ServiceStatus = await mgr.status();
return withStatusDetails({ ok: true, action: 'status', ...status }, status);
});
});
}
async function runLifecycle(
deps: LifecycleCommandDeps,
json: boolean,
body: (mgr: ServiceManager) => Promise<Record<string, unknown>>,
): Promise<void> {
try {
const mgr = deps.resolveManager();
const result = await body(mgr);
if (json) {
deps.stdout.write(`${JSON.stringify(result)}\n`);
return;
}
deps.stdout.write(formatHuman(result));
} catch (error) {
if (error instanceof ServiceUnavailableError || error instanceof ServiceUnsupportedError) {
const payload = {
ok: false,
action: error instanceof ServiceUnavailableError ? 'unavailable' : 'unsupported',
platform: error.platform,
message: error.message,
};
if (json) {
deps.stdout.write(`${JSON.stringify(payload)}\n`);
} else {
deps.stderr.write(`${error.message}\n`);
}
process.exit(2);
return;
}
if (json) {
deps.stdout.write(
`${JSON.stringify({ ok: false, message: error instanceof Error ? error.message : String(error) })}\n`,
);
} else {
deps.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
}
process.exit(1);
}
}
function formatHuman(result: Record<string, unknown>): string {
const rawAction = result['action'];
const action = typeof rawAction === 'string' ? rawAction : 'action';
const rawMessage = result['message'];
const message = typeof rawMessage === 'string' ? `: ${rawMessage}` : '';
const lines = [`${action}${message}`];
const url = result['url'];
if (typeof url === 'string') lines.push(`URL: ${url}`);
const running = result['running'];
if (typeof running === 'boolean') lines.push(`Status: ${running ? 'running' : 'not running'}`);
const logPath = result['logPath'];
if (typeof logPath === 'string') lines.push(`Log: ${logPath}`);
const notes = result['notes'];
if (Array.isArray(notes)) {
for (const note of notes) {
if (typeof note === 'string' && note.length > 0) lines.push(`Note: ${note}`);
}
}
return `${lines.join('\n')}\n`;
}
async function readStatus(mgr: ServiceManager): Promise<ServiceStatus | undefined> {
try {
return await mgr.status();
} catch {
return undefined;
}
}
function withStatusDetails(
result: Record<string, unknown>,
status: ServiceStatus | undefined,
fallback?: { host: string; port: number },
): Record<string, unknown> & { url?: string; running?: boolean } {
const host = status?.host ?? fallback?.host;
const port = status?.port ?? fallback?.port;
const url = host !== undefined && port !== undefined ? formatServiceUrl(host, port) : undefined;
return {
...result,
url,
running: status?.running,
host,
port,
logPath: status?.logPath,
notes: status?.notes,
};
}
function formatServiceUrl(host: string, port: number): string {
return serverOrigin(host === '0.0.0.0' ? LOCAL_SERVER_HOST : host, port);
}

View file

@ -1,84 +0,0 @@
/**
* Enumerate this machine's non-loopback network interface addresses, used to
* print `Network: http://<addr>:<port>/` hints (à la Vite) when the server
* binds a wildcard host (`0.0.0.0` / `::`).
*/
import { networkInterfaces } from 'node:os';
export interface NetworkAddress {
/** Raw IP address (IPv4 or IPv6); IPv6 is NOT bracket-wrapped here. */
address: string;
family: 'IPv4' | 'IPv6';
}
/**
* List non-internal interface addresses, IPv4 first then IPv6, preserving
* interface order within each family.
*
* Like Vite, this lists the machine's own interface addresses LAN
* (192.168/10/172.16) plus any directly-assigned public address. It does not
* (and cannot, without an external service) discover a NAT-translated WAN IP,
* and we deliberately avoid any network call for a startup hint.
*/
export function listNetworkAddresses(): NetworkAddress[] {
const raw: NetworkAddress[] = [];
for (const entries of Object.values(networkInterfaces())) {
for (const info of entries ?? []) {
if (info.internal) {
continue;
}
if (info.family === 'IPv4') {
raw.push({ address: info.address, family: 'IPv4' });
} else if (info.family === 'IPv6') {
raw.push({ address: info.address, family: 'IPv6' });
}
}
}
return filterDisplayAddresses(raw);
}
/**
* Drop addresses that are not useful as a connect target and de-duplicate.
*
* IPv6 link-local (`fe80::/10`) is filtered out: it is only reachable with a
* zone id (e.g. `fe80::1%en0`), which our bare URL cannot carry, so showing it
* is pure noise and it is the bulk of what `os.networkInterfaces()` reports
* on a typical machine. Duplicates (the same address reported on more than one
* interface) are collapsed. The result is IPv4 first, then IPv6, preserving
* order within each family.
*/
export function filterDisplayAddresses(
addrs: readonly NetworkAddress[],
): NetworkAddress[] {
const seen = new Set<string>();
const kept: NetworkAddress[] = [];
for (const addr of addrs) {
if (addr.family === 'IPv6' && isLinkLocalV6(addr.address)) {
continue;
}
if (seen.has(addr.address)) {
continue;
}
seen.add(addr.address);
kept.push(addr);
}
return [
...kept.filter((a) => a.family === 'IPv4'),
...kept.filter((a) => a.family === 'IPv6'),
];
}
/** True for IPv6 link-local addresses (`fe80::/10`, i.e. `fe80::``febf::`). */
function isLinkLocalV6(address: string): boolean {
const first = Number.parseInt(address.split(':')[0] ?? '', 16);
return first >= 0xfe80 && first <= 0xfebf;
}
/**
* Format an address for use as a URL host: bracket-wrap IPv6 per RFC 3986,
* return IPv4 as-is.
*/
export function formatHostForUrl(address: string, family: NetworkAddress['family']): string {
return family === 'IPv6' ? `[${address}]` : address;
}

View file

@ -1,148 +0,0 @@
/**
* `kimi server ps` list clients currently connected to the running server.
*
* Talks to the running server over HTTP (`GET /api/v1/connections`) using the
* single-instance lock (`~/.kimi-code/server/lock`) to discover its origin
* the same way `kimi web` locates the daemon.
*/
import chalk from 'chalk';
import type { Command } from 'commander';
import { getLiveLock } from '@moonshot-ai/kap-server';
import { getDataDir } from '#/utils/paths';
import { lockConnectHost } from './daemon';
import { authHeaders, isServerHealthy, resolveServerToken, serverOrigin } from './shared';
/** Wire shape of a single connection returned by `GET /api/v1/connections`. */
interface ConnectionInfo {
id: string;
connected_at: string;
remote_address: string | null;
user_agent: string | null;
has_client_hello: boolean;
subscriptions: string[];
}
interface ConnectionsEnvelope {
code: number;
msg: string;
data?: { connections?: ConnectionInfo[] };
}
const HEALTH_TIMEOUT_MS = 1500;
const FETCH_TIMEOUT_MS = 5000;
const USER_AGENT_MAX_WIDTH = 40;
export function registerPsCommand(server: Command): void {
server
.command('ps')
.description('List clients currently connected to the running Kimi server.')
.option('--json', 'Print the raw connection list as JSON.')
.action(async (opts: { json?: boolean }) => {
try {
await handlePsCommand(opts);
} catch (error) {
process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
process.exit(1);
}
});
}
async function handlePsCommand(opts: { json?: boolean }): Promise<void> {
const lock = getLiveLock();
if (!lock) {
throw new Error(
'No running Kimi server. Start one with `kimi server run` or `kimi web`.',
);
}
const origin = serverOrigin(lockConnectHost(lock), lock.port);
if (!(await isServerHealthy(origin, HEALTH_TIMEOUT_MS))) {
throw new Error(`Kimi server at ${origin} is not responding.`);
}
// The `/api/v1/connections` route is gated by bearer auth (M5.1). Read the
// persistent token; a clear error here means the server has never been
// started (no token file yet) or the token file was removed.
const token = resolveServerToken(getDataDir());
const connections = await fetchConnections(origin, token);
if (opts.json) {
process.stdout.write(`${JSON.stringify(connections, null, 2)}\n`);
return;
}
process.stdout.write(formatTable(connections));
}
async function fetchConnections(origin: string, token: string): Promise<ConnectionInfo[]> {
const controller = new AbortController();
const timeout = setTimeout(() => {
controller.abort();
}, FETCH_TIMEOUT_MS);
try {
const res = await fetch(`${origin}/api/v1/connections`, {
headers: authHeaders(token),
signal: controller.signal,
});
if (!res.ok) {
throw new Error(`Failed to list clients: HTTP ${String(res.status)} from ${origin}.`);
}
const body = (await res.json()) as ConnectionsEnvelope;
if (body.code !== 0) {
throw new Error(`Failed to list clients: ${body.msg}`);
}
return body.data?.connections ?? [];
} catch (error) {
if (error instanceof Error && error.name === 'AbortError') {
throw new Error(`Timed out listing clients from ${origin}.`);
}
throw error;
} finally {
clearTimeout(timeout);
}
}
function formatTable(connections: ConnectionInfo[]): string {
if (connections.length === 0) {
return 'No active clients.\n';
}
const header = ['ID', 'CONNECTED', 'REMOTE', 'USER_AGENT', 'SESSIONS', 'HELLO'];
const rows = connections.map((c) => [
c.id,
formatAge(c.connected_at),
c.remote_address ?? '-',
truncate(c.user_agent ?? '-', USER_AGENT_MAX_WIDTH),
String(c.subscriptions.length),
c.has_client_hello ? 'yes' : 'no',
]);
const widths = header.map((h, i) => Math.max(h.length, ...rows.map((r) => r[i]!.length)));
const formatRow = (cells: string[]): string =>
cells.map((cell, i) => cell + ' '.repeat(Math.max(0, widths[i]! - cell.length))).join(' ');
const lines = [chalk.bold(formatRow(header)), ...rows.map(formatRow)];
return `${lines.join('\n')}\n`;
}
function formatAge(iso: string): string {
const ms = Date.now() - Date.parse(iso);
if (!Number.isFinite(ms) || ms < 0) return '-';
const seconds = Math.floor(ms / 1000);
if (seconds < 60) return `${String(seconds)}s`;
const minutes = Math.floor(seconds / 60);
if (minutes < 60) return `${String(minutes)}m`;
const hours = Math.floor(minutes / 60);
if (hours < 24) return `${String(hours)}h`;
const days = Math.floor(hours / 24);
return `${String(days)}d`;
}
function truncate(value: string, max: number): string {
if (value.length <= max) return value;
if (max <= 1) return value.slice(0, max);
return `${value.slice(0, max - 1)}`;
}

View file

@ -1,57 +0,0 @@
/**
* `kimi server rotate-token` generate a new persistent server token.
*
* Rewrites `<KIMI_CODE_HOME>/server.token` (0600, atomic). The previous token
* stops working immediately: a running server re-reads the file on its next
* auth check, so rotation takes effect without a restart.
*/
import { getLiveLock, rotateServerToken } from '@moonshot-ai/kap-server';
import chalk from 'chalk';
import type { Command } from 'commander';
import { darkColors } from '#/tui/theme/colors';
import { getDataDir } from '#/utils/paths';
import { accessUrlLines, splitTokenFragment } from './access-urls';
import { DEFAULT_SERVER_HOST } from './shared';
export function registerRotateTokenCommand(server: Command): void {
server
.command('rotate-token')
.description(
'Generate a new persistent server token; the previous token stops working immediately.',
)
.action(async () => {
try {
const token = await rotateServerToken(getDataDir());
process.stdout.write(
'The previous token is now invalid. A running server picks up the new token automatically.\n',
);
// Token in the middle: indented and set off by blank lines (no color
// highlight), so it is easy to spot without dominating the output.
process.stdout.write(`\n ${chalk.bold('New server token:')} ${token}\n\n`);
// Re-print the access links with the new token so the user can
// reconnect immediately. When a server is running its bind host/port
// come from the lock; otherwise there is nothing to connect to yet.
const lock = getLiveLock();
if (lock !== undefined) {
const host = lock.host ?? DEFAULT_SERVER_HOST;
for (const { label, url: href } of accessUrlLines(host, lock.port, token)) {
// De-emphasize the `#token=…` fragment so the host/port stands out.
const [base, frag] = splitTokenFragment(href);
const rendered =
frag === ''
? chalk.hex(darkColors.accent)(base)
: chalk.hex(darkColors.accent)(base) + chalk.hex(darkColors.textDim)(frag);
process.stdout.write(` ${chalk.dim(label)}${rendered}\n`);
}
}
} catch (error) {
process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
process.exit(1);
}
});
}

View file

@ -1,600 +0,0 @@
/**
* `kimi server run` starts the local server.
*
* By default this ensures a single background daemon is running (spawning a
* detached `kimi server run --daemon` child when needed) and returns once it is
* healthy. Pass `--foreground` to run the server in-process and keep this
* terminal attached until SIGINT/SIGTERM. OS-managed background operation
* (launchd / systemd / schtasks) lives in `kimi server install` + `kimi server start`.
*
* `kimi web` is an alias of this command with `--open` defaulted to `true`,
* registered in `./web-alias.ts`.
*/
import { join } from 'node:path';
import { hostRequestHeadersSeed } from '@moonshot-ai/agent-core-v2';
import { createServerLogger, startServer, type ServerLogger } from '@moonshot-ai/kap-server';
import { shutdownTelemetry, track } from '@moonshot-ai/kimi-telemetry';
import chalk from 'chalk';
import { Option, type Command } from 'commander';
import { CLI_SHUTDOWN_TIMEOUT_MS } from '#/constant/app';
import { getNativeWebAssetsDir } from '#/native/web-assets';
import { darkColors } from '#/tui/theme/colors';
import { openUrl as defaultOpenUrl } from '#/utils/open-url';
import { getDataDir } from '#/utils/paths';
import { initializeServerTelemetry } from '../../telemetry';
import {
buildKimiDefaultHeaders,
getHostPackageRoot,
getVersion,
} from '../../version';
import {
accessUrlLines,
buildOpenableUrl,
isLoopbackHost,
splitTokenFragment,
} from './access-urls';
import { ensureDaemon, type EnsureDaemonResult } from './daemon';
import { type NetworkAddress } from './networks';
import {
DEFAULT_FOREGROUND_LOG_LEVEL,
DEFAULT_LAN_HOST,
DEFAULT_SERVER_HOST,
DEFAULT_SERVER_PORT,
parseServerOptions,
tryResolveServerToken,
VALID_LOG_LEVELS,
type ParsedServerOptions,
type ServerCliOptions,
} from './shared';
const WEB_ASSETS_DIR = 'dist-web';
/**
* Minimal surface `runServerInProcess` needs from the server. kap-server's
* `RunningServer` is adapted to it (it returns `{ host, port, close }`
* instead of `{ address, logger, close }`).
*/
interface RoutedServer {
readonly address: string;
readonly logger: ServerLogger;
close(): Promise<void>;
}
export interface RunCliOptions extends ServerCliOptions {
open?: boolean;
/** Run the server in-process instead of spawning a background daemon. */
foreground?: boolean;
}
export interface StartForegroundHooks {
/** Fires once the server is listening, before the foreground runner blocks. */
onReady?: (origin: string) => void;
}
export interface RunCommandDeps {
startServerBackground(options: ParsedServerOptions): Promise<{
origin: string;
/** True when an already-running daemon was reused (no new server started). */
reused?: boolean;
/** Bind host the running daemon is actually listening on (from the lock). */
host?: string;
/** Port the running daemon is actually listening on (from the lock). */
port?: number;
}>;
/** Foreground runner; defaults to the real in-process runner when omitted. */
startServerForeground?: (
options: ParsedServerOptions,
hooks?: StartForegroundHooks,
) => Promise<never>;
openUrl(url: string): void;
/**
* Best-effort read of the server's persistent bearer token. When it returns
* a token, the ready banner prints it and the opened Web UI URL carries it in
* the `#token=` fragment (M5.5). Optional so callers/tests that don't supply
* it simply print/open the plain origin.
*/
resolveToken?: () => string | undefined;
/**
* Non-loopback interface addresses to display for a wildcard bind. Defaults
* to the machine's own interfaces (`listNetworkAddresses()`); inject a fixed
* list in tests for deterministic output.
*/
networkAddresses?: NetworkAddress[];
stdout: Pick<NodeJS.WriteStream, 'write'>;
stderr: Pick<NodeJS.WriteStream, 'write'>;
}
/**
* Build the Web UI URL, carrying the bearer token in the URL fragment.
*
* The token rides in `#token=<token>` a client-side fragment that is never
* sent to the server (so it never appears in server access logs) and is not
* logged by proxies. The Web UI reads it from `location.hash` after load.
*/
export function buildWebUrl(origin: string, token: string): string {
return buildOpenableUrl(origin, token);
}
/** Build the `run` subcommand, mounted under a parent (`server` or top-level). */
export function buildRunCommand(cmd: Command, options: { defaultOpen: boolean }): Command {
return cmd
.option(
'--port <port>',
`Bind port (default ${DEFAULT_SERVER_PORT})`,
String(DEFAULT_SERVER_PORT),
)
.option(
'--host [host]',
`Bind host. Omit to bind ${DEFAULT_SERVER_HOST} (this machine only); pass --host to bind ${DEFAULT_LAN_HOST} (all interfaces), or --host <host> for a specific host. The bearer token is printed at startup.`,
)
.option(
'--allowed-host <host...>',
'Extra Host header value to allow through the DNS-rebinding check. Repeat or comma-separate; a leading dot matches a domain suffix (e.g. .example.com).',
)
.option(
'--keep-alive',
'Keep the server running instead of exiting after 60s with no connected clients. Implied automatically by --host / --allowed-host, and always on in --foreground mode.',
false,
)
.option(
'--insecure-no-tls',
'Allow a non-loopback bind without a TLS-terminating reverse proxy. Defaults to true; only relevant for non-loopback binds.',
true,
)
.option(
'--allow-remote-shutdown',
'On a non-loopback bind, keep POST /api/v1/shutdown enabled (default: route is disabled → 404).',
false,
)
.option(
'--allow-remote-terminals',
'On a non-loopback bind, keep the PTY /api/v1/terminals/* routes enabled (default: disabled → 404). Remote shell is high risk.',
false,
)
.option(
'--dangerous-bypass-auth',
'Disable bearer-token auth on every REST and WebSocket route, and advertise it via /api/v1/meta so the web UI connects without a token. Only use on a trusted network or behind your own authenticating proxy.',
false,
)
.option(
'--log-level <level>',
`Server log level: ${VALID_LOG_LEVELS.join('|')}. Omit to keep logs off.`,
)
.option(
'--debug-endpoints',
'Mount /api/v1/debug/* routes for test introspection. OFF by default; production callers leave this unset.',
false,
)
.option(
'--foreground',
'Run the server in the foreground and keep this terminal attached until SIGINT/SIGTERM (do not daemonize).',
false,
)
.option(
options.defaultOpen ? '--no-open' : '--open',
options.defaultOpen
? 'Do not open the web UI in the default browser.'
: 'Open the web UI in the default browser once the server is healthy.',
options.defaultOpen,
)
.addOption(
new Option('--daemon', 'Run as an idle-exiting background daemon (internal).').hideHelp(),
)
.addOption(
new Option(
'--idle-grace-ms <ms>',
'Idle-shutdown grace in ms (daemon mode, internal).',
).hideHelp(),
)
.action(async (opts: RunCliOptions) => {
try {
await handleRunCommand(opts);
} catch (error) {
process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
process.exit(1);
}
});
}
export async function handleRunCommand(
opts: RunCliOptions,
deps: RunCommandDeps = DEFAULT_RUN_COMMAND_DEPS,
): Promise<void> {
const parsed = parseServerOptions(opts);
if (parsed.daemon) {
await startServerDaemon(parsed);
return;
}
// Foreground is always keep-alive: a server attached to the operator's
// terminal must never idle-kill itself. Background daemons respect the
// derived `--keep-alive` flag.
const runOptions: ParsedServerOptions =
opts.foreground === true ? { ...parsed, keepAlive: true } : parsed;
// Resolve the persistent token once: it is printed in the ready banner and
// rides in the opened Web UI URL's `#token=` fragment (M5.5). Falls back to
// the plain origin / no token line when unavailable. When auth is bypassed,
// the token is meaningless and is intentionally NOT shown or carried in the
// opened URL.
const writeReady = (result: { origin: string; reused?: boolean; host?: string }): void => {
const { origin } = result;
const host = result.host ?? parsed.host;
// When a daemon is reused, this command's flags were NOT applied to the
// already-running server. Don't trust the requested `--dangerous-bypass-auth`
// for display/open: treat the server as token-protected so we never hide a
// token the user actually needs, nor claim bypass for a server that is
// authenticating. (Probing the running server's `/meta` would give its real
// mode; we conservatively assume non-bypass on reuse.)
const effectiveBypass = result.reused === true ? false : parsed.dangerousBypassAuth;
const token = effectiveBypass ? undefined : deps.resolveToken?.();
let output = '';
if (result.reused === true) {
// A daemon was already running, so this command's --host/--port/etc. did
// not start a new one. Say so loudly, then print the actual running
// server's URLs (using its real bind host, not the requested one).
output += formatReuseNotice(origin);
}
output +=
parsed.logLevel === DEFAULT_FOREGROUND_LOG_LEVEL
? formatReadyBanner(origin, host, {
token,
networkAddresses: deps.networkAddresses,
dangerousBypassAuth: effectiveBypass,
})
: formatReadyLine(origin, token, effectiveBypass);
deps.stdout.write(output);
if (opts.open === true) {
deps.openUrl(token !== undefined ? buildWebUrl(origin, token) : origin);
}
};
if (opts.foreground === true) {
const run = deps.startServerForeground ?? startServerForeground;
await run(runOptions, {
onReady: (origin) => {
writeReady({ origin, reused: false, host: parsed.host });
},
});
return;
}
const result = await deps.startServerBackground(runOptions);
writeReady(result);
}
function formatReuseNotice(origin: string): string {
return (
`${chalk.hex(darkColors.warning)('A server is already running')} at ${origin}` +
`the options from this command were not applied. ` +
`Run ${chalk.bold('kimi server kill')} first to bind a new host/port.\n`
);
}
function formatReadyLine(
origin: string,
token: string | undefined,
dangerousBypassAuth = false,
): string {
const notice = dangerousBypassAuth
? `${formatDangerNoticeLines().join('\n')}\n`
: '';
return `${notice}Kimi server: ${buildOpenableUrl(origin, token)}\n`;
}
/**
* Red, impossible-to-miss notice emitted when `--dangerous-bypass-auth`
* disables the bearer-token gate. Shared by the full ready banner and the
* compact one-line output so the warning always shows regardless of log level.
*/
function formatDangerNoticeLines(): string[] {
const danger = (text: string): string => chalk.hex(darkColors.error)(text);
const dangerBold = (text: string): string => chalk.bold.hex(darkColors.error)(text);
return [
` ${dangerBold('⚠ DANGER: authentication is DISABLED (--dangerous-bypass-auth).')}`,
` ${danger('Anyone who can reach this port gets full access. Only continue if you understand the risk.')}`,
` ${danger(`If you are unsure, run `)}${dangerBold('kimi server kill')}${danger(' now to stop this process.')}`,
];
}
/**
* `kimi server run` (non-daemon) ensures a background daemon is running
* (spawning a detached `kimi server run --daemon` child if needed), then
* returns its origin so the caller can print the ready banner and exit. The
* server keeps running in the background after this returns.
*/
export async function startServerBackground(
options: ParsedServerOptions,
): Promise<EnsureDaemonResult> {
return ensureDaemon({
host: options.host,
port: options.port,
logLevel: options.logLevel,
debugEndpoints: options.debugEndpoints,
insecureNoTls: options.insecureNoTls,
allowRemoteShutdown: options.allowRemoteShutdown,
allowRemoteTerminals: options.allowRemoteTerminals,
dangerousBypassAuth: options.dangerousBypassAuth,
keepAlive: options.keepAlive,
allowedHosts: options.allowedHosts,
idleGraceMs: options.idleGraceMs,
});
}
/**
* `kimi server run --daemon` runs the local server as a background daemon.
*
* Spawned as a detached child by {@link startServerBackground}. The process is
* expected to be detached (no controlling terminal) and self-terminates after
* the last web client disconnects and a grace period elapses. The grace timer
* is driven by the WS connection count reported through `wsGatewayOptions`.
* Resolves only via `process.exit`.
*/
export async function startServerDaemon(options: ParsedServerOptions): Promise<never> {
return runServerInProcess(options, { daemon: true });
}
/**
* `kimi server run --foreground` runs the local server in-process, attached
* to the current terminal. Resolves only via `process.exit` (SIGINT/SIGTERM).
*/
export async function startServerForeground(
options: ParsedServerOptions,
hooks: StartForegroundHooks = {},
): Promise<never> {
return runServerInProcess(options, { daemon: false }, hooks.onReady);
}
/**
* Start the server in the current process and block until shutdown. Shared by
* the detached daemon (`daemon: true`, with idle-exit) and the foreground
* runner (`daemon: false`). `onReady` fires once the server is listening.
*/
async function runServerInProcess(
options: ParsedServerOptions,
mode: { daemon: boolean },
onReady?: (origin: string) => void,
): Promise<never> {
const version = getVersion();
// Registers the telemetry provider for `track` / `shutdownTelemetry`; the
// client itself is not passed into kap-server.
initializeServerTelemetry({ version });
let running: RoutedServer | undefined;
let stopping = false;
// Idle auto-shutdown is only for the on-demand personal daemon. It is skipped
// in foreground mode (`mode.daemon` is false) and whenever `--keep-alive` is
// set — explicitly, or implied by `--host` / `--allowed-host`.
const idle =
mode.daemon && !options.keepAlive
? createIdleShutdownHandler({
graceMs: options.idleGraceMs,
onIdle: () => {
void shutdown('idle');
},
})
: undefined;
async function shutdown(reason: string): Promise<void> {
if (stopping) return;
stopping = true;
idle?.cancel();
running?.logger.info({ reason }, 'server shutting down');
try {
await running?.close();
await shutdownTelemetry({ timeoutMs: CLI_SHUTDOWN_TIMEOUT_MS });
} catch (error) {
running?.logger.error(
{ err: error instanceof Error ? error : new Error(String(error)) },
'server shutdown error',
);
}
process.exit(0);
}
// kap-server (the DI × Scope engine server) is the only server flavor. Its
// `startServer` returns `{ host, port, close }` rather than `{ address,
// logger, close }`, so adapt it to the `RoutedServer` surface the rest of
// this runner consumes.
const logger = createServerLogger({ level: options.logLevel });
const v2 = await startServer({
host: options.host,
port: options.port,
// Report the CLI's product version as `server_version` (/meta, web UI)
// rather than kap-server's private package version.
version,
logLevel: options.logLevel,
logger,
debugEndpoints: options.debugEndpoints,
insecureNoTls: options.insecureNoTls,
allowRemoteShutdown: options.allowRemoteShutdown,
allowRemoteTerminals: options.allowRemoteTerminals,
allowedHosts: options.allowedHosts,
disableAuth: options.dangerousBypassAuth,
// Seed the CLI's Kimi identity headers so the engine's outbound
// requests (model, WebSearch, FetchURL) carry the same User-Agent +
// X-Msh-* identity as direct CLI runs.
seeds: hostRequestHeadersSeed(buildKimiDefaultHeaders(version)),
webAssetsDir: serverWebAssetsDir(),
});
// The connection registry exposes no count-change hook, so forward
// add/remove to the daemon's idle-shutdown handler (a no-op when `idle`
// is undefined, e.g. foreground or --keep-alive).
if (idle !== undefined) {
const registry = v2.connectionRegistry;
const add = registry.add.bind(registry);
const remove = registry.remove.bind(registry);
registry.add = (conn) => {
add(conn);
idle.onConnectionCountChange(registry.size());
};
registry.remove = (connId) => {
remove(connId);
idle.onConnectionCountChange(registry.size());
};
}
logger.info('serving the REST/WS API and the bundled web UI');
running = {
address: `http://${v2.host}:${v2.port}`,
logger,
close: () => v2.close(),
};
track('server_started', { daemon: mode.daemon });
process.once('SIGINT', () => {
void shutdown('SIGINT');
});
process.once('SIGTERM', () => {
void shutdown('SIGTERM');
});
const readyFields = mode.daemon
? options.keepAlive
? { address: running.address, idleShutdown: 'disabled' as const }
: { address: running.address, idleGraceMs: options.idleGraceMs }
: { address: running.address };
running.logger.info(readyFields, mode.daemon ? 'daemon ready' : 'server ready');
onReady?.(running.address);
return new Promise<never>(() => {
// Keeps the event loop alive; the process ends via shutdown()/process.exit.
});
}
/**
* Pure idle-shutdown state machine, exported for tests.
*
* Watches the live WS connection count and fires `onIdle` exactly once, after
* the count has dropped back to zero for `graceMs` ms *and* at least one
* client had connected since startup. A reconnect before the grace elapses
* cancels the pending exit. The initial "no clients yet" state never arms the
* timer (so a freshly-spawned daemon is not killed before anyone connects).
*/
export function createIdleShutdownHandler(opts: { graceMs: number; onIdle: () => void }): {
onConnectionCountChange(size: number): void;
cancel(): void;
} {
let timer: NodeJS.Timeout | undefined;
let seenClient = false;
const cancel = (): void => {
if (timer !== undefined) {
clearTimeout(timer);
timer = undefined;
}
};
return {
onConnectionCountChange(size: number): void {
if (size > 0) {
seenClient = true;
cancel();
return;
}
if (seenClient) {
cancel();
timer = setTimeout(opts.onIdle, opts.graceMs);
}
},
cancel,
};
}
function serverWebAssetsDir(): string {
return resolveServerWebAssetsDir();
}
export function resolveServerWebAssetsDir(
nativeWebAssetsDir: string | null = getNativeWebAssetsDir(),
): string {
return nativeWebAssetsDir ?? join(getHostPackageRoot(), WEB_ASSETS_DIR);
}
interface FormatReadyBannerOptions {
/** Persistent bearer token to print; omitted when unresolvable. */
token?: string;
/** Non-loopback interface addresses to list for a wildcard bind. */
networkAddresses?: NetworkAddress[];
/** When true, render a red danger notice (auth is disabled). */
dangerousBypassAuth?: boolean;
}
function formatReadyBanner(
origin: string,
host: string,
opts: FormatReadyBannerOptions = {},
): string {
const primary = (text: string): string => chalk.hex(darkColors.primary)(text);
const title = (text: string): string => chalk.bold.hex(darkColors.primary)(text);
const dim = (text: string): string => chalk.hex(darkColors.textDim)(text);
const muted = (text: string): string => chalk.hex(darkColors.textMuted)(text);
const label = (text: string): string => chalk.bold.hex(darkColors.textDim)(text);
const url = (text: string): string => chalk.hex(darkColors.accent)(text);
// Render the `#token=…` fragment in a de-emphasized gray so the host/port
// stands out while the full URL stays selectable for copying.
const urlWithDimToken = (href: string): string => {
const [base, frag] = splitTokenFragment(href);
return frag === '' ? url(base) : url(base) + dim(frag);
};
const port = Number(new URL(origin).port);
// Borderless header: the Kimi sprite (the little mascot with eyes) sits next
// to the title, keeping the brand without the enclosing box.
const logo = ['▐█▛█▛█▌', '▐█████▌'] as const;
const lines: string[] = [
'',
` ${primary(logo[0])} ${title('Kimi server ready')} ${dim(getVersion())}`,
` ${primary(logo[1])} ${dim('Local web UI is available from this machine.')}`,
'',
];
if (opts.dangerousBypassAuth === true) {
// Red, impossible-to-miss notice: the bearer-token gate is off, so anyone
// who can reach this port gets full session / filesystem / shell access.
lines.push(...formatDangerNoticeLines(), '');
}
// Access links.
for (const { label: text, url: href } of accessUrlLines(
host,
port,
opts.token,
opts.networkAddresses,
)) {
lines.push(` ${label(text)}${urlWithDimToken(href)}`);
}
// On a loopback bind there is no network URL; show how to enable one.
if (isLoopbackHost(host)) {
lines.push(` ${label('Network: ')}${muted('off')}${dim(' use --host to enable')}`);
}
if (opts.token !== undefined) {
// Set the token off with surrounding whitespace rather than color, so it is
// easy to spot without being highlighted.
lines.push('');
lines.push(` ${label('Token: ')}${opts.token}`);
lines.push('');
}
// Auxiliary controls last.
lines.push(` ${label('Logs: ')}${muted('off')}${dim(' use --log-level info to enable')}`);
lines.push(` ${label('Stop: ')}${muted('kimi server kill')}`);
lines.push('');
return lines.join('\n');
}
const DEFAULT_RUN_COMMAND_DEPS: RunCommandDeps = {
startServerBackground,
startServerForeground,
openUrl: defaultOpenUrl,
resolveToken: () => {
// Read the persistent `<homeDir>/server.token` written on first boot
// (M5.1). Best-effort: a missing/older server yields undefined and the
// caller opens the plain origin.
return tryResolveServerToken(getDataDir());
},
stdout: process.stdout,
stderr: process.stderr,
};

View file

@ -1,279 +0,0 @@
/**
* Shared helpers for `kimi server …` subcommands.
*
* Owns the default host/port, option parsers, and health/readiness probes that
* `run`, `web`, and `status` all use.
*/
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import type { ServerLogLevel } from '@moonshot-ai/kap-server';
export const LOCAL_SERVER_HOST = '127.0.0.1';
export const DEFAULT_LAN_HOST = '0.0.0.0';
export const DEFAULT_SERVER_HOST = LOCAL_SERVER_HOST;
export const DEFAULT_SERVER_PORT = 58627;
export const DEFAULT_SERVER_ORIGIN = serverOrigin(DEFAULT_SERVER_HOST, DEFAULT_SERVER_PORT);
/** Filename (under KIMI_CODE_HOME) of the persistent server bearer token. */
export const SERVER_TOKEN_FILE = 'server.token';
export const DEFAULT_LOG_LEVEL: ServerLogLevel = 'info';
export const DEFAULT_FOREGROUND_LOG_LEVEL: ServerLogLevel = 'silent';
/**
* Default idle-shutdown grace for the background daemon: once the last web
* client disconnects, the daemon waits this long before exiting. Overridable
* via the internal `--idle-grace-ms` flag (used by tests).
*/
export const DEFAULT_IDLE_GRACE_MS = 60_000;
export const VALID_LOG_LEVELS: readonly ServerLogLevel[] = [
'fatal',
'error',
'warn',
'info',
'debug',
'trace',
'silent',
];
export interface ParsedServerOptions {
host: string;
port: number;
logLevel: ServerLogLevel;
debugEndpoints: boolean;
/** Allow a non-loopback bind without a TLS-terminating reverse proxy. */
insecureNoTls: boolean;
/** Allow `POST /api/v1/shutdown` on a non-loopback bind. */
allowRemoteShutdown: boolean;
/** Allow PTY `/api/v1/terminals/*` routes on a non-loopback bind. */
allowRemoteTerminals: boolean;
/** Disable bearer-token auth on every route (`--dangerous-bypass-auth`). */
dangerousBypassAuth: boolean;
/** Extra `Host` header values to allow through the DNS-rebinding check. */
allowedHosts: readonly string[];
/**
* Keep the server running instead of idle-killing it after 60s with no
* connected clients (`--keep-alive`). Also implied automatically by a
* non-default bind (`--host`) or a proxy/tunnel setup (`--allowed-host`),
* and always on in `--foreground` mode. Only the daemon mode consults this
* foreground never idle-kills regardless.
*/
keepAlive: boolean;
/** Internal: run as an idle-exiting background daemon instead of foreground. */
daemon: boolean;
/** Internal: idle-shutdown grace in ms (daemon mode only). */
idleGraceMs: number;
}
export interface ServerCliOptions {
host?: string | boolean;
port?: string;
logLevel?: string;
debugEndpoints?: boolean;
/** Allow a non-loopback bind without TLS (`--insecure-no-tls`). */
insecureNoTls?: boolean;
/** Allow remote shutdown on a non-loopback bind (`--allow-remote-shutdown`). */
allowRemoteShutdown?: boolean;
/** Allow remote terminals on a non-loopback bind (`--allow-remote-terminals`). */
allowRemoteTerminals?: boolean;
/** Disable bearer-token auth on every route (`--dangerous-bypass-auth`). */
dangerousBypassAuth?: boolean;
/** Extra `Host` header values to allow (`--allowed-host`). */
allowedHost?: string[];
/** Keep the server running instead of idle-killing it (`--keep-alive`). */
keepAlive?: boolean;
/** Internal flag set by the daemon spawner (`kimi web`). */
daemon?: boolean;
/** Internal flag set by the daemon spawner / tests. */
idleGraceMs?: string;
}
export function parseServerOptions(opts: ServerCliOptions): ParsedServerOptions {
const host = parseHost(opts.host);
const allowedHosts = parseAllowedHostArgs(opts.allowedHost);
// `--keep-alive` is explicit, but also implied by a non-default bind
// (`--host`) or a proxy/tunnel setup (`--allowed-host`). Foreground mode is
// forced keep-alive later in `handleRunCommand`.
const keepAlive =
opts.keepAlive === true || host !== DEFAULT_SERVER_HOST || allowedHosts.length > 0;
return {
host,
port: parsePort(opts.port, '--port', DEFAULT_SERVER_PORT),
logLevel: parseLogLevel(opts.logLevel ?? DEFAULT_FOREGROUND_LOG_LEVEL),
debugEndpoints: opts.debugEndpoints === true,
insecureNoTls: opts.insecureNoTls !== false,
allowRemoteShutdown: opts.allowRemoteShutdown === true,
allowRemoteTerminals: opts.allowRemoteTerminals === true,
dangerousBypassAuth: opts.dangerousBypassAuth === true,
allowedHosts,
keepAlive,
daemon: opts.daemon === true,
idleGraceMs: parseIdleGraceMs(opts.idleGraceMs),
};
}
export function parseAllowedHostArgs(raw: readonly string[] | undefined): string[] {
if (raw === undefined) return [];
return raw
.flatMap((entry) => entry.split(','))
.map((entry) => entry.trim())
.filter((entry) => entry.length > 0);
}
function parseHost(raw: string | boolean | undefined): string {
if (raw === undefined || raw === false) return DEFAULT_SERVER_HOST;
if (raw === true || raw === '') return DEFAULT_LAN_HOST;
return raw;
}
function parseIdleGraceMs(raw: string | undefined): number {
if (raw === undefined) return DEFAULT_IDLE_GRACE_MS;
const n = Number.parseInt(raw, 10);
if (!Number.isFinite(n) || n < 0) {
throw new Error(`error: invalid --idle-grace-ms value: ${raw}`);
}
return n;
}
export function parsePort(raw: string | undefined, label: string, fallback: number): number {
if (raw === undefined) return fallback;
const n = Number.parseInt(raw, 10);
if (!Number.isFinite(n) || n < 0 || n > 65535) {
throw new Error(`error: invalid ${label} value: ${raw}`);
}
return n;
}
export function parseLogLevel(raw: string | undefined): ServerLogLevel {
if (raw === undefined) return DEFAULT_LOG_LEVEL;
if ((VALID_LOG_LEVELS as readonly string[]).includes(raw)) {
return raw as ServerLogLevel;
}
throw new Error(
`error: invalid --log-level value: ${raw} (allowed: ${VALID_LOG_LEVELS.join(', ')})`,
);
}
export function serverOrigin(host: string, port: number): string {
return `http://${host}:${port}`;
}
/** Strip `/api/v1` and trailing slashes so user-supplied origins are uniform. */
export function normalizeServerOrigin(value: string): string {
const url = new URL(value);
url.pathname = url.pathname.replace(/\/api\/v1\/?$/, '').replace(/\/$/, '');
url.search = '';
url.hash = '';
return url.toString().replace(/\/$/, '');
}
/** Single probe of `/api/v1/healthz`. Returns true if the response envelope reports `code: 0`. */
export async function isServerHealthy(origin: string, timeoutMs: number): Promise<boolean> {
const controller = new AbortController();
const timeout = setTimeout(() => {
controller.abort();
}, timeoutMs);
try {
const response = await fetch(`${origin}/api/v1/healthz`, {
signal: controller.signal,
});
if (!response.ok) return false;
const body = (await response.json()) as { code?: unknown };
return body.code === 0;
} catch {
return false;
} finally {
clearTimeout(timeout);
}
}
/** Poll `/api/v1/healthz` until it reports healthy or `timeoutMs` elapses. */
export async function waitForServerHealthy(origin: string, timeoutMs: number): Promise<boolean> {
const deadline = Date.now() + timeoutMs;
do {
if (await isServerHealthy(origin, 500)) {
return true;
}
await new Promise((resolve) => {
setTimeout(resolve, 200);
});
} while (Date.now() < deadline);
return false;
}
/**
* Probe `/` and confirm the bundled web UI is being served.
*
* A different build that runs on the same port serves its own bundle opening
* a browser at that origin lands on stale code. Catching that here lets the
* caller surface a clear "stop the running server" message instead of silently
* handing the user the wrong UI.
*/
export async function ensureServerWebReady(origin: string): Promise<void> {
const controller = new AbortController();
const timeout = setTimeout(() => {
controller.abort();
}, 3000);
try {
const response = await fetch(`${origin}/`, {
headers: { accept: 'text/html' },
signal: controller.signal,
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const body = await response.text();
if (!body.includes('<div id="app"')) {
throw new Error('missing app root');
}
} catch (error) {
const reason = error instanceof Error ? ` (${error.message})` : '';
throw new Error(
`Server at ${origin} does not serve the Kimi web UI${reason}. Stop the existing server and rerun \`kimi server run\`.`,
{ cause: error },
);
} finally {
clearTimeout(timeout);
}
}
/**
* Read the persistent bearer token for the server.
*
* The server writes `<homeDir>/server.token` (0600) on first boot and reuses
* it across restarts (ROADMAP M5.1); CLI commands that hit a gated REST route
* read it back here and send it as `Authorization: Bearer <token>`. `homeDir`
* is the CLI's own KIMI_CODE_HOME resolution (`getDataDir()`).
*
* Throws a clear error when the file is missing/unreadable the usual cause
* is a server that has never been started (no token file yet), or an older
* build that predates token auth.
*/
export function resolveServerToken(homeDir: string): string {
const tokenPath = join(homeDir, SERVER_TOKEN_FILE);
try {
return readFileSync(tokenPath, 'utf8').trim();
} catch (error) {
throw new Error(
`unable to read server token at ${tokenPath}; has the server been started at least once?`,
{ cause: error },
);
}
}
/** Best-effort token read: returns `undefined` instead of throwing. */
export function tryResolveServerToken(homeDir: string): string | undefined {
try {
return resolveServerToken(homeDir);
} catch {
return undefined;
}
}
/** An `Authorization: Bearer <token>` header bag for `fetch`. */
export function authHeaders(token: string): { Authorization: string } {
return { Authorization: `Bearer ${token}` };
}

View file

@ -1,22 +0,0 @@
/**
* `kimi web` open the Kimi web UI.
*
* Shares the exact same code path as `kimi server run`: it is registered via
* the same `buildRunCommand` builder (and therefore the same `handleRunCommand`
* handler, the same background-daemon flow, and the same ready banner) with
* `defaultOpen` flipped to `true`. The only difference from `server run` is
* that `web` opens the browser by default.
*/
import type { Command } from 'commander';
import { buildRunCommand } from './run';
export function registerWebAliasCommand(program: Command): void {
buildRunCommand(
program
.command('web')
.description('Open the Kimi web UI (starts a background daemon if needed).'),
{ defaultOpen: true },
);
}

View file

@ -1,158 +0,0 @@
/**
* `kimi vis` sub-command.
*
* CLI glue only: resolves the kimi home, starts the in-process session
* visualizer server (auto-picking a free port by default), prints the URL,
* optionally opens the browser (with an optional session deep-link), then
* waits for Ctrl-C and shuts the server down. The visualizer server itself
* lives in `@moonshot-ai/vis-server`.
*/
import type { Command } from 'commander';
import { createCliTelemetryBootstrap } from '#/cli/telemetry';
import { openUrl } from '#/utils/open-url';
interface WritableLike {
write(chunk: string): boolean;
}
export interface StartedVisServer {
readonly port: number;
readonly host: string;
readonly url: string;
readonly close: () => Promise<void>;
}
export interface StartVisServerArgs {
readonly homeDir: string;
readonly port: number;
readonly host?: string;
readonly webAsset?: { gzipped: Uint8Array };
}
export interface VisDeps {
readonly getHomeDir: () => string;
readonly startVisServer: (opts: StartVisServerArgs) => Promise<StartedVisServer>;
readonly openUrl: (url: string) => Promise<void>;
readonly waitForShutdown: () => Promise<void>;
readonly stdout: WritableLike;
readonly stderr: WritableLike;
readonly exit: (code: number) => never;
}
export interface VisOptions {
readonly open: boolean;
readonly port?: number;
readonly host?: string;
readonly sessionId?: string;
}
export async function handleVis(deps: VisDeps, opts: VisOptions): Promise<void> {
const homeDir = deps.getHomeDir();
// Lazily load the embedded single-file SPA so normal `kimi` startup never
// pays for it. The module is generated at build time (prebuild). When running
// from source without a build — e.g. tests — the generated value module is
// absent and the dynamic import throws; in that case the server falls back to
// its own static `public/` directory.
let webAsset: { gzipped: Uint8Array } | undefined;
try {
const { VIS_WEB_GZIP_B64 } = await import('#/generated/vis-web-asset');
if (VIS_WEB_GZIP_B64.length > 0) {
webAsset = { gzipped: new Uint8Array(Buffer.from(VIS_WEB_GZIP_B64, 'base64')) };
}
} catch {
// Embedded asset not generated in this context — fall back to filesystem.
}
let server: StartedVisServer;
try {
server = await deps.startVisServer({
homeDir,
port: opts.port ?? 0,
...(opts.host === undefined ? {} : { host: opts.host }),
...(webAsset === undefined ? {} : { webAsset }),
});
} catch (error) {
const msg = error instanceof Error ? error.message : String(error);
deps.stderr.write(`Failed to start kimi vis: ${msg}\n`);
return deps.exit(1);
}
const target =
opts.sessionId === undefined
? server.url
: `${server.url}sessions/${encodeURIComponent(opts.sessionId)}`;
deps.stdout.write(`kimi vis is running at ${server.url}\n`);
deps.stdout.write('Press Ctrl-C to stop.\n');
if (opts.open) {
try {
await deps.openUrl(target);
} catch {
deps.stderr.write(`Could not open a browser; visit ${target} manually.\n`);
}
}
await deps.waitForShutdown();
await server.close();
}
export function registerVisCommand(parent: Command, overrides?: Partial<VisDeps>): void {
parent
.command('vis')
.description('Launch the session visualizer in your browser.')
.option('--port <number>', 'Port to bind. Default: auto-pick a free port.')
.option('--host <host>', 'Host to bind. Default: 127.0.0.1.')
.option('--no-open', 'Do not open the browser automatically.')
.argument('[sessionId]', 'Open directly to this session.')
.action(
async (
sessionId: string | undefined,
options: { port?: string; host?: string; open?: boolean },
) => {
const port = options.port === undefined ? undefined : Number.parseInt(options.port, 10);
await handleVis(createDefaultVisDeps(overrides), {
open: options.open !== false,
...(port === undefined || Number.isNaN(port) ? {} : { port }),
...(options.host === undefined ? {} : { host: options.host }),
...(sessionId === undefined ? {} : { sessionId }),
});
},
);
}
function createDefaultVisDeps(overrides: Partial<VisDeps> = {}): VisDeps {
return {
getHomeDir: overrides.getHomeDir ?? (() => createCliTelemetryBootstrap().homeDir),
startVisServer:
overrides.startVisServer ??
(async (opts) => {
// Dynamic import keeps the vis server (and Hono) out of the hot path.
const { startVisServer } = await import('@moonshot-ai/vis-server/start');
return startVisServer(opts);
}),
// `openUrl` is a synchronous fire-and-forget; adapt it to the async dep.
openUrl:
overrides.openUrl ??
(async (url: string) => {
openUrl(url);
}),
waitForShutdown: overrides.waitForShutdown ?? waitForSigint,
stdout: overrides.stdout ?? process.stdout,
stderr: overrides.stderr ?? process.stderr,
exit: overrides.exit ?? ((code: number) => process.exit(code)),
};
}
function waitForSigint(): Promise<void> {
return new Promise<void>((resolve) => {
const onSig = (): void => {
process.off('SIGINT', onSig);
resolve();
};
process.on('SIGINT', onSig);
});
}

View file

@ -1,24 +1,8 @@
import { createKimiDeviceId, KIMI_CODE_PROVIDER_NAME } from '@moonshot-ai/kimi-code-oauth';
import {
KimiAuthFacade,
loadRuntimeConfigSafe,
resolveConfigPath,
resolveKimiHome,
type KimiConfig,
type TelemetryClient,
} from '@moonshot-ai/kimi-code-sdk';
import { initializeTelemetry } from '@moonshot-ai/kimi-telemetry';
import { resolveKimiHome, type KimiConfig, type KimiHarness } from '@moonshot-ai/kimi-code-sdk';
import type { PromptHarness } from './prompt-session';
import {
initializeTelemetry,
setTelemetryContext,
track,
withTelemetryContext,
} from '@moonshot-ai/kimi-telemetry';
import { CLI_USER_AGENT_PRODUCT, WEB_UI_MODE } from '#/constant/app';
import { createKimiCodeHostIdentity } from './version';
import { CLI_USER_AGENT_PRODUCT } from '#/constant/app';
export interface CliTelemetryBootstrap {
readonly homeDir: string;
@ -27,13 +11,12 @@ export interface CliTelemetryBootstrap {
}
export interface InitializeCliTelemetryOptions {
readonly harness: PromptHarness;
readonly harness: KimiHarness;
readonly bootstrap: CliTelemetryBootstrap;
readonly config: Pick<KimiConfig, 'defaultModel' | 'telemetry'>;
readonly version: string;
readonly uiMode: string;
readonly model?: string;
readonly sessionId?: string;
}
export function createCliTelemetryBootstrap(): CliTelemetryBootstrap {
@ -56,7 +39,6 @@ export function initializeCliTelemetry(options: InitializeCliTelemetryOptions):
version: options.version,
uiMode: options.uiMode,
model: options.model ?? options.config.defaultModel,
sessionId: options.sessionId,
getAccessToken: async () =>
(await options.harness.auth.getCachedAccessToken(KIMI_CODE_PROVIDER_NAME)) ?? null,
});
@ -64,67 +46,3 @@ export function initializeCliTelemetry(options: InitializeCliTelemetryOptions):
options.harness.track('first_launch');
}
}
export interface InitializeServerTelemetryOptions {
readonly version: string;
}
/**
* Bootstrap telemetry for the `kimi web` / `kimi server run` host.
*
* Mirrors {@link initializeCliTelemetry}: mints the device id, reads config to
* honor the `telemetry` toggle and pick up the default model, attaches the
* sink with `ui_mode = "web"`, and returns a {@link TelemetryClient} the
* caller hands to `startServer` via `coreProcessOptions.telemetry`. That wires
* the same real client into `KimiCore`, so agent-core events emitted inside the
* server process (`mcp_connected`, `session_load_failed`, plan-mode / cron
* events, ) actually leave the process carrying the enriched context
* (`app_name` / `version` / `ui_mode` / `model` / platform fields).
*
* The returned client wraps the `@moonshot-ai/kimi-telemetry` module
* functions, so the module-level `track` / `withTelemetryContext` (used to
* fire the startup event) share the same underlying client + sink.
*/
export function initializeServerTelemetry(
options: InitializeServerTelemetryOptions,
): TelemetryClient {
const bootstrap = createCliTelemetryBootstrap();
const configPath = resolveConfigPath({ homeDir: bootstrap.homeDir });
const config = readServerTelemetryConfig(configPath);
const auth = new KimiAuthFacade({
homeDir: bootstrap.homeDir,
configPath,
identity: createKimiCodeHostIdentity(options.version),
});
initializeTelemetry({
homeDir: bootstrap.homeDir,
deviceId: bootstrap.deviceId,
enabled: config.telemetry !== false,
appName: CLI_USER_AGENT_PRODUCT,
version: options.version,
uiMode: WEB_UI_MODE,
model: config.defaultModel,
getAccessToken: async () => (await auth.getCachedAccessToken(KIMI_CODE_PROVIDER_NAME)) ?? null,
});
return {
track,
withContext: withTelemetryContext,
setContext: setTelemetryContext,
};
}
function readServerTelemetryConfig(
configPath: string,
): Pick<KimiConfig, 'telemetry' | 'defaultModel'> {
try {
const { config, fileError } = loadRuntimeConfigSafe(configPath);
// A broken config fails the server on its own inside KimiCore; for
// telemetry just degrade to "enabled, no model" so we never block startup.
if (fileError !== undefined) return {};
return config;
} catch {
return {};
}
}

View file

@ -3,20 +3,13 @@ import { z } from 'zod';
import { getUpdateStateFile } from '#/utils/paths';
import { readJsonFile, writeJsonFile } from '#/utils/persistence';
import { UpdateManifestSchema } from './cdn';
import { emptyUpdateCache, type UpdateCache } from './types';
// Stays `.strict()` (we own this file), but a malformed manifest is treated
// as no manifest so one bad optional field does not discard the whole cache.
const UpdateCacheSchema = z
const UpdateCacheSchema: z.ZodType<UpdateCache> = z
.object({
source: z.literal('cdn'),
checkedAt: z.string().min(1).nullable(),
latest: z.string().min(1).nullable(),
manifest: z.preprocess((value) => {
const parsed = UpdateManifestSchema.nullable().safeParse(value === undefined ? null : value);
return parsed.success ? parsed.data : null;
}, z.union([UpdateManifestSchema, z.null()])),
})
.strict();

View file

@ -1,49 +1,6 @@
import { valid } from 'semver';
import { z } from 'zod';
import { KIMI_CODE_CDN_LATEST_JSON_URL, KIMI_CODE_CDN_LATEST_URL } from '#/constant/app';
import type { UpdateManifest } from './types';
const CDN_FETCH_TIMEOUT_MS = 3_000;
const RolloutBatchSchema = z.object({
percent: z.number().int().min(0).max(100),
delaySeconds: z.number().int().min(0),
});
/**
* CDN `latest.json` wire format. Deliberately NOT `.strict()` unknown
* fields are ignored so future manifest additions never break shipped
* clients (the plain-text `/latest` taught us that hard-failing on
* unexpected content bricks the update path forever).
*/
export const UpdateManifestSchema = z.object({
version: z.string().refine((value) => valid(value) !== null, { error: 'invalid semver' }),
publishedAt: z
.string()
.refine((value) => Number.isFinite(Date.parse(value)), { error: 'invalid timestamp' }),
rollout: z.array(RolloutBatchSchema).readonly().default([]),
});
export interface FetchLatestResult {
/** Raw newest version — what `kimi upgrade` installs, never rollout-gated. */
readonly latest: string;
/** Null when the JSON manifest was unavailable and we fell back to plain text. */
readonly manifest: UpdateManifest | null;
}
async function fetchWithTimeout(fetchImpl: typeof fetch, input: string): Promise<Response> {
const controller = new AbortController();
const timeout = setTimeout(() => {
controller.abort();
}, CDN_FETCH_TIMEOUT_MS);
try {
return await fetchImpl(input, { signal: controller.signal });
} finally {
clearTimeout(timeout);
}
}
import { KIMI_CODE_CDN_LATEST_URL } from '#/constant/app';
/**
* Fetch the latest published Kimi Code version from the CDN.
@ -58,7 +15,7 @@ async function fetchWithTimeout(fetchImpl: typeof fetch, input: string): Promise
export async function fetchLatestVersionFromCdn(
fetchImpl: typeof fetch = fetch,
): Promise<string> {
const response = await fetchWithTimeout(fetchImpl, KIMI_CODE_CDN_LATEST_URL);
const response = await fetchImpl(KIMI_CODE_CDN_LATEST_URL);
if (!response.ok) {
throw new Error(`CDN /latest returned HTTP ${response.status}`);
}
@ -68,30 +25,3 @@ export async function fetchLatestVersionFromCdn(
}
return raw;
}
async function fetchUpdateManifestFromCdn(fetchImpl: typeof fetch): Promise<UpdateManifest> {
const response = await fetchWithTimeout(fetchImpl, KIMI_CODE_CDN_LATEST_JSON_URL);
if (!response.ok) {
throw new Error(`CDN /latest.json returned HTTP ${response.status}`);
}
return UpdateManifestSchema.parse(JSON.parse(await response.text()));
}
/**
* Fetch the rollout manifest, falling back to the plain-text `/latest` when
* `latest.json` is unavailable or malformed. The fallback removes any
* deployment-order coupling between client releases and the CDN file, and a
* null manifest means "fully rolled out" exactly the pre-rollout behavior.
*
* **Throws** only when both sources fail; callers must catch (see above).
*/
export async function fetchLatestFromCdn(
fetchImpl: typeof fetch = fetch,
): Promise<FetchLatestResult> {
const manifest = await fetchUpdateManifestFromCdn(fetchImpl).catch(() => null);
if (manifest !== null) {
return { latest: manifest.version, manifest };
}
const latest = await fetchLatestVersionFromCdn(fetchImpl);
return { latest, manifest: null };
}

View file

@ -19,22 +19,13 @@ import {
type InstallPromptOptions,
} from './prompt';
import { refreshUpdateCache } from './refresh';
import {
appendRolloutDecisionLog,
decidePassiveUpdateTarget,
isRolloutBypassedByExperimentalEnv,
resolveUpdateDeviceId,
rolloutBucket,
rolloutDelayForBucket,
type PassiveUpdateDecision,
} from './rollout';
import { selectUpdateTarget } from './select';
import { detectInstallSource } from './source';
import {
NPM_PACKAGE_NAME,
type InstallSource,
type UpdateDecision,
type UpdateInstallState,
type UpdateManifest,
type UpdatePreflightResult,
type UpdateTarget,
} from './types';
@ -186,59 +177,8 @@ function refreshInBackground(): void {
void refreshUpdateCache().catch(() => {});
}
/** Telemetry properties describing where this device sits in the rollout. */
interface RolloutTelemetry {
readonly rollout_bucket: number;
readonly rollout_delay_seconds: number;
readonly rollout_from_manifest: boolean;
readonly rollout_bypassed: boolean;
}
function rolloutTelemetryFor(
deviceId: string,
targetVersion: string,
manifest: UpdateManifest | null,
bypassRollout: boolean,
): RolloutTelemetry {
const bucket = rolloutBucket(deviceId, targetVersion);
return {
rollout_bucket: bucket,
rollout_delay_seconds:
manifest === null || bypassRollout ? 0 : rolloutDelayForBucket(manifest.rollout, bucket),
rollout_from_manifest: manifest !== null,
rollout_bypassed: bypassRollout,
};
}
type RolloutCheckPhase = 'startup-cache' | 'background-refresh' | 'prompt-refresh';
/** Record which case a passive version check hit in `updates/rollout.log`. */
function logRolloutDecision(
phase: RolloutCheckPhase,
currentVersion: string,
latest: string | null,
manifest: UpdateManifest | null,
decision: PassiveUpdateDecision,
): void {
void appendRolloutDecisionLog({
ts: nowIso(),
phase,
reason: decision.reason,
current: currentVersion,
latest,
target: decision.target?.version ?? null,
manifestPresent: manifest !== null,
publishedAt: manifest?.publishedAt ?? null,
bucket: decision.bucket,
delaySeconds: decision.delaySeconds,
eligibleAt: decision.eligibleAt,
});
}
function refreshAndMaybeInstallInBackground(
currentVersion: string,
deviceId: string,
bypassRollout: boolean,
isInteractive: boolean,
installState: UpdateInstallState,
platform: NodeJS.Platform,
@ -248,16 +188,7 @@ function refreshAndMaybeInstallInBackground(
void (async () => {
const refreshed = await refreshUpdateCache();
if (!isInteractive) return;
const decision = decidePassiveUpdateTarget(
currentVersion,
refreshed.latest,
refreshed.manifest,
deviceId,
new Date(),
bypassRollout,
);
logRolloutDecision('background-refresh', currentVersion, refreshed.latest, refreshed.manifest, decision);
const target = decision.target;
const target = selectUpdateTarget(currentVersion, refreshed.latest);
if (target === null) return;
const source = await detectInstallSource().catch(() => 'unsupported' as const);
await tryStartAutomaticBackgroundInstall(
@ -268,54 +199,27 @@ function refreshAndMaybeInstallInBackground(
platform,
track,
logger,
rolloutTelemetryFor(deviceId, target.version, refreshed.manifest, bypassRollout),
);
})().catch(() => {});
}
interface UserVisibleUpdateTarget {
readonly target: UpdateTarget | null;
readonly manifest: UpdateManifest | null;
}
async function refreshUserVisibleUpdateTarget(
currentVersion: string,
deviceId: string,
bypassRollout: boolean,
fallbackTarget: UpdateTarget,
fallbackManifest: UpdateManifest | null,
): Promise<UserVisibleUpdateTarget> {
): Promise<UpdateTarget | null> {
let timeout: ReturnType<typeof setTimeout> | undefined;
const fallback: UserVisibleUpdateTarget = {
target: fallbackTarget,
manifest: fallbackManifest,
};
try {
const refresh = refreshUpdateCache()
.then((refreshed) => {
const decision = decidePassiveUpdateTarget(
currentVersion,
refreshed.latest,
refreshed.manifest,
deviceId,
new Date(),
bypassRollout,
);
logRolloutDecision('prompt-refresh', currentVersion, refreshed.latest, refreshed.manifest, decision);
return {
target: decision.target,
manifest: refreshed.manifest,
};
})
.catch(() => fallback);
const timeoutFallback = new Promise<UserVisibleUpdateTarget>((resolve) => {
.then((refreshed) => selectUpdateTarget(currentVersion, refreshed.latest))
.catch(() => fallbackTarget);
const fallback = new Promise<UpdateTarget>((resolve) => {
timeout = setTimeout(() => {
resolve(fallback);
resolve(fallbackTarget);
}, USER_VISIBLE_UPDATE_REFRESH_TIMEOUT_MS);
});
return await Promise.race([refresh, timeoutFallback]);
return await Promise.race([refresh, fallback]);
} catch {
return fallback;
return fallbackTarget;
} finally {
if (timeout !== undefined) {
clearTimeout(timeout);
@ -427,14 +331,14 @@ function trackUpdatePrompted(
target: UpdateTarget,
source: InstallSource,
decision: UpdateDecision,
rolloutTelemetry: RolloutTelemetry,
): void {
trackUpdateEvent(track, 'update_prompted', {
current: currentVersion,
latest: target.version,
current_version: currentVersion,
target_version: target.version,
source,
decision,
...rolloutTelemetry,
});
}
@ -488,14 +392,7 @@ export async function installUpdate(
): Promise<void> {
const { cmd, args } = spawnForSource(source, version, platform);
await new Promise<void>((resolve, reject) => {
// Windows package managers (npm/pnpm/yarn) are .cmd shims. Since the
// CVE-2024-27980 fix, Node throws EINVAL when spawning a .cmd/.bat without
// a shell, so run through the shell on win32. The version is a validated
// semver and the package name is a constant, so args are shell-safe.
const child = spawn(cmd, [...args], {
stdio: 'inherit',
shell: platform === 'win32' ? true : undefined,
});
const child = spawn(cmd, [...args], { stdio: 'inherit' });
child.once('error', reject);
child.once('exit', (code, signal) => {
if (code === 0) {
@ -516,7 +413,6 @@ async function startBackgroundInstall(
platform: NodeJS.Platform,
track: RunUpdatePreflightOptions['track'],
logger: UpdateLogger,
rolloutTelemetry: RolloutTelemetry,
): Promise<void> {
const lock = await tryAcquireUpdateInstallLock({ version: target.version });
if (lock === null) return;
@ -543,7 +439,6 @@ async function startBackgroundInstall(
current_version: currentVersion,
target_version: target.version,
source,
...rolloutTelemetry,
});
logUpdateInfo(logger, 'background update install started', {
currentVersion,
@ -603,15 +498,7 @@ async function startBackgroundInstall(
});
};
const child = spawn(cmd, [...args], {
detached: true,
stdio: 'ignore',
shell: platform === 'win32' ? true : undefined,
// On Windows a detached child gets its own console window; with shell:true
// that window would flash during a passive background update. Hide it so
// the silent updater stays silent.
windowsHide: platform === 'win32' ? true : undefined,
});
const child = spawn(cmd, [...args], { detached: true, stdio: 'ignore' });
child.once('error', () => { finish(false); });
child.once('exit', (code) => { finish(code === 0); });
child.unref();
@ -628,7 +515,6 @@ async function tryStartAutomaticBackgroundInstall(
platform: NodeJS.Platform,
track: RunUpdatePreflightOptions['track'],
logger: UpdateLogger,
rolloutTelemetry: RolloutTelemetry,
): Promise<boolean> {
const sourceCanAutoInstall = canAutoInstall(source, platform);
const autoInstallUpdates = sourceCanAutoInstall ? await shouldAutoInstallUpdates() : false;
@ -645,7 +531,6 @@ async function tryStartAutomaticBackgroundInstall(
platform,
track,
logger,
rolloutTelemetry,
).catch(() => {});
}
return true;
@ -677,8 +562,6 @@ export async function runUpdatePreflight(
try {
const isInteractive =
options.isTTY ?? (process.stdin.isTTY && process.stdout.isTTY);
const deviceId = resolveUpdateDeviceId();
const bypassRollout = isRolloutBypassedByExperimentalEnv();
let installState = await readUpdateInstallState().catch(() => emptyUpdateInstallState());
if (isInteractive) {
installState = await showPendingBackgroundInstallNotice(
@ -691,22 +574,11 @@ export async function runUpdatePreflight(
}
const cache = await readUpdateCache().catch(() => null);
const cachedManifest = cache?.manifest ?? null;
const cachedDecision = decidePassiveUpdateTarget(
currentVersion,
cache?.latest ?? null,
cachedManifest,
deviceId,
new Date(),
bypassRollout,
);
logRolloutDecision('startup-cache', currentVersion, cache?.latest ?? null, cachedManifest, cachedDecision);
const target = cachedDecision.target;
const latest = cache?.latest ?? null;
const target = selectUpdateTarget(currentVersion, latest);
if (target === null) {
refreshAndMaybeInstallInBackground(
currentVersion,
deviceId,
bypassRollout,
isInteractive,
installState,
platform,
@ -736,28 +608,14 @@ export async function runUpdatePreflight(
platform,
options.track,
logger,
rolloutTelemetryFor(deviceId, target.version, cachedManifest, bypassRollout),
)
) {
refreshInBackground();
return 'continue';
}
const userVisibleUpdate = await refreshUserVisibleUpdateTarget(
currentVersion,
deviceId,
bypassRollout,
target,
cachedManifest,
);
const userVisibleTarget = userVisibleUpdate.target;
const userVisibleTarget = await refreshUserVisibleUpdateTarget(currentVersion, target);
if (userVisibleTarget === null) return 'continue';
const userVisibleRollout = rolloutTelemetryFor(
deviceId,
userVisibleTarget.version,
userVisibleUpdate.manifest,
bypassRollout,
);
if (
await tryStartAutomaticBackgroundInstall(
installState,
@ -767,14 +625,13 @@ export async function runUpdatePreflight(
platform,
options.track,
logger,
userVisibleRollout,
)
) {
return 'continue';
}
const installCommand = installCommandFor(source, userVisibleTarget.version, platform);
trackUpdatePrompted(options.track, currentVersion, userVisibleTarget, source, decision, userVisibleRollout);
trackUpdatePrompted(options.track, currentVersion, userVisibleTarget, source, decision);
if (decision === 'manual-command') {
stdout.write(renderManualUpdateMessage(

View file

@ -1,14 +1,13 @@
import { writeUpdateCache } from './cache';
import { fetchLatestFromCdn, type FetchLatestResult } from './cdn';
import { fetchLatestVersionFromCdn } from './cdn';
import { type UpdateCache } from './types';
export interface RefreshUpdateCacheDeps {
/** Resolves with the latest version + rollout manifest. **Throws** on any
* failure callers (including the default background invocation in
* preflight) must catch. Errors intentionally skip `writeCache` so a
* transient CDN blip does not overwrite a previously known `latest` with
* `null`. */
readonly fetchLatest: () => Promise<FetchLatestResult>;
/** Resolves with the latest semver. **Throws** on any failure callers
* (including the default background invocation in preflight) must catch.
* Errors intentionally skip `writeCache` so a transient CDN blip does not
* overwrite a previously known `latest` with `null`. */
readonly fetchLatest: () => Promise<string>;
readonly writeCache: (cache: UpdateCache) => Promise<void>;
readonly now: () => Date;
}
@ -17,17 +16,16 @@ export async function refreshUpdateCache(
overrides: Partial<RefreshUpdateCacheDeps> = {},
): Promise<UpdateCache> {
const resolved: RefreshUpdateCacheDeps = {
fetchLatest: overrides.fetchLatest ?? (() => fetchLatestFromCdn()),
fetchLatest: overrides.fetchLatest ?? (() => fetchLatestVersionFromCdn()),
writeCache: overrides.writeCache ?? writeUpdateCache,
now: overrides.now ?? (() => new Date()),
};
const { latest, manifest } = await resolved.fetchLatest();
const latest = await resolved.fetchLatest();
const cache: UpdateCache = {
source: 'cdn',
checkedAt: resolved.now().toISOString(),
latest,
manifest,
};
await resolved.writeCache(cache);
return cache;

View file

@ -1,210 +0,0 @@
import { createHash, randomUUID } from 'node:crypto';
import { appendFile, mkdir, stat, writeFile } from 'node:fs/promises';
import { dirname } from 'node:path';
import { readKimiDeviceId } from '@moonshot-ai/kimi-code-oauth';
import { resolveKimiHome } from '@moonshot-ai/kimi-code-sdk';
import { getUpdateRolloutLogFile } from '#/utils/paths';
import { selectUpdateTarget } from './select';
import type { RolloutBatch, UpdateManifest, UpdateTarget } from './types';
/**
* Hard ceiling for any rollout delay. Combined with the uncovered-bucket
* fallback below, it guarantees every device sees a release no later than
* `publishedAt + 24h`, no matter what the published plan says.
*/
export const MAX_ROLLOUT_DELAY_SECONDS = 86_400;
/**
* Deterministic 0-99 bucket for a device. The version is mixed into the hash
* so each release reshuffles which devices land in the early batches.
*/
export function rolloutBucket(deviceId: string, version: string): number {
const digest = createHash('sha256').update(`${deviceId}:${version}`, 'utf-8').digest();
return digest.readUInt32BE(0) % 100;
}
/**
* Delay assigned to a bucket by the published plan. Batches claim bucket
* ranges in array order; buckets left uncovered (percents summing under 100)
* fall into the slowest cohort, and oversized delays are clamped to 24h.
*/
export function rolloutDelayForBucket(rollout: readonly RolloutBatch[], bucket: number): number {
let cumulative = 0;
for (const batch of rollout) {
cumulative += batch.percent;
if (bucket < cumulative) {
return Math.min(Math.max(batch.delaySeconds, 0), MAX_ROLLOUT_DELAY_SECONDS);
}
}
if (rollout.length === 0) return 0;
return MAX_ROLLOUT_DELAY_SECONDS;
}
export function rolloutDelaySeconds(manifest: UpdateManifest, deviceId: string): number {
return rolloutDelayForBucket(manifest.rollout, rolloutBucket(deviceId, manifest.version));
}
export function isRolloutEligible(
manifest: UpdateManifest,
deviceId: string,
now: Date,
): boolean {
const publishedAt = Date.parse(manifest.publishedAt);
// Schema validation rejects unparseable timestamps before they get here;
// fail open defensively so a defect can never block updates indefinitely.
if (!Number.isFinite(publishedAt)) return true;
const delayMs = rolloutDelaySeconds(manifest, deviceId) * 1000;
return now.getTime() >= publishedAt + delayMs;
}
/** Which case a passive update check hit; written to the rollout log. */
export type PassiveUpdateReason =
/** Nothing known yet (no cache / CDN never reached). */
| 'no-latest'
/** Known version is not an upgrade over the running one. */
| 'not-newer'
/** Plain-text fallback or legacy cache: visible immediately, no gating. */
| 'no-manifest'
/** Gated: this device's batch delay has not elapsed yet. */
| 'held'
/** Gated and the batch delay has elapsed: update is visible. */
| 'eligible'
/** KIMI_CODE_EXPERIMENTAL_FLAG is on: rollout skipped, newest always visible. */
| 'experimental';
export interface PassiveUpdateDecision {
readonly target: UpdateTarget | null;
readonly reason: PassiveUpdateReason;
readonly bucket: number | null;
readonly delaySeconds: number | null;
readonly eligibleAt: string | null;
}
/**
* Update decision for the passive surfaces (background install, startup
* prompt, manual-command notice). Devices whose batch is not yet eligible see
* no update at all. A null manifest (plain-text fallback or legacy cache)
* keeps the pre-rollout behavior: the latest version is visible immediately.
*
* `kimi upgrade` must NOT go through this gate it selects directly from the
* raw latest version.
*/
export function decidePassiveUpdateTarget(
currentVersion: string,
latest: string | null,
manifest: UpdateManifest | null,
deviceId: string,
now: Date,
bypassRollout = false,
): PassiveUpdateDecision {
if (bypassRollout) {
if (latest === null) {
return { target: null, reason: 'no-latest', bucket: null, delaySeconds: null, eligibleAt: null };
}
const target = selectUpdateTarget(currentVersion, latest);
return {
target,
reason: target === null ? 'not-newer' : 'experimental',
bucket: null,
delaySeconds: null,
eligibleAt: null,
};
}
if (manifest === null) {
if (latest === null) {
return { target: null, reason: 'no-latest', bucket: null, delaySeconds: null, eligibleAt: null };
}
const target = selectUpdateTarget(currentVersion, latest);
return {
target,
reason: target === null ? 'not-newer' : 'no-manifest',
bucket: null,
delaySeconds: null,
eligibleAt: null,
};
}
const target = selectUpdateTarget(currentVersion, manifest.version);
if (target === null) {
return { target: null, reason: 'not-newer', bucket: null, delaySeconds: null, eligibleAt: null };
}
const bucket = rolloutBucket(deviceId, manifest.version);
const delaySeconds = rolloutDelayForBucket(manifest.rollout, bucket);
const publishedAt = Date.parse(manifest.publishedAt);
const eligibleAt = Number.isFinite(publishedAt)
? new Date(publishedAt + delaySeconds * 1000).toISOString()
: null;
const eligible = isRolloutEligible(manifest, deviceId, now);
return {
target: eligible ? target : null,
reason: eligible ? 'eligible' : 'held',
bucket,
delaySeconds,
eligibleAt,
};
}
export function selectPassiveUpdateTarget(
currentVersion: string,
latest: string | null,
manifest: UpdateManifest | null,
deviceId: string,
now: Date,
): UpdateTarget | null {
return decidePassiveUpdateTarget(currentVersion, latest, manifest, deviceId, now).target;
}
const ROLLOUT_LOG_MAX_BYTES = 256 * 1024;
/**
* Append one JSON line describing a passive update decision to
* `<dataDir>/updates/rollout.log`. Best-effort diagnostics: any I/O failure
* is swallowed logging must never affect update prompting. The file is
* reset once it grows past a small cap so it cannot grow unbounded.
*/
export async function appendRolloutDecisionLog(
entry: Record<string, unknown>,
filePath: string = getUpdateRolloutLogFile(),
): Promise<void> {
try {
await mkdir(dirname(filePath), { recursive: true });
const line = `${JSON.stringify(entry)}\n`;
const size = await stat(filePath).then((s) => s.size, () => 0);
if (size > ROLLOUT_LOG_MAX_BYTES) {
await writeFile(filePath, line, 'utf-8');
return;
}
await appendFile(filePath, line, 'utf-8');
} catch {
// Diagnostic logging must never affect the update flow.
}
}
/**
* Stable per-installation id used for bucketing when telemetry has already
* minted one. Missing ids stay ephemeral here so update preflight never
* creates the telemetry device_id before telemetry can emit first_launch.
*/
export function resolveUpdateDeviceId(): string {
return readKimiDeviceId(resolveKimiHome()) ?? randomUUID();
}
/**
* The experimental master switch opts a device out of staged rollouts: the
* newest version is always visible to the passive update surfaces, exactly as
* if every release were fully rolled out. Read directly from the env (same
* truthy values as `KIMI_CODE_NO_AUTO_UPDATE`) the update preflight runs
* before the harness exists, so the core flag registry is not consulted.
* `KIMI_CODE_NO_AUTO_UPDATE` still wins: disabling updates beats opting in.
*/
export function isRolloutBypassedByExperimentalEnv(
env: Readonly<Record<string, string | undefined>> = process.env,
): boolean {
const value = (env['KIMI_CODE_EXPERIMENTAL_FLAG'] ?? '').trim().toLowerCase();
return ['1', 'true', 'yes', 'on'].includes(value);
}

View file

@ -16,28 +16,10 @@ export interface UpdateTarget {
readonly version: string;
}
/** One gradual-rollout cohort: `percent` of devices delayed by `delaySeconds`. */
export interface RolloutBatch {
readonly percent: number;
readonly delaySeconds: number;
}
/**
* Parsed CDN `latest.json`. `rollout` batches claim bucket ranges in array
* order; an empty array means the release is fully rolled out immediately.
*/
export interface UpdateManifest {
readonly version: string;
readonly publishedAt: string;
readonly rollout: readonly RolloutBatch[];
}
export interface UpdateCache {
readonly source: 'cdn';
readonly checkedAt: string | null;
readonly latest: string | null;
/** Null when the manifest came from the plain-text fallback or a legacy cache file. */
readonly manifest: UpdateManifest | null;
}
export interface UpdateInstallActive {
@ -72,7 +54,6 @@ export function emptyUpdateCache(): UpdateCache {
source: 'cdn',
checkedAt: null,
latest: null,
manifest: null,
};
}

View file

@ -1,728 +0,0 @@
/**
* Native v2 `kimi -p` (print mode) runner.
*
* Unlike the v1 path (and the former `V2PromptHarness` / `V2Session` shim), this
* runner talks to agent-core-v2's native DI services directly no
* `PromptHarness`, no SDK-shaped session, no v2v1 event translation. It:
* - `bootstrap()`s the app scope,
* - creates / resumes a session and its main agent via native services,
* - subscribes to the main agent's per-agent `IEventBus` and renders the
* native `DomainEvent` stream (payloads are already v1-protocol-shaped),
* - drives a turn through `IAgentPromptService.enqueue()` and awaits
* `Turn.result` for authoritative completion,
* - applies the print-mode background policy (config-driven, v1-aligned:
* `exit` / `drain` / `steer`) before exiting.
*
* Selected by `runPrompt` when `KIMI_CODE_EXPERIMENTAL_FLAG` is set.
*/
import {
IAgentGoalService,
IAgentLifecycleService,
IAgentPermissionModeService,
IAgentProfileService,
IAgentPromptService,
IAgentTaskService,
IAuthSummaryService,
IConfigService,
IEventBus,
IOAuthToolkit,
ISessionIndex,
ISessionLifecycleService,
ITelemetryService,
bootstrap,
createCloudAppender,
ensureMainAgent,
hostRequestHeadersSeed,
logSeed,
resolveAgentTaskConfig,
resolveKimiHome,
resolveLoggingConfig,
resolvePrintBackgroundMode,
skillCatalogRuntimeOptionsSeed,
type DomainEvent,
type IAgentScopeHandle,
type ISessionScopeHandle,
type LoopRunResult,
type PrintBackgroundMode,
type Scope,
} from '@moonshot-ai/agent-core-v2';
import { createKimiDefaultHeaders, createKimiDeviceId } from '@moonshot-ai/kimi-code-oauth';
import { resolve } from 'pathe';
import {
CLI_SHUTDOWN_TIMEOUT_MS,
CLI_USER_AGENT_PRODUCT,
PROMPT_CLEANUP_TIMEOUT_MS,
} from '#/constant/app';
import {
formatGoalSummaryText,
goalExitCode,
goalSummaryJson,
parseHeadlessGoalCreate,
type HeadlessGoalCreate,
} from '../goal-prompt';
import {
type PromptRunIO,
configuredModel,
installPromptTerminationCleanup,
raceWithTimeout,
requireConfiguredModel,
} from '../run-prompt';
import { createKimiCodeHostIdentity } from '../version';
import { resolveOutputFormat } from '../options';
import type { CLIOptions, PromptOutputFormat } from '../options';
import {
type PromptOutput,
PromptJsonWriter,
type PromptTurnWriter,
PromptTranscriptWriter,
writeExperimentalVersion,
writeResumeHint,
} from '../prompt-render';
const PROMPT_UI_MODE = 'print';
const DEFAULT_PRINT_WAIT_CEILING_S = 3600;
const DEFAULT_PRINT_MAX_TURNS = 50;
/** Re-check `goalActive` at least this often while waiting for goal turns. */
const GOAL_WAIT_POLL_MS = 250;
export async function runV2Print(
opts: CLIOptions,
version: string,
io: PromptRunIO = {},
): Promise<void> {
const startedAt = Date.now();
const stdout = io.stdout ?? process.stdout;
const stderr = io.stderr ?? process.stderr;
const promptProcess = io.process ?? process;
const outputFormat = resolveOutputFormat(opts);
const workDir = process.cwd();
writeExperimentalVersion(version, outputFormat, stdout, stderr);
const homeDir = resolveKimiHome();
let firstLaunch = false;
const deviceId = createKimiDeviceId(homeDir, {
onFirstLaunch: () => {
firstLaunch = true;
},
});
const logging = resolveLoggingConfig({ homeDir, env: process.env });
const identity = createKimiCodeHostIdentity(version);
const hostHeaders = createKimiDefaultHeaders({ homeDir, ...identity });
const { app } = bootstrap({ homeDir, clientVersion: version }, [
...logSeed(logging),
...hostRequestHeadersSeed(hostHeaders),
// `--skillsDir` (v1 print parity): explicit skill dirs replace default
// user / project discovery for this process.
...skillCatalogRuntimeOptionsSeed(opts.skillsDirs),
]);
const auth = app.accessor.get(IOAuthToolkit);
const configService = app.accessor.get(IConfigService);
await configService.ready;
const defaultModel = configService.get<string>('defaultModel') ?? undefined;
let telemetryEnabled = true;
try {
telemetryEnabled = configService.get('telemetry') !== false;
} catch {
telemetryEnabled = true;
}
for (const diagnostic of configService.diagnostics()) {
if (diagnostic.severity === 'warning') {
stderr.write(`Warning: ${diagnostic.message}\n`);
}
}
let restorePermission = async (): Promise<void> => {};
let removeTerminationCleanup: (() => void) | undefined;
let cleanupPromise: Promise<void> | undefined;
let telemetryService: ITelemetryService | undefined;
const cleanup = async (): Promise<void> => {
const pending = (cleanupPromise ??= (async () => {
removeTerminationCleanup?.();
try {
await restorePermission();
} finally {
if (telemetryService !== undefined) {
await raceWithTimeout(telemetryService.shutdown(), CLI_SHUTDOWN_TIMEOUT_MS);
}
app.dispose();
}
})());
await raceWithTimeout(pending, PROMPT_CLEANUP_TIMEOUT_MS);
};
removeTerminationCleanup = installPromptTerminationCleanup(promptProcess, cleanup);
try {
// Install the appender BEFORE resolving the session: `session_started` and
// `session_load_failed` fire inside create()/resume(), so an appender wired
// up only after resolveNativeSession() would drop them to the null appender.
// The model below is the best known up front; a resumed session's real
// model is reconciled via setContext once resolved.
telemetryService = app.accessor.get(ITelemetryService);
if (telemetryEnabled) {
telemetryService.setAppender(
createCloudAppender(app.accessor, {
deviceId,
appName: CLI_USER_AGENT_PRODUCT,
uiMode: PROMPT_UI_MODE,
model: opts.model ?? defaultModel,
getAccessToken: async () => (await auth.getCachedAccessToken()) ?? null,
}),
);
}
const resolved = await resolveNativeSession(app, opts, workDir, defaultModel, stderr);
restorePermission = resolved.restorePermission;
telemetryService.setContext({ sessionId: resolved.session.id, model: resolved.telemetryModel });
if (firstLaunch) {
telemetryService.track2('first_launch');
}
const goalCreate = parseHeadlessGoalCreate(opts.prompt!);
if (goalCreate !== undefined) {
await runNativeGoal(
app,
resolved.session,
resolved.agent,
goalCreate,
resolved.goalModel,
outputFormat,
stdout,
stderr,
);
} else {
await runNativeTurn(
app,
resolved.session,
resolved.agent,
opts.prompt!,
outputFormat,
stdout,
stderr,
);
}
writeResumeHint(resolved.session.id, outputFormat, stdout, stderr);
telemetryService.withContext({ sessionId: resolved.session.id }).track2('exit', {
duration_ms: Date.now() - startedAt,
});
} finally {
await cleanup();
}
}
interface ResolvedNativeSession {
readonly session: ISessionScopeHandle;
readonly agent: IAgentScopeHandle;
readonly restorePermission: () => Promise<void>;
readonly telemetryModel: string | undefined;
readonly goalModel: string | undefined;
}
async function resolveNativeSession(
app: Scope,
opts: CLIOptions,
workDir: string,
defaultModel: string | undefined,
stderr: PromptOutput,
): Promise<ResolvedNativeSession> {
const lifecycle = app.accessor.get(ISessionLifecycleService);
const index = app.accessor.get(ISessionIndex);
const resumeById = async (id: string): Promise<ISessionScopeHandle> => {
const session = await lifecycle.resume(id);
if (session === undefined) {
throw new Error(`Session "${id}" not found.`);
}
return session;
};
const forceAuto = (
agent: IAgentScopeHandle,
): { readonly restorePermission: () => Promise<void> } => {
const permissionMode = agent.accessor.get(IAgentPermissionModeService);
const previous = permissionMode.mode;
permissionMode.setMode('auto');
return {
restorePermission: async () => {
permissionMode.setMode(previous);
},
};
};
if (opts.session !== undefined) {
const page = await index.list({});
const target = page.items.find((summary) => summary.id === opts.session);
if (target === undefined) {
throw new Error(`Session "${opts.session}" not found.`);
}
if (target.cwd !== undefined && resolve(target.cwd) !== resolve(workDir)) {
stderr.write(
`Session "${opts.session}" was created under a different directory.\n` +
` cd "${target.cwd}" && kimi -r ${opts.session}\n\n`,
);
throw new Error(`Session "${opts.session}" was created under a different directory.`);
}
const session = await resumeById(opts.session);
const agent = await ensureMainAgent(session);
const profile = agent.accessor.get(IAgentProfileService);
if (opts.model !== undefined) {
await profile.setModel(opts.model);
}
const currentModel = profile.getModel();
const { restorePermission } = forceAuto(agent);
return {
session,
agent,
restorePermission,
telemetryModel: configuredModel(opts.model, currentModel, defaultModel),
goalModel: configuredModel(opts.model, currentModel),
};
}
if (opts.continue) {
const page = await index.list({});
const previous = page.items.find((summary) => summary.cwd === workDir);
if (previous !== undefined) {
const session = await resumeById(previous.id);
const agent = await ensureMainAgent(session);
const profile = agent.accessor.get(IAgentProfileService);
if (opts.model !== undefined) {
await profile.setModel(opts.model);
}
const currentModel = profile.getModel();
const { restorePermission } = forceAuto(agent);
return {
session,
agent,
restorePermission,
telemetryModel: configuredModel(opts.model, currentModel, defaultModel),
goalModel: configuredModel(opts.model, currentModel),
};
}
stderr.write(`No sessions to continue under "${workDir}"; starting a fresh session.\n`);
}
const model = requireConfiguredModel(opts.model, defaultModel);
const session = await lifecycle.create({
workDir,
additionalDirs: opts.addDirs?.length ? opts.addDirs : undefined,
});
const agent = await ensureMainAgent(session);
await agent.accessor.get(IAgentProfileService).setModel(model);
agent.accessor.get(IAgentPermissionModeService).setMode('auto');
return {
session,
agent,
restorePermission: async () => {},
telemetryModel: model,
goalModel: model,
};
}
async function runNativeTurn(
app: Scope,
session: ISessionScopeHandle,
agent: IAgentScopeHandle,
prompt: string,
outputFormat: PromptOutputFormat,
stdout: PromptOutput,
stderr: PromptOutput,
): Promise<void> {
const writer: PromptTurnWriter =
outputFormat === 'stream-json'
? new PromptJsonWriter(stdout)
: new PromptTranscriptWriter(stdout, stderr);
await agent.accessor.get(IAuthSummaryService).ensureReady();
const turnEndings = createPrintTurnEndings();
const subscription = agent.accessor.get(IEventBus).subscribe((event: DomainEvent) => {
dispatchNativeEvent(writer, event, stderr);
// Arm the turn-endings collector before `turn.result` settles so a
// background-task completion that steers a new turn right after the main
// turn ends cannot have its `turn.ended` slip past the policy loop.
if (event.type === 'turn.ended') turnEndings.push(event);
});
try {
const handle = await agent.accessor.get(IAgentPromptService).enqueue({
message: {
role: 'user',
content: [{ type: 'text', text: prompt }],
toolCalls: [],
origin: { kind: 'user' },
},
});
const turn = await handle.launched;
if (turn === undefined) {
// A prompt blocked by an onBeforeSubmitPrompt hook never launches a turn.
writer.finish();
const completion = await handle.completion;
throw new Error(
completion.state === 'blocked'
? 'Prompt hook blocked the request.'
: 'Prompt turn could not be started',
);
}
const result = await turn.result;
// Turn settled, but `-p` is not done until the print-mode background
// policy says so (config-driven: exit / drain / steer). Flush the buffered
// assistant message first so a long drain/steer wait does not withhold the
// final message.
writer.flushAssistant();
if (result.type === 'completed') {
const configService = app.accessor.get(IConfigService);
const taskConfig = resolveAgentTaskConfig(configService);
const goalService = agent.accessor.get(IAgentGoalService);
try {
await applyPrintBackgroundPolicy({
mode: resolvePrintBackgroundMode(configService),
ceilingS: taskConfig?.printWaitCeilingS ?? DEFAULT_PRINT_WAIT_CEILING_S,
maxTurns: taskConfig?.printMaxTurns ?? DEFAULT_PRINT_MAX_TURNS,
countPending: () => countPendingBackgroundTasks(session),
drain: () => drainBackgroundTasks(session, taskConfig?.printWaitCeilingS),
turnEndings,
skipTurnId: turn.id,
warn: (message) => stderr.write(`Warning: ${message}\n`),
now: () => Date.now(),
goalActive: () => goalService.getGoal().goal?.status === 'active',
});
} catch (error) {
// A steered turn that fails fails the run (v1 parity). Anything else
// is best-effort: a wedged background task must not fail the (already
// completed) main turn.
if (error instanceof PrintSteeredTurnFailedError) {
writer.finish();
throw error;
}
stderr.write(
`Warning: print background policy failed: ${
error instanceof Error ? error.message : String(error)
}\n`,
);
}
writer.finish();
return;
}
writer.finish();
throw new Error(formatNativeTurnFailure(result));
} catch (error) {
writer.finish();
throw error instanceof Error ? error : new Error(String(error));
} finally {
subscription.dispose();
}
}
async function runNativeGoal(
app: Scope,
session: ISessionScopeHandle,
agent: IAgentScopeHandle,
goal: HeadlessGoalCreate,
model: string | undefined,
outputFormat: PromptOutputFormat,
stdout: PromptOutput,
stderr: PromptOutput,
): Promise<void> {
requireConfiguredModel(model);
const goalService = agent.accessor.get(IAgentGoalService);
await goalService.createGoal({
objective: goal.objective,
replace: goal.replace,
});
let completedSnapshot: { readonly status: string } | null = null;
const subscription = agent.accessor.get(IEventBus).subscribe((event: DomainEvent) => {
if (
event.type === 'goal.updated' &&
event.change?.kind === 'completion' &&
event.snapshot !== null
) {
completedSnapshot = event.snapshot;
}
});
try {
await runNativeTurn(app, session, agent, goal.objective, outputFormat, stdout, stderr);
} finally {
subscription.dispose();
const snapshot = completedSnapshot ?? goalService.getGoal().goal;
if (outputFormat === 'stream-json') {
stdout.write(`${JSON.stringify(goalSummaryJson(snapshot))}\n`);
} else {
stderr.write(`${formatGoalSummaryText(snapshot)}\n`);
}
if (snapshot !== null && snapshot.status !== 'complete') {
process.exitCode = goalExitCode(snapshot.status);
}
}
}
function dispatchNativeEvent(
writer: PromptTurnWriter,
event: DomainEvent,
stderr: PromptOutput,
): void {
switch (event.type) {
case 'turn.step.started':
case 'turn.step.interrupted':
writer.flushAssistant();
return;
case 'turn.step.retrying':
writer.discardAssistant();
return;
case 'assistant.delta':
writer.writeAssistantDelta(event.delta);
return;
case 'hook.result':
writer.writeHookResult(event);
return;
case 'thinking.delta':
writer.writeThinkingDelta(event.delta);
return;
case 'tool.call.started':
writer.writeToolCall(event.toolCallId, event.name, event.args);
return;
case 'tool.call.delta':
writer.writeToolCallDelta(event.toolCallId, event.name, event.argumentsPart);
return;
case 'tool.result':
writer.writeToolResult(event.toolCallId, event.output);
return;
case 'tool.progress':
if (event.update.text !== undefined && event.update.text.length > 0) {
stderr.write(event.update.text.endsWith('\n') ? event.update.text : `${event.update.text}\n`);
}
return;
}
}
export type PrintTurnEnding = Extract<DomainEvent, { type: 'turn.ended' }>;
/**
* Source of `turn.ended` events for the print steer loop. `next` resolves with
* the next ending (skipping `skipTurnId`, the main turn's own buffered
* ending), or `null` when `remainingMs` elapses first.
*/
export interface PrintTurnEndings {
next(remainingMs: number, skipTurnId: number): Promise<PrintTurnEnding | null>;
}
/**
* Buffered `turn.ended` collector fed from the agent event bus. Events that
* arrive while no one is waiting are queued, so endings that fire between the
* main turn settling and the policy loop starting are not missed.
*/
export function createPrintTurnEndings(): PrintTurnEndings & {
push: (event: PrintTurnEnding) => void;
} {
const buffer: PrintTurnEnding[] = [];
let waiter: ((ending: PrintTurnEnding | null) => void) | undefined;
return {
push: (event) => {
const resolve = waiter;
if (resolve !== undefined) {
waiter = undefined;
resolve(event);
return;
}
buffer.push(event);
},
next: async (remainingMs, skipTurnId) => {
const deadlineAt = Date.now() + remainingMs;
const waitOnce = (ms: number): Promise<PrintTurnEnding | null> =>
new Promise((resolve) => {
let settled = false;
const settle = (value: PrintTurnEnding | null): void => {
if (settled) return;
settled = true;
clearTimeout(timer);
waiter = undefined;
// oxlint-disable-next-line promise/no-multiple-resolved -- `settled` guards the single resolve; the rule cannot see it
resolve(value);
};
const timer = Number.isFinite(ms)
? setTimeout(() => {
settle(null);
}, ms)
: undefined;
waiter = settle;
});
for (;;) {
while (buffer.length > 0) {
const ending = buffer.shift()!;
if (ending.turnId !== skipTurnId) return ending;
}
const ms = deadlineAt - Date.now();
if (ms <= 0) return null;
const ending = await waitOnce(ms);
if (ending === null) return null;
if (ending.turnId !== skipTurnId) return ending;
// The skipped turn's own ending: keep waiting within the same budget.
}
},
};
}
/** A background-task completion steered a new main turn that did not complete. */
export class PrintSteeredTurnFailedError extends Error {}
export interface PrintBackgroundPolicyInput {
readonly mode: PrintBackgroundMode;
readonly ceilingS: number;
readonly maxTurns: number;
readonly countPending: () => number;
readonly drain: () => Promise<void>;
readonly turnEndings: PrintTurnEndings;
readonly skipTurnId: number;
readonly warn: (message: string) => void;
readonly now: () => number;
/**
* Reports whether an agent goal is still `active`. v2 drives goal
* continuation as new turns (v1 keeps a single turn alive), so a `-p` goal
* run must stay alive until the goal leaves `active`, independent of the
* background policy.
*/
readonly goalActive?: () => boolean;
}
/**
* Apply the print-mode (`kimi -p`) background-task policy after the main turn
* completes. Mirrors v1's `Session.handlePrintMainTurnCompleted`:
* - goal : while a goal is `active`, keep waiting for its continuation
* turns (bounded by `ceilingS` as a safety net), regardless of
* the background mode; the goal summary drives the exit code.
* - 'exit' : return immediately (default).
* - 'drain' : suppress + drain background tasks, then return.
* - 'steer' : while background tasks are still pending, stay alive so task
* completions steer new main turns; return once quiescent, or
* when the wall-clock ceiling (`ceilingS`) or the turn cap
* (`maxTurns`) is reached. A steered turn that does not complete
* fails the run.
*/
export async function applyPrintBackgroundPolicy(
input: PrintBackgroundPolicyInput,
): Promise<void> {
if (input.goalActive !== undefined) {
const goalDeadline = input.now() + input.ceilingS * 1000;
while (input.goalActive()) {
// Also wake on a short poll: a goal can leave `active` without any
// further turn.ended (budget block at a turn boundary, or a pause after
// a continuation-launch failure), which would otherwise hang the run
// until the ceiling.
const ended = await input.turnEndings.next(
Math.min(goalDeadline - input.now(), GOAL_WAIT_POLL_MS),
input.skipTurnId,
);
if (ended === null && input.now() >= goalDeadline) {
input.warn(`print goal wait ceiling reached (${input.ceilingS}s), finishing`);
return;
}
// A continuation turn that does not complete pauses/blocks the goal, so
// the loop condition exits on the next check.
}
}
if (input.mode === 'exit') return;
if (input.mode === 'drain') {
await input.drain();
return;
}
// 'steer'
const deadline = input.now() + input.ceilingS * 1000;
let turns = 0;
for (;;) {
turns += 1;
if (input.now() >= deadline) {
input.warn(`print steer ceiling reached (${input.ceilingS}s), finishing`);
return;
}
if (turns > input.maxTurns) {
input.warn(`print steer max turns reached (${input.maxTurns}), finishing`);
return;
}
if (input.countPending() === 0) return;
const ended = await input.turnEndings.next(deadline - input.now(), input.skipTurnId);
if (ended === null) return;
if (ended.reason !== 'completed') {
throw new PrintSteeredTurnFailedError(formatTurnEndingFailure(ended));
}
}
}
function formatTurnEndingFailure(ending: PrintTurnEnding): string {
if (ending.error?.code === 'provider.filtered') {
return 'Provider safety policy blocked the response.';
}
if (ending.error !== undefined) return `${ending.error.code}: ${ending.error.message}`;
if (ending.reason === 'blocked') {
return 'Prompt hook blocked the request.';
}
return `Prompt turn ended with reason: ${ending.reason}`;
}
function countPendingBackgroundTasks(session: ISessionScopeHandle): number {
let count = 0;
for (const handle of session.accessor.get(IAgentLifecycleService).list()) {
count += handle.accessor.get(IAgentTaskService).list(true).length;
}
return count;
}
async function drainBackgroundTasks(
session: ISessionScopeHandle,
ceilingS: number | undefined,
): Promise<void> {
const ceilingMs =
typeof ceilingS === 'number' && Number.isFinite(ceilingS) && ceilingS > 0
? ceilingS * 1000
: DEFAULT_PRINT_WAIT_CEILING_S * 1000;
const deadline = Date.now() + ceilingMs;
const seen = new Set<string>();
const allWaiters: Promise<unknown>[] = [];
while (Date.now() < deadline) {
const batch: Promise<unknown>[] = [];
const suppressions: Promise<void>[] = [];
let activeCount = 0;
for (const handle of session.accessor.get(IAgentLifecycleService).list()) {
const taskService = handle.accessor.get(IAgentTaskService);
for (const task of taskService.list(true)) {
activeCount++;
if (seen.has(task.taskId)) continue;
seen.add(task.taskId);
suppressions.push(taskService.suppressTerminalNotification(task.taskId));
const remaining = Math.max(1, deadline - Date.now());
const waiter = taskService.wait(task.taskId, remaining);
batch.push(waiter);
allWaiters.push(waiter);
}
}
if (suppressions.length > 0) await Promise.all(suppressions);
if (activeCount === 0 || batch.length === 0) break;
await Promise.all(batch);
}
if (allWaiters.length > 0) await Promise.all(allWaiters);
}
function formatNativeTurnFailure(result: LoopRunResult): string {
if (result.type === 'failed') {
const error = result.error as { readonly code?: string; readonly message?: string } | undefined;
if (error?.code === 'provider.filtered') {
return 'Provider safety policy blocked the response.';
}
if (error?.code !== undefined) {
return `${error.code}: ${error.message ?? ''}`.trimEnd();
}
if (result.error instanceof Error) {
return result.error.message;
}
}
return `Prompt turn ended with reason: ${result.type}`;
}

View file

@ -7,7 +7,7 @@
import { existsSync, readFileSync } from 'node:fs';
import { dirname, resolve } from 'node:path';
import { createKimiDefaultHeaders, createKimiUserAgent, type KimiHostIdentity } from '@moonshot-ai/kimi-code-oauth';
import { createKimiDefaultHeaders, type KimiHostIdentity } from '@moonshot-ai/kimi-code-oauth';
import { CLI_USER_AGENT_PRODUCT } from '#/constant/app';
@ -55,14 +55,6 @@ export function createKimiCodeHostIdentity(version = getVersion()): KimiHostIden
};
}
/**
* Product User-Agent (`kimi-code-cli/<version>`) for ad-hoc outbound fetches
* that don't go through the provider pipeline (registry / catalog imports).
*/
export function createKimiCodeUserAgent(version = getVersion()): string {
return createKimiUserAgent(createKimiCodeHostIdentity(version));
}
export function buildKimiDefaultHeaders(version: string): Record<string, string> {
return createKimiDefaultHeaders({
homeDir: getDataDir(),

View file

@ -7,33 +7,10 @@ export const PROCESS_NAME = 'kimi-code';
// Used in telemetry app names and HTTP User-Agent headers.
export const CLI_USER_AGENT_PRODUCT = 'kimi-code-cli';
export const CLI_UI_MODE = 'shell';
// Telemetry ui_mode for the `kimi web` / `kimi server run` host. Same product
// as the CLI (CLI_USER_AGENT_PRODUCT); the surface is distinguished by ui_mode.
export const WEB_UI_MODE = 'web';
// Give telemetry a short flush window without making CLI exit feel stuck.
export const CLI_SHUTDOWN_TIMEOUT_MS = 3000;
// Upper bound on headless (`kimi -p`) shutdown. A wedged cleanup step (e.g. a
// SessionEnd hook, an MCP shutdown, or a connection blackholed by a restrictive
// firewall) must not keep a completed run alive indefinitely — once this elapses
// we stop waiting on cleanup and let the run return.
export const PROMPT_CLEANUP_TIMEOUT_MS = 8000;
// Grace after a headless run has fully completed (turn done, cleanup attempted)
// before force-exiting. `kimi -p` otherwise relies on the event loop draining to
// exit; a stray ref'd handle (socket/timer/child) left over from the run would
// wedge it. The guard timer is unref'd, so a healthy run still exits naturally
// well before this fires.
export const HEADLESS_FORCE_EXIT_GRACE_MS = 2000;
// Max time to wait for buffered stdout/stderr to flush before arming the
// force-exit fallback. A slow/piped consumer's still-draining stdio is a
// legitimate ref'd handle — flushing first prevents the fallback from
// truncating completed output. Bounded so a permanently-stuck consumer can't
// re-introduce the hang.
export const HEADLESS_STDIO_DRAIN_TIMEOUT_MS = 10000;
// Published npm package name; this can differ from the executable command.
export const NPM_PACKAGE_NAME = '@moonshot-ai/kimi-code';
@ -41,16 +18,12 @@ export const NPM_PACKAGE_NAME = '@moonshot-ai/kimi-code';
export const KIMI_CODE_HOME_ENV = 'KIMI_CODE_HOME';
export const KIMI_CODE_DATA_DIR_NAME = '.kimi-code';
export const KIMI_CODE_LOG_DIR_NAME = 'logs';
export const KIMI_CODE_CACHE_DIR_NAME = 'cache';
export const KIMI_CODE_UPDATE_DIR_NAME = 'updates';
export const KIMI_CODE_BIN_DIR_NAME = 'bin';
export const KIMI_CODE_UPDATE_STATE_FILE_NAME = 'latest.json';
export const KIMI_CODE_UPDATE_INSTALL_STATE_FILE_NAME = 'install.json';
export const KIMI_CODE_UPDATE_INSTALL_LOCK_FILE_NAME = 'install.lock';
export const KIMI_CODE_UPDATE_ROLLOUT_LOG_FILE_NAME = 'rollout.log';
export const KIMI_CODE_INPUT_HISTORY_DIR_NAME = 'user-history';
export const KIMI_CODE_BANNER_DIR_NAME = 'banner';
export const KIMI_CODE_BANNER_STATE_FILE_NAME = 'state.json';
// Managed Kimi auth provider key shared with OAuth/SDK config.
export const DEFAULT_OAUTH_PROVIDER_NAME = 'managed:kimi-code';
@ -72,11 +45,6 @@ export const FEEDBACK_TELEMETRY_EVENT = 'feedback_submitted';
// CDN source of truth: all version checks and native install scripts pull from here.
export const KIMI_CODE_CDN_BASE = 'https://code.kimi.com/kimi-code';
export const KIMI_CODE_CDN_LATEST_URL = `${KIMI_CODE_CDN_BASE}/latest`;
// Rollout manifest consumed by update checks; the plain-text `/latest` above
// stays unchanged forever — already-shipped clients hard-fail on non-semver
// bodies, and the CDN install scripts read it for fresh installs.
export const KIMI_CODE_CDN_LATEST_JSON_URL = `${KIMI_CODE_CDN_BASE}/latest.json`;
export const KIMI_CODE_TIPS_BANNER_URL = 'https://cdn.kimi.com/kimi-code-tips/tips.json';
export const KIMI_CODE_PLUGIN_MARKETPLACE_URL = `${KIMI_CODE_CDN_BASE}/plugins/marketplace.json`;
export const KIMI_CODE_PLUGIN_MARKETPLACE_URL_ENV = 'KIMI_CODE_PLUGIN_MARKETPLACE_URL';
export const KIMI_CODE_INSTALL_SH_URL = `${KIMI_CODE_CDN_BASE}/install.sh`;

View file

@ -1,72 +0,0 @@
import { mkdir, mkdtemp, readdir, rm, stat } from 'node:fs/promises';
import { dirname, join } from 'node:path';
import { getCacheDir } from '../utils/paths';
const STALE_ARCHIVE_MAX_AGE_MS = 24 * 60 * 60 * 1000; // 24 hours.
/**
* A file produced for a feedback attachment upload. Both the session log
* archive and the codebase archive share this shape; the generic uploader
* consumes it without caring how the file was produced.
*/
export interface FeedbackArchive {
readonly path: string;
readonly size: number;
readonly sha256: string;
readonly fingerprint: string;
readonly fileCount: number;
/** Directory created exclusively for this archive and safe to remove after upload. */
readonly cleanupDir?: string;
}
export async function createFeedbackArchivePath(filename: string): Promise<{
readonly archivePath: string;
readonly cleanupDir: string;
}> {
const archivePath = await createArchivePath(filename);
return { archivePath, cleanupDir: archivePathCleanupDir(archivePath) };
}
/**
* Remove feedback-upload archive directories older than 24 hours. Packaging
* cleans up its own archive on success and on failure, but a killed process
* or an empty parent dir can still leave leftovers behind; this is a
* best-effort backstop so the cache dir does not grow without bound.
*
* `dir` is injectable for tests; production callers leave it as the default.
*/
export async function removeStaleFeedbackUploads(
options: { readonly now?: number; readonly dir?: string } = {},
): Promise<void> {
const now = options.now ?? Date.now();
const dir = options.dir ?? join(getCacheDir(), 'feedback-uploads');
const entries = await readdir(dir, { withFileTypes: true }).catch((error: unknown) => {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return null;
throw error;
});
if (entries === null) return;
const cutoff = now - STALE_ARCHIVE_MAX_AGE_MS;
await Promise.all(
entries.map(async (entry) => {
if (!entry.isDirectory() && !entry.isSymbolicLink()) return;
const target = join(dir, entry.name);
const targetStat = await stat(target).catch(() => null);
if (targetStat === null || targetStat.mtimeMs >= cutoff) return;
await rm(target, { recursive: true, force: true }).catch(() => {});
}),
);
}
async function createArchivePath(filename: string): Promise<string> {
await removeStaleFeedbackUploads();
const root = join(getCacheDir(), 'feedback-uploads');
await mkdir(root, { recursive: true });
const dir = await mkdtemp(join(root, 'upload-'));
return join(dir, filename);
}
function archivePathCleanupDir(archivePath: string): string {
return dirname(archivePath);
}

View file

@ -1,92 +0,0 @@
export const DEFAULT_MAX_FILES = 50000;
export const DEFAULT_MAX_FILE_SIZE = 50 * 1024 * 1024;
// Upper bound for the compressed codebase archive, aligned with the backend's
// per-upload limit. The scanner uses cumulative raw file size as a conservative
// estimate so the resulting zip stays within this bound.
export const DEFAULT_MAX_ARCHIVE_SIZE = 500 * 1024 * 1024;
const IGNORED_DIR_NAMES: ReadonlySet<string> = new Set([
'.git',
'.hg',
'.svn',
'node_modules',
'dist',
'build',
'out',
'.next',
'.nuxt',
'.turbo',
'.cache',
'.parcel-cache',
'coverage',
'.nyc_output',
'target',
'__pycache__',
'.pytest_cache',
'.mypy_cache',
'.venv',
'venv',
'env',
'.idea',
]);
const SENSITIVE_DIR_NAMES: ReadonlySet<string> = new Set([
'.ssh',
'.gnupg',
'.aws',
'.kube',
'.docker',
]);
const SENSITIVE_FILE_NAMES: ReadonlySet<string> = new Set([
'.env',
'id_rsa',
'id_dsa',
'id_ecdsa',
'id_ed25519',
'credentials.json',
'service-account.json',
'serviceAccount.json',
'.netrc',
'.htpasswd',
'.pypirc',
'.npmrc',
'.envrc',
'.yarnrc',
'.yarnrc.yml',
]);
const SENSITIVE_FILE_SUFFIXES: readonly string[] = [
'.pem',
'.key',
'.p12',
'.pfx',
'.jks',
'.keystore',
];
const ENV_FILE_ALLOWED_SUFFIXES: ReadonlySet<string> = new Set(['.example', '.sample', '.template']);
export function isIgnoredDirName(name: string): boolean {
return IGNORED_DIR_NAMES.has(name);
}
export function isSensitivePath(relativePath: string): boolean {
const segments = relativePath.split('/');
for (let i = 0; i < segments.length - 1; i += 1) {
const segment = segments[i];
if (segment !== undefined && SENSITIVE_DIR_NAMES.has(segment)) return true;
}
const base = segments.at(-1);
if (base === undefined || base.length === 0) return false;
if (SENSITIVE_FILE_NAMES.has(base)) return true;
if (SENSITIVE_FILE_SUFFIXES.some((suffix) => base.endsWith(suffix))) return true;
if (base.startsWith('.env.')) {
const suffix = base.slice('.env'.length);
return !ENV_FILE_ALLOWED_SUFFIXES.has(suffix);
}
return false;
}

View file

@ -1,3 +0,0 @@
export * from './packager';
export * from './scanner';
export * from './types';

View file

@ -1,98 +0,0 @@
import { createHash } from 'node:crypto';
import { createWriteStream } from 'node:fs';
import { mkdir, rm, stat } from 'node:fs/promises';
import { dirname } from 'node:path';
import { ZipFile } from 'yazl';
import type { FeedbackArchive } from '../archive';
import type { FeedbackCodebaseScanResult } from './types';
interface PackageEntry {
readonly absolutePath: string;
readonly archivePath: string;
readonly size: number;
readonly mtimeMs: number;
}
/**
* Pack the scanned codebase into a zip, with files placed at the zip root.
*/
export async function packageCodebase(
scan: FeedbackCodebaseScanResult,
archivePath: string,
): Promise<FeedbackArchive> {
const entries: PackageEntry[] = scan.files.map((file) => ({
absolutePath: file.absolutePath,
archivePath: file.path,
size: file.size,
mtimeMs: file.mtimeMs,
}));
return packageEntries(entries, archivePath);
}
async function packageEntries(
entries: readonly PackageEntry[],
archivePath: string,
): Promise<FeedbackArchive> {
if (entries.length === 0) {
throw new Error('Cannot package an empty feedback archive.');
}
await mkdir(dirname(archivePath), { recursive: true });
const zip = new ZipFile();
const hash = createHash('sha256');
const output = createWriteStream(archivePath);
try {
const done = new Promise<void>((resolvePromise, rejectPromise) => {
output.on('finish', resolvePromise);
output.on('error', rejectPromise);
zip.outputStream.on('error', rejectPromise);
});
zip.outputStream.on('data', (chunk: Buffer) => {
hash.update(chunk);
});
zip.outputStream.pipe(output);
for (const entry of entries) {
zip.addFile(entry.absolutePath, entry.archivePath, {
mtime: new Date(entry.mtimeMs),
mode: 0o100644,
});
}
zip.end();
await done;
const archiveStat = await stat(archivePath);
return {
path: archivePath,
size: archiveStat.size,
sha256: hash.digest('hex'),
fingerprint: fingerprintEntries(entries),
fileCount: entries.length,
};
} catch (error) {
// A failed zip (e.g. a source file vanished or became unreadable between
// scan and packaging) would otherwise leave a partial archive behind in
// the cache dir. Destroy the stream so the handle is released before we
// remove the file, then best-effort delete it.
output.destroy();
await rm(archivePath, { force: true }).catch(() => {});
throw error;
}
}
function fingerprintEntries(entries: readonly PackageEntry[]): string {
const hash = createHash('sha256');
for (const entry of entries) {
hash.update(entry.archivePath);
hash.update('\0');
hash.update(String(entry.size));
hash.update('\0');
hash.update(String(Math.trunc(entry.mtimeMs)));
hash.update('\n');
}
return hash.digest('hex');
}

View file

@ -1,217 +0,0 @@
import { execFile } from 'node:child_process';
import { createHash } from 'node:crypto';
import { lstat, readdir } from 'node:fs/promises';
import { join, relative, resolve } from 'node:path';
import { promisify } from 'node:util';
import {
DEFAULT_MAX_ARCHIVE_SIZE,
DEFAULT_MAX_FILES,
DEFAULT_MAX_FILE_SIZE,
isIgnoredDirName,
isSensitivePath,
} from './filter';
import type {
FeedbackCodebaseFile,
FeedbackCodebaseLimitExceeded,
FeedbackCodebaseScanResult,
} from './types';
const execFileAsync = promisify(execFile);
export interface ScanCodebaseLimits {
readonly maxFiles: number;
readonly maxFileSize: number;
readonly maxArchiveSize: number;
}
export interface ScanCodebaseOptions {
readonly limits?: {
readonly maxFiles?: number;
readonly maxFileSize?: number;
readonly maxArchiveSize?: number;
};
readonly signal?: AbortSignal;
}
interface CollectedFiles {
readonly files: FeedbackCodebaseFile[];
readonly exceedsLimit?: FeedbackCodebaseLimitExceeded;
}
export async function scanCodebase(
rootInput: string,
options: ScanCodebaseOptions = {},
): Promise<FeedbackCodebaseScanResult> {
const root = resolve(rootInput);
const limits = resolveLimits(options.limits);
throwIfAborted(options.signal);
const usedGitIgnore = await isInsideGitWorkTree(root);
const collected = usedGitIgnore
? await scanWithGit(root, limits, options.signal)
: await scanWithoutFilter(root, limits, options.signal);
const sortedFiles = collected.files.toSorted((a, b) => a.path.localeCompare(b.path));
return {
root,
files: sortedFiles,
fingerprint: fingerprintFiles(sortedFiles),
usedGitIgnore,
exceedsLimit: collected.exceedsLimit,
};
}
function resolveLimits(limits: ScanCodebaseOptions['limits']): ScanCodebaseLimits {
return {
maxFiles: limits?.maxFiles ?? DEFAULT_MAX_FILES,
maxFileSize: limits?.maxFileSize ?? DEFAULT_MAX_FILE_SIZE,
maxArchiveSize: limits?.maxArchiveSize ?? DEFAULT_MAX_ARCHIVE_SIZE,
};
}
async function isInsideGitWorkTree(root: string): Promise<boolean> {
try {
const { stdout } = await execFileAsync('git', ['-C', root, 'rev-parse', '--is-inside-work-tree']);
return stdout.trim() === 'true';
} catch {
return false;
}
}
async function scanWithGit(
root: string,
limits: ScanCodebaseLimits,
signal?: AbortSignal,
): Promise<CollectedFiles> {
const { stdout } = await execFileAsync(
'git',
['-C', root, 'ls-files', '-co', '--exclude-standard', '-z'],
{ encoding: 'buffer', maxBuffer: 1024 * 1024 * 64, signal },
);
throwIfAborted(signal);
const relativePaths = splitNull(stdout);
const files: FeedbackCodebaseFile[] = [];
let exceedsLimit: FeedbackCodebaseLimitExceeded | undefined;
let totalSize = 0;
for (const relativePath of relativePaths) {
throwIfAborted(signal);
if (files.length >= limits.maxFiles) {
exceedsLimit = { reason: 'file-count', limit: limits.maxFiles };
break;
}
if (isSensitivePath(relativePath)) continue;
const file = await statFile(root, relativePath);
if (file) {
if (file.size > limits.maxFileSize) continue;
if (totalSize + file.size > limits.maxArchiveSize) {
exceedsLimit = { reason: 'total-size', limit: limits.maxArchiveSize };
break;
}
files.push(file);
totalSize += file.size;
}
}
return { files, exceedsLimit };
}
async function scanWithoutFilter(
root: string,
limits: ScanCodebaseLimits,
signal?: AbortSignal,
): Promise<CollectedFiles> {
const files: FeedbackCodebaseFile[] = [];
let exceedsLimit: FeedbackCodebaseLimitExceeded | undefined;
let stopped = false;
let totalSize = 0;
async function walk(dir: string): Promise<void> {
if (stopped) return;
throwIfAborted(signal);
const entries = await readdir(dir, { withFileTypes: true });
for (const entry of entries) {
if (stopped) return;
throwIfAborted(signal);
if (files.length >= limits.maxFiles) {
exceedsLimit = { reason: 'file-count', limit: limits.maxFiles };
stopped = true;
return;
}
if (entry.isSymbolicLink()) continue;
const absolutePath = join(dir, entry.name);
if (entry.isDirectory()) {
if (isIgnoredDirName(entry.name)) continue;
await walk(absolutePath);
if (stopped) return;
continue;
}
if (!entry.isFile()) continue;
const relativePath = toPosixPath(relative(root, absolutePath));
if (isSensitivePath(relativePath)) continue;
const file = await statFile(root, relativePath);
if (file) {
if (file.size > limits.maxFileSize) continue;
if (totalSize + file.size > limits.maxArchiveSize) {
exceedsLimit = { reason: 'total-size', limit: limits.maxArchiveSize };
stopped = true;
return;
}
files.push(file);
totalSize += file.size;
}
}
}
await walk(root);
return { files, exceedsLimit };
}
async function statFile(root: string, relativePath: string): Promise<FeedbackCodebaseFile | null> {
const absolutePath = resolve(root, relativePath);
// A tracked file can be deleted from the working tree but still listed by
// `git ls-files`; lstat then throws ENOENT. Treat unreadable/vanished paths
// like any other non-regular entry so one bad path does not abort the scan.
const stat = await lstat(absolutePath).catch(() => null);
if (stat === null || stat.isSymbolicLink() || !stat.isFile()) return null;
return {
path: toPosixPath(relativePath),
absolutePath,
size: stat.size,
mtimeMs: stat.mtimeMs,
};
}
function throwIfAborted(signal?: AbortSignal): void {
if (signal?.aborted) {
const error = new Error('Codebase scan aborted.');
error.name = 'AbortError';
throw error;
}
}
function fingerprintFiles(files: readonly FeedbackCodebaseFile[]): string {
const hash = createHash('sha256');
for (const file of files) {
hash.update(file.path);
hash.update('\0');
hash.update(String(file.size));
hash.update('\0');
hash.update(String(Math.trunc(file.mtimeMs)));
hash.update('\n');
}
return hash.digest('hex');
}
function splitNull(buffer: Buffer): string[] {
return buffer
.toString('utf8')
.split('\0')
.filter((item) => item.length > 0);
}
function toPosixPath(value: string): string {
return value.split('\\').join('/');
}

View file

@ -1,19 +0,0 @@
export interface FeedbackCodebaseFile {
readonly path: string;
readonly absolutePath: string;
readonly size: number;
readonly mtimeMs: number;
}
export interface FeedbackCodebaseLimitExceeded {
readonly reason: 'file-count' | 'total-size';
readonly limit: number;
}
export interface FeedbackCodebaseScanResult {
readonly root: string;
readonly files: readonly FeedbackCodebaseFile[];
readonly fingerprint: string;
readonly usedGitIgnore: boolean;
readonly exceedsLimit?: FeedbackCodebaseLimitExceeded;
}

View file

@ -1,183 +0,0 @@
import { createHash } from 'node:crypto';
import { appendFile, mkdir, readFile, rm, stat } from 'node:fs/promises';
import { join } from 'node:path';
import { detectInstallSource } from '#/cli/update/source';
import type { SlashCommandHost } from '#/tui/commands/dispatch';
import type { FeedbackAttachmentLevel } from '#/tui/commands/prompts';
import { getLogDir } from '#/utils/paths';
import { detectShellEnvironment } from '#/utils/process/shell-env';
import { createFeedbackArchivePath, type FeedbackArchive } from './archive';
import { packageCodebase, scanCodebase, type FeedbackCodebaseScanResult } from './codebase';
import { uploadArchive, type FeedbackUploadUrlApi } from './upload';
export const CODEBASE_ARCHIVE_FILENAME = 'repo.zip';
export const SESSION_ARCHIVE_FILENAME = 'session.zip';
const CODEBASE_SCAN_TIMEOUT_MS = 3000;
/**
* Stage 3 of the `/feedback` flow: prepare and upload each requested attachment
* independently. Attachment failures are non-fatal because the text feedback
* already exists, but any requested artifact that cannot be prepared/uploaded
* is reported as a partial attachment failure instead of silently downgrading
* the request.
*
* Returns `true` when at least one requested attachment failed so the caller
* can surface a partial-failure status.
*/
export async function submitFeedbackWithAttachments(
host: SlashCommandHost,
feedbackId: number,
level: FeedbackAttachmentLevel,
): Promise<boolean> {
const api = createFeedbackUploadApi(host);
if (level === 'logs') {
const uploaded = await prepareAndUploadSessionArchive(host, api, feedbackId);
return !uploaded;
}
if (level === 'logs+codebase') {
const [sessionDir, scan] = await Promise.all([
resolveCurrentSessionDir(host),
scanCodebaseForFeedback(host.state.appState.workDir),
]);
const [uploadedSession, uploadedCodebase] = await Promise.all([
prepareAndUploadSessionArchive(host, api, feedbackId, sessionDir),
prepareAndUploadCodebaseArchive(api, feedbackId, scan),
]);
return !uploadedSession || !uploadedCodebase;
}
return false;
}
async function prepareAndUploadSessionArchive(
host: SlashCommandHost,
api: FeedbackUploadUrlApi,
feedbackId: number,
knownSessionDir?: string,
): Promise<boolean> {
const sessionDir = knownSessionDir ?? (await resolveCurrentSessionDir(host));
if (sessionDir === undefined) {
await logFeedbackUploadError(new Error('cannot locate the current session directory'));
return false;
}
return uploadProducedArchive(api, feedbackId, SESSION_ARCHIVE_FILENAME, async (archivePath) => {
const exported = await host.harness.exportSession({
id: host.state.appState.sessionId,
outputPath: archivePath,
includeGlobalLog: true,
version: host.state.appState.version,
installSource: await detectInstallSource(),
shellEnv: detectShellEnvironment(),
});
return archiveFromExportedSession(exported.zipPath);
});
}
async function prepareAndUploadCodebaseArchive(
api: FeedbackUploadUrlApi,
feedbackId: number,
scan: FeedbackCodebaseScanResult | undefined,
): Promise<boolean> {
if (scan === undefined) return false;
return uploadProducedArchive(api, feedbackId, CODEBASE_ARCHIVE_FILENAME, (archivePath) =>
packageCodebase(scan, archivePath),
);
}
/**
* Shared lifecycle for a single attachment: create a temp archive path, let
* `produce` write the archive to it, upload it, then always remove the temp
* directory even when `produce` or the upload throws. Both the session log
* archive and the codebase archive flow through here so their cleanup and
* error handling cannot drift apart.
*/
async function uploadProducedArchive(
api: FeedbackUploadUrlApi,
feedbackId: number,
filename: string,
produce: (archivePath: string) => Promise<FeedbackArchive>,
): Promise<boolean> {
const { archivePath, cleanupDir } = await createFeedbackArchivePath(filename);
try {
const archive = await produce(archivePath);
await uploadArchive(api, { ...archive, cleanupDir }, feedbackId, { filename });
return true;
} catch (error) {
await logFeedbackUploadError(error);
return false;
} finally {
await rm(cleanupDir, { recursive: true, force: true }).catch(() => {});
}
}
async function archiveFromExportedSession(zipPath: string): Promise<FeedbackArchive> {
const data = await readFile(zipPath);
const archiveStat = await stat(zipPath);
return {
path: zipPath,
size: archiveStat.size,
sha256: createHash('sha256').update(data).digest('hex'),
fingerprint: createHash('sha256').update(data).digest('hex'),
fileCount: 1,
};
}
async function resolveCurrentSessionDir(host: SlashCommandHost): Promise<string | undefined> {
try {
const sessions = await host.harness.listSessions({ workDir: host.state.appState.workDir });
return sessions.find((session) => session.id === host.state.appState.sessionId)?.sessionDir;
} catch {
return undefined;
}
}
async function scanCodebaseForFeedback(
workDir: string,
): Promise<FeedbackCodebaseScanResult | undefined> {
const controller = new AbortController();
const timer = setTimeout(() => {
controller.abort();
}, CODEBASE_SCAN_TIMEOUT_MS);
try {
return await scanCodebase(workDir, { signal: controller.signal });
} catch (error) {
await logFeedbackUploadError(error);
return undefined;
} finally {
clearTimeout(timer);
}
}
async function logFeedbackUploadError(error: unknown): Promise<void> {
try {
const logDir = getLogDir();
await mkdir(logDir, { recursive: true });
const message = error instanceof Error ? (error.stack ?? error.message) : String(error);
await appendFile(join(logDir, 'feedback-upload.log'), `${new Date().toISOString()} ${message}\n`);
} catch {
// best-effort logging only
}
}
function createFeedbackUploadApi(host: SlashCommandHost): FeedbackUploadUrlApi {
return {
async createUploadUrl(input) {
const res = await host.harness.auth.createFeedbackUploadUrl(input);
if (res.kind !== 'ok') throw new Error(res.message);
return {
uploadId: res.uploadId,
parts: res.parts,
};
},
async completeUpload(input) {
const res = await host.harness.auth.completeFeedbackUpload({
uploadId: input.uploadId,
parts: input.parts.map((part) => ({ partNumber: part.partNumber, etag: part.etag })),
});
if (res.kind !== 'ok') throw new Error(res.message);
},
};
}

View file

@ -1,208 +0,0 @@
import { createReadStream } from 'node:fs';
import { Readable } from 'node:stream';
import type { FeedbackArchive } from './archive';
const MAX_ARCHIVE_SIZE = 524_288_000; // 500 MiB, matches the backend limit.
const DEFAULT_CONCURRENCY = 3;
const DEFAULT_MAX_RETRIES = 3;
const DEFAULT_PART_TIMEOUT_MS = 60_000;
const RETRY_BASE_DELAY_MS = 1_000;
export interface FeedbackUploadPart {
readonly partNumber: number;
readonly url: string;
readonly method: string;
readonly size: number;
}
export interface CreateFeedbackUploadUrlInput {
readonly feedbackId: number;
readonly filename: string;
readonly size: number;
readonly sha256: string;
}
export interface CreateFeedbackUploadUrlResult {
readonly uploadId: number;
readonly parts: readonly FeedbackUploadPart[];
}
export interface CompletedUploadPart {
readonly partNumber: number;
readonly etag: string;
}
export interface CompleteFeedbackUploadUrlInput {
readonly uploadId: number;
readonly parts: readonly CompletedUploadPart[];
}
export interface FeedbackUploadUrlApi {
createUploadUrl(input: CreateFeedbackUploadUrlInput): Promise<CreateFeedbackUploadUrlResult>;
completeUpload(input: CompleteFeedbackUploadUrlInput): Promise<void>;
}
export interface UploadArchiveOptions {
/** Zip entry name sent to the backend. */
readonly filename: string;
/** Abort a single part PUT if it does not complete within this many milliseconds. */
readonly timeoutMs?: number;
/** Number of parts to upload concurrently (defaults to 3). */
readonly concurrency?: number;
/** Per-part retry attempts after the first failure (defaults to 3). */
readonly maxRetries?: number;
/** Called after each part finishes with the cumulative uploaded bytes. */
readonly onProgress?: (uploadedBytes: number, totalBytes: number) => void;
}
export async function uploadArchive(
api: FeedbackUploadUrlApi,
archive: FeedbackArchive,
feedbackId: number,
options: UploadArchiveOptions,
): Promise<void> {
if (archive.size > MAX_ARCHIVE_SIZE) {
throw new Error(
`Failed to upload archive: size ${archive.size} exceeds maximum allowed size ${MAX_ARCHIVE_SIZE}.`,
);
}
const created = await api.createUploadUrl({
feedbackId,
filename: options.filename,
size: archive.size,
sha256: archive.sha256,
});
const completed = await uploadParts(archive.path, created.parts, archive.size, options);
await api.completeUpload({ uploadId: created.uploadId, parts: completed });
}
interface PartLayout {
readonly part: FeedbackUploadPart;
readonly start: number;
}
function layoutParts(parts: readonly FeedbackUploadPart[]): PartLayout[] {
const sorted = parts.toSorted((a, b) => a.partNumber - b.partNumber);
let offset = 0;
return sorted.map((part) => {
const start = offset;
offset += part.size;
return { part, start };
});
}
async function uploadParts(
filePath: string,
parts: readonly FeedbackUploadPart[],
totalBytes: number,
options: UploadArchiveOptions,
): Promise<CompletedUploadPart[]> {
const layout = layoutParts(parts);
const results: CompletedUploadPart[] = Array.from({ length: layout.length });
const concurrency = Math.max(1, Math.min(options.concurrency ?? DEFAULT_CONCURRENCY, layout.length));
let nextIndex = 0;
let uploadedBytes = 0;
async function worker(): Promise<void> {
while (true) {
const index = nextIndex;
nextIndex += 1;
if (index >= layout.length) return;
const entry = layout[index];
if (entry === undefined) return;
const completed = await uploadOnePartWithRetry(filePath, entry, options);
results[index] = completed;
uploadedBytes += entry.part.size;
options.onProgress?.(uploadedBytes, totalBytes);
}
}
await Promise.all(Array.from({ length: concurrency }, () => worker()));
return results;
}
async function uploadOnePartWithRetry(
filePath: string,
layout: PartLayout,
options: UploadArchiveOptions,
): Promise<CompletedUploadPart> {
const maxRetries = Math.max(0, options.maxRetries ?? DEFAULT_MAX_RETRIES);
let lastError: unknown;
for (let attempt = 0; attempt <= maxRetries; attempt += 1) {
try {
return await uploadOnePart(filePath, layout, options);
} catch (error) {
lastError = error;
if (attempt === maxRetries || !isRetryable(error)) break;
await sleep(RETRY_BASE_DELAY_MS * 2 ** attempt);
}
}
throw lastError;
}
async function uploadOnePart(
filePath: string,
layout: PartLayout,
options: UploadArchiveOptions,
): Promise<CompletedUploadPart> {
const { part, start } = layout;
const timeoutMs = options.timeoutMs ?? DEFAULT_PART_TIMEOUT_MS;
const controller = new AbortController();
const timer = setTimeout(() => {
controller.abort();
}, timeoutMs);
const stream = createReadStream(filePath, { start, end: start + part.size - 1 });
try {
const res = await fetch(part.url, {
method: part.method,
body: Readable.toWeb(stream),
headers: { 'Content-Length': String(part.size) },
duplex: 'half',
signal: controller.signal,
} as RequestInit);
if (!res.ok) {
const text = await res.text().catch(() => '');
throw new UploadPartHttpError(part.partNumber, res.status, text);
}
const etag = res.headers.get('etag');
if (etag === null || etag.length === 0) {
throw new Error(`Failed to upload part ${part.partNumber}: missing ETag in response.`);
}
return { partNumber: part.partNumber, etag };
} catch (error) {
stream.destroy();
if (error instanceof Error && error.name === 'AbortError') {
throw new Error(`Failed to upload part ${part.partNumber}: upload timed out.`, { cause: error });
}
throw error;
} finally {
clearTimeout(timer);
}
}
class UploadPartHttpError extends Error {
constructor(
readonly partNumber: number,
readonly status: number,
readonly responseBody: string,
) {
super(
`Failed to upload part ${partNumber}: HTTP ${String(status)}${responseBody.length > 0 ? ` ${responseBody}` : ''}`,
);
}
}
function isRetryable(error: unknown): boolean {
if (error instanceof UploadPartHttpError) {
return error.status >= 500 || error.status === 408 || error.status === 429;
}
// Network errors and timeouts are retryable.
return true;
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => {
setTimeout(resolve, ms);
});
}

View file

@ -1 +0,0 @@
export declare const VIS_WEB_GZIP_B64: string;

View file

@ -23,7 +23,6 @@ import {
} from '@moonshot-ai/kimi-telemetry';
import { createProgram } from './cli/commands';
import { finalizeHeadlessRun } from './cli/headless-exit';
import type { CLIOptions } from './cli/options';
import { OptionConflictError, validateOptions } from './cli/options';
import { runPrompt } from './cli/run-prompt';
@ -39,22 +38,7 @@ import { cleanupStaleNativeCacheForCurrent } from './native/native-assets';
import { installNativeModuleHook } from './native/module-hook';
import { runNativeAssetSmokeIfRequested } from './native/smoke';
/**
* Outcome of a CLI command run, reported back to the process entrypoint.
*
* `handleMainCommand` is a reusable, unit-tested handler it must not terminate
* the process itself. It reports here whether a headless (`kimi -p`) run
* completed so the entrypoint (the only place that owns the process) can arm the
* force-exit fallback.
*/
export interface MainCommandOutcome {
readonly headlessCompleted: boolean;
}
export async function handleMainCommand(
opts: CLIOptions,
version: string,
): Promise<MainCommandOutcome> {
export async function handleMainCommand(opts: CLIOptions, version: string): Promise<void> {
let validated: ReturnType<typeof validateOptions>;
try {
validated = validateOptions(opts);
@ -76,11 +60,10 @@ export async function handleMainCommand(
if (validated.uiMode === 'print') {
await runPrompt(validated.options, version);
return { headlessCompleted: true };
return;
}
await runShell(validated.options, version);
return { headlessCompleted: false };
}
/** `kimi migrate`: launch the migration screen only, then exit. */
@ -156,42 +139,17 @@ export function main(): void {
const program = createProgram(
version,
(opts) => {
void handleMainCommand(opts, version)
.then(async (outcome) => {
// Only the process entrypoint disposes of the process. Print mode
// relies on the event loop draining to exit; flush any buffered output
// and then arm an unref'd fallback so a stray ref'd handle left over
// from the run can't wedge a completed `kimi -p` until an external
// timeout. A healthy run drains and exits before the fallback fires.
if (outcome.headlessCompleted) {
await finalizeHeadlessRun(
process,
[process.stdout, process.stderr],
() => Number(process.exitCode) || 0,
);
}
})
.catch(async (error: unknown) => {
// Set the failure exit code synchronously, before any `await`. The
// terminal `process.exit(1)` below is our intended exit, but it sits
// behind `await logStartupFailure(...)`; by the time we reach that
// await, the failed run's `finally` cleanup has already torn down its
// ref'd handles (sockets, timers, background tasks). If the event loop
// drains during the await, Node exits on its own with the DEFAULT code
// 0 and `process.exit(1)` never runs — headless (`kimi -p`) failures
// would then exit 0 nondeterministically. Setting `process.exitCode`
// up front makes that drain-exit report failure too.
process.exitCode = 1;
const operation = opts.prompt !== undefined ? 'run prompt' : 'start shell';
await logStartupFailure(operation, error);
process.stderr.write(
formatStartupError(error, {
operation,
}),
);
process.stderr.write(`See log: ${resolveGlobalLogPath(resolveKimiHome())}\n`);
process.exit(1);
});
void handleMainCommand(opts, version).catch(async (error: unknown) => {
const operation = opts.prompt !== undefined ? 'run prompt' : 'start shell';
await logStartupFailure(operation, error);
process.stderr.write(
formatStartupError(error, {
operation,
}),
);
process.stderr.write(`See log: ${resolveGlobalLogPath(resolveKimiHome())}\n`);
process.exit(1);
});
},
() => {
void handleMigrateCommand(version).catch(async (error: unknown) => {

View file

@ -11,7 +11,7 @@
* This file implements the ask, progress, and result phases. `beginMigration`
* drives the real runMigration flow (injectable for tests).
*/
import { Container, matchesKey, Key, truncateToWidth, type Focusable } from '@moonshot-ai/pi-tui';
import { Container, matchesKey, Key, truncateToWidth, type Focusable } from '@earendil-works/pi-tui';
import chalk from 'chalk';
import type { ColorPalette } from '#/tui/theme/colors';

View file

@ -1,8 +1,6 @@
import { existsSync } from 'node:fs';
import { createRequire } from 'node:module';
import { join } from 'node:path';
import { getNativePackageRoot } from './native-assets';
import { loadNativePackage } from './native-require';
type ModuleLoad = (request: string, parent: unknown, isMain: boolean) => unknown;
@ -12,16 +10,7 @@ interface ModuleWithLoad {
const nodeRequire = createRequire(import.meta.url);
let installed = false;
// pi-tui loads its platform-specific native helpers via an absolute-path
// require() computed from import.meta.url / process.execPath
// (see pi-tui dist/terminal.js and dist/native-modifiers.js). In a SEA binary
// those .node files live in the native-asset cache, so redirect any absolute
// require of a pi-tui native helper to the cached copy.
//
// Path shape: native/<darwin|win32>/prebuilds/<arch>/<file>.node — note the
// two path segments after "prebuilds", so ".+" (not "[^/]+") is required.
const PI_TUI_NATIVE_PATTERN = /native[\\/](?:win32|darwin)[\\/]prebuilds[\\/].+\.node$/;
let loadingNativePackage = false;
export function installNativeModuleHook(): void {
if (installed) return;
@ -37,18 +26,13 @@ export function installNativeModuleHook(): void {
parent: unknown,
isMain: boolean,
): unknown {
if (
typeof request === 'string' &&
PI_TUI_NATIVE_PATTERN.test(request) &&
!existsSync(request)
) {
const pkgRoot = getNativePackageRoot('@moonshot-ai/pi-tui');
if (pkgRoot !== null) {
const match = request.match(PI_TUI_NATIVE_PATTERN);
if (match !== null) {
const redirected = join(pkgRoot, match[0]);
return originalLoad.call(this, redirected, parent, isMain);
}
if (request === 'koffi' && !loadingNativePackage) {
loadingNativePackage = true;
try {
const pkg = loadNativePackage<unknown>('koffi');
if (pkg !== null) return pkg;
} finally {
loadingNativePackage = false;
}
}
return originalLoad.call(this, request, parent, isMain);

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