diff --git a/.agents/skills/agent-core-dev/SKILL.md b/.agents/skills/agent-core-dev/SKILL.md index d0f24b29c..a7ec68c25 100644 --- a/.agents/skills/agent-core-dev/SKILL.md +++ b/.agents/skills/agent-core-dev/SKILL.md @@ -43,7 +43,7 @@ End-to-end procedures that span the stages. Reach for these before reading the s - 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: [Permission](permission.md) — risk-only chain-of-responsibility kernel, harness constraints and product reviews as domain `onBeforeExecuteTool` veto listeners (`veto` / `allow` / `pass` / cold `waitUntil` factories), shared `toolApproval` round-trip, policy registry + composer, `modes`/`agentTypes` metadata, `resolveExecution`/`accesses`. - Topic: [Telemetry](telemetry.md) — emitting events via `ITelemetryService`, context propagation, and appender destinations (`ConsoleAppender` / `CloudAppender`). - [Stage 4 — Test](test.md): resolve the system under test by interface, pick `TestInstantiationService` vs `createScopedTestHost`, shared stubs, service groups, teardown. - [Stage 5 — Verify & submit](verify.md): `lint:domain`, `typecheck`, `test`, and the pre-submit checklist. diff --git a/.agents/skills/agent-core-dev/config.md b/.agents/skills/agent-core-dev/config.md index 477fd05ec..ac00d1313 100644 --- a/.agents/skills/agent-core-dev/config.md +++ b/.agents/skills/agent-core-dev/config.md @@ -92,7 +92,7 @@ pass `ConfigTarget.Memory` for a per-run override that is never written to disk. - `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`. +A domain that owns a section keeps the schema in its own `configSection.ts` (e.g. `src/flag/flag.ts` for `experimental`, `src/loop/configSection.ts` for `loopControl`). Exception: kosong-owned sections (`providers`, `models`, `thinking`) — kosong is a pure, persistence-free abstraction layer that defines only the types (`src/kosong/{provider,model}`); the section constants, the zod schemas (re-derived from those types and compile-time pinned via `AssertExact, Type>>`, see `_base/utils/typeEquality.ts`), the registrations, env bindings, and TOML transforms all live in the persistence wrapper `src/app/kosongConfig/configSection.ts`. (`modelCatalog` has no kosong-side type at all — its section is fully self-contained in `app/kosongConfig`.) A cross-section env overlay (e.g. the `KIMI_MODEL_*` synthesis) lives in the wrapper too (`src/app/kosongConfig/envOverlay.ts`) and is registered via `IConfigRegistry.registerEffectiveOverlay`. The two-way sync between config sections and kosong's in-memory registries is owned by `IKosongConfigService` (`src/app/kosongConfig/kosongConfigService.ts`). ## Scope @@ -150,8 +150,18 @@ Each field is an `EnvBinding` — a string (env var name) or 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`. +`stripEnv(value, raw?, getEnv?)` removes env-derived fields before `set`/`replace` +persists, so env overrides never leak into `config.toml`. `raw` is the section's +env-free camelCase base (already `fromToml`-normalized, so legacy key renames +are honored), and `getEnv` reads the live env bag. For fields that are **both +user-persistable and env-overridable**, register +`stripEnv: stripEnvBoundFields(sectionEnvBindings)` (from `#/app/config/config`) +— it derives the guard from the same bindings the read path uses: while a +field's env var resolves to a value, writes restore the field's raw-base value +(or drop it) instead of persisting an echoed env value; an env value that +fails the binding's `parse` owns nothing, so writes pass through. Env-only +fields/sections need no env check — strip them unconditionally (e.g. thinking's +`forcedEffort`, cron's whole-section `() => undefined`). Business domains read `config.get('section')`; they never read env directly, and never write their own env-merge logic. @@ -217,34 +227,27 @@ This means registration order is never a correctness concern — you do not need - **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: +`ConfigService` keeps four 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. +- `validated` — validated `raw`, env-free; the base every live env re-application starts from, so a degraded or removed env value falls back to the file instead of a stale overlay. +- `effective` — `validated` plus the env overlay, recomputed on load/set; `get()`/`getAll()` re-apply the overlay on a fresh `validated` copy per read rather than caching it. ### `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. +When `KIMI_MODEL_NAME` is set, the `kosongConfig` wrapper's `kimiModelEnvOverlay` (`src/app/kosongConfig/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) +## Ownership map (generated) -| 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 | +The authoritative, always-current list of registered sections — rendered in the on-disk `config.toml` shape, with owner file, scope, defaults, env bindings, and schema fields — is generated from the live registry: + +- `packages/agent-core-v2/docs/config-manifest.toml` (checked in; do not edit by hand). +- Regenerate with `pnpm --filter @moonshot-ai/agent-core-v2 gen:config-manifest` (add `--check` for a freshness check; `test/app/config/configManifest.test.ts` enforces it in CI). `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. diff --git a/.agents/skills/agent-core-dev/design.md b/.agents/skills/agent-core-dev/design.md index 8bdb21762..c9b4c2f0c 100644 --- a/.agents/skills/agent-core-dev/design.md +++ b/.agents/skills/agent-core-dev/design.md @@ -243,7 +243,7 @@ domain: `sessionLifecycle` (owning scope: App) ├─ 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 + ├─ workspace @App direct — resolves a session's workspace └─ event @App direct — broadcasts session-level facts (e.g. archived) ``` diff --git a/.agents/skills/agent-core-dev/domain-boundaries.md b/.agents/skills/agent-core-dev/domain-boundaries.md index 0f7236e7a..c2f62cc9c 100644 --- a/.agents/skills/agent-core-dev/domain-boundaries.md +++ b/.agents/skills/agent-core-dev/domain-boundaries.md @@ -46,7 +46,7 @@ Before introducing `I{Domain}EntityService`, classify the persistence model: | **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` | +| **Registry / catalog** | Global or scoped known items | `workspace`, `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. @@ -101,7 +101,7 @@ The `session` domain owns only Session-level identity, metadata, lifecycle comma | Background tasks | `background` | | Cron tasks | `cron` | | Pending approvals / questions | `interaction` / `approval` / `question` | -| Workspace | `workspaceRegistry` | +| Workspace | `workspace` | | Provider / config | `provider` / `config` | Entity-service conclusion for `session`: diff --git a/.agents/skills/agent-core-dev/edge-exposure.md b/.agents/skills/agent-core-dev/edge-exposure.md index 1cc6b25be..738326d1a 100644 --- a/.agents/skills/agent-core-dev/edge-exposure.md +++ b/.agents/skills/agent-core-dev/edge-exposure.md @@ -56,11 +56,11 @@ Read = `GET`, write = `POST`. `sid` = `session_id`, `aid` = `agent_id`. | `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 | +| `workspaces` | `list` | IWorkspaceService.list | GET | +| `workspaces` | `get` | IWorkspaceService.get | GET | +| `workspaces` | `createOrTouch` | IWorkspaceService.createOrTouch | POST | +| `workspaces` | `update` | IWorkspaceService.update | POST | +| `workspaces` | `delete` | IWorkspaceService.delete | POST | | `config` | `get` / `getAll` / `inspect` | IConfigService.* | GET | | `config` | `set` / `replace` / `reload` | IConfigService.* | POST | | `providers` | `list` / `get` | IProviderService.* | GET | diff --git a/.agents/skills/agent-core-dev/flags.md b/.agents/skills/agent-core-dev/flags.md index abae89ac3..e4c33d8b2 100644 --- a/.agents/skills/agent-core-dev/flags.md +++ b/.agents/skills/agent-core-dev/flags.md @@ -11,7 +11,7 @@ Gate not-yet-public features behind `IFlagService.enabled(id)`, per the reposito - `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//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`. +- `src//flag.ts` — each domain that owns a flag declares it here and calls `registerFlagDefinition` at the module top level (e.g. `src/agent/toolSelect/flag.ts` or `src/agent/faultInjection/flag.ts`). The directory already names the domain, so the file is just `flag.ts`. ## Public surface diff --git a/.agents/skills/agent-core-dev/orient.md b/.agents/skills/agent-core-dev/orient.md index 3047c43d4..402ff9304 100644 --- a/.agents/skills/agent-core-dev/orient.md +++ b/.agents/skills/agent-core-dev/orient.md @@ -60,7 +60,7 @@ So a Session-scoped service is not "L1" — e.g. `session` is Session-scoped but |---|---|---| | 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` | +| L2 | data & cross-cutting capabilities | `records`, `wireRecord`, `config`, `provider`, `auth`, `workspace` | | L3 | registries & capabilities | `tool`, `toolRegistry`, `permission*`, `flag`, `skill`, `plugin` | | L4 | agent behaviour | `turn`, `loop`, `prompt`, `profile`, `contextMemory`, `goal`, `plan`, `swarm` | | L5 | async lifecycle | `background`, `mcp`, `cron`, `agentTool` | diff --git a/.agents/skills/agent-core-dev/permission.md b/.agents/skills/agent-core-dev/permission.md index 7db146b9c..0a786e4ba 100644 --- a/.agents/skills/agent-core-dev/permission.md +++ b/.agents/skills/agent-core-dev/permission.md @@ -4,6 +4,8 @@ The target design for the agent-core permission system. Read this when touching > **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. > +> **The chain adjudicates risk only.** A policy node answers "how dangerous is this call, and may the user override that judgment?" — its `ask`/`deny` outcomes are always user-overridable. **Harness constraints are not permissions**: a mechanism that limits the agent for its own correctness (plan-mode write guard, AgentSwarm batch exclusivity, btw side-question fork, goal budget rejection) produces a hard deny with no ask channel and no per-call user exemption. Those live in their owning domains as `onBeforeExecuteTool` veto listeners that call `event.veto(...)` (precedent: `goalService.ts`'s budget/stale rejection). Product reviews (plan review, goal-start review) are likewise not permissions: the owning domain intercepts its tool with a cold `event.waitUntil(factory)` and drives the shared `IAgentToolApprovalService` round-trip itself, so the review only starts once no other listener vetoed the call. +> > **Do not introduce Casbin** — the hard part here is *decision behavior* (continuations, side effects, RPC, state machines), not "match + scalar decision". ## 1. Problem definition @@ -56,7 +58,7 @@ Casbin = single Strategy + data-driven. This design = multiple Strategies + chai 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". +3. **Guard/review off-chain, risk on-chain:** harness constraints and product reviews ship with their owning domain as `onBeforeExecuteTool` veto listeners (§5.4); risk dimensions contributed by a domain self-register as chain policies in DI, mirroring v2's "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 @@ -106,23 +108,23 @@ Key points: 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 +### 5.4 Domain dimensions: guard/review via the executor veto event, policy registration for risk -Mirrors v2's "domain registers tools in its constructor". `PlanService` self-registers its dimensions: +**Harness constraints and product reviews no longer live on the chain.** A domain that owns one registers an `onBeforeExecuteTool` veto listener and adjudicates through the event: ```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)) }); +// src/plan/planService.ts — constructor +constructor(@IAgentToolExecutorService executor, ...) { + executor.onBeforeExecuteTool((event) => this.guardToolExecution(event)); } ``` -A complex domain may register a single **composite** node externally and run a small internal chain, hiding its internal order from the global chain. +- The veto event carries no id and no ordering contract. Listeners answer with `event.veto(result)` (first one wins, ends adjudication), `event.allow()` (final pass, ends everything including the permission gate's own listener), `event.pass(metadata)` (pass with an `executionMetadata` trace, ends nothing), or `event.waitUntil(factory)` (defer to a cold factory). +- **Guard** (hard deny): call `event.veto(denyToolExecution(toolApproval.formatDenyMessage(...)))`. An immediate veto suppresses every pending `waitUntil` factory, so a deny can never be preceded by someone else's approval prompt. +- **Review** (product approval): intercept the tool with `event.waitUntil(() => ...requestToolApproval(event, ask, origin))`. The factory is cold — the executor only invokes it after every listener ran without a veto or an allow, so the review's Interaction starts only once the call is otherwise clear to proceed; abstain (no statement) for every case you do not review so user rules still apply. +- **Plain allow**: do NOT `allow()` casually — prefer putting the tool in `default-tool-approve`'s whitelist so user deny/ask rules keep their precedence; reserve `allow()` for cases like the plan-file write guard that must bypass even the permission chain. + +**Risk dimensions contributed by a domain still go through the chain** (the registry path below): a domain whose state changes the *risk* verdict registers its policy via `IPermissionPolicyRegistry`, mirroring v2's "domain self-registers tools". 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`) @@ -170,37 +172,42 @@ Each new resource kind can pair with a generic dimension that consumes it; tools ### 5.6 Dimension ownership -| Dimension | Owner (who registers) | Type | +| Dimension | Owner | 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 | +| tool-batch exclusivity | `swarm` domain — `onBeforeExecuteTool` veto listener | harness constraint (off-chain) | +| plan-mode write guard | `plan` domain — `onBeforeExecuteTool` veto listener | harness constraint (off-chain) | +| plan review | `plan` domain — same listener's `waitUntil` + `toolApproval` | product review (off-chain) | +| goal-start review | `goal` domain — veto listener's `waitUntil` + `toolApproval` | product review (off-chain) | +| goal budget / stale rejection | `goal` domain — `onBeforeExecuteTool` veto listener | harness constraint (off-chain) | +| btw tool disablement | `btw` domain — veto listener on the fork | harness constraint (off-chain) | +| runtime-mode posture (auto/yolo) | `permissionMode` domain (chain nodes, pending the level×routing split) | generic | | 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) | +| tool intrinsic risk | core permission (`default-tool-approve`) | generic (consumes tool declarations) | | workspace write trust | generic "file-access/security" dimension | generic (consumes `accesses`) | | fallback | core permission | generic | +| approval round-trip | `toolApproval` domain — shared by gate asks and domain reviews | infrastructure | -Pattern: **specific dimensions ship with their owning domain + tool; generic dimensions register centrally and apply across tools via the declared `accesses`.** +Pattern: **harness constraints and reviews ship with their owning domain as `onBeforeExecuteTool` veto listeners; risk dimensions ship as chain policies (self-registered once the registry lands); 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. +1. ~~**Sink domain dimensions.**~~ **Done** — plan guard/review, goal-start review, swarm batch exclusivity, and btw deny-all moved out of the chain into their owning domains as `onBeforeExecuteTool` veto listeners (immediate `veto` / `allow` / `pass` statements plus cold `waitUntil` factories for approval round-trips); the shared approval round-trip was extracted to `IAgentToolApprovalService`; `registerPolicy` was removed (btw was its only production user). The chain now holds 12 risk-adjudication nodes only. +2. **Level × routing split.** Separate "risk level" (read-only / read-write / yolo posture — what `yolo-mode-approve` really is) from "interaction routing" (what `auto-mode-approve` / `auto-mode-ask-user-question-deny` really are: route permission asks and reviews without the user). The routing layer lands on the `session/approval` broker; the three remaining mode policies leave the chain here. +3. **Registry + Composer.** Replace the hardcoded `new`s in `PermissionPolicyService` with reads from `IPermissionPolicyRegistry`; lift mode guards into `modes` metadata. Chain shape becomes selectable per `(agent, mode)` and externally extensible. 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 adjudicates risk only. A node whose deny/ask the user cannot per-call exempt is a harness constraint: implement it as an `onBeforeExecuteTool` veto listener in the owning domain (`event.veto(...)` / `event.allow()`), never as a chain policy. +- Product reviews (plan/goal) are not permissions either: the owning domain intercepts its tool with a cold `event.waitUntil(factory)` and drives `IAgentToolApprovalService` itself; the gate only handles chain asks. - 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. +- New specifics go through the data path (rules); only new risk behavior goes through the code path (a policy node). - 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. diff --git a/.agents/skills/agent-core-dev/server-align.md b/.agents/skills/agent-core-dev/server-align.md index 82b9188f1..f7976b7c9 100644 --- a/.agents/skills/agent-core-dev/server-align.md +++ b/.agents/skills/agent-core-dev/server-align.md @@ -67,7 +67,7 @@ Self-check: "would a released v1 client get a byte-identical envelope from `pack 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 A — the v2 native Service already matches the v1 contract.** Use it directly. Most data/command Services (`IConfigService`, `IWorkspaceService`, `IApprovalService`, `IQuestionService`, `IFileStore`, …) land here: the route is a thin adapter that resolves the scope, calls the method, and wraps the result. Examples: `routes/config.ts`, `routes/messages.ts`, `routes/questions.ts`, `routes/files.ts`. **Case B — the v1 contract needs behavior that would distort the v2 domain.** Introduce a **`*LegacyService`** — an L7 edge adapter that implements the v1 contract **on top of** the v2 native Service, leaving the native Service untouched. The v2 native Service keeps serving `/api/v2`; the LegacyService serves `/api/v1`. diff --git a/.agents/skills/agent-core-dev/telemetry.md b/.agents/skills/agent-core-dev/telemetry.md index 5eaa27413..0d04dc5da 100644 --- a/.agents/skills/agent-core-dev/telemetry.md +++ b/.agents/skills/agent-core-dev/telemetry.md @@ -2,13 +2,14 @@ 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. +Telemetry is a **layer-1 root** domain (alongside `log`): the facade lives at `App` scope (a per-Agent ambient context service is bound at `Agent` scope), stateless, with 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/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`. Agent-scope events register with `defineAgentTelemetryEvent

` and compose the ambient `AgentTelemetryEventContext` (`agent_id`) into their wire schema; all other events register with `defineTelemetryEvent

`. - `src/app/telemetry/telemetryService.ts`: `TelemetryService` impl + `registerScopedService(LifecycleScope.App, …)`. +- `src/app/telemetry/agentTelemetryContext.ts` + `agentTelemetryContextService.ts`: `IAgentTelemetryContextService` — Agent-scoped mutable request context (`mode` / `provider_type` / `protocol` / `turn_id` / `trace_id`) snapshot into turn telemetry at launch. Agent identity (`agent_id`) is not part of it — identity is bound by the Agent-scoped `ITelemetryService` view. - `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`. @@ -26,20 +27,20 @@ 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

({ 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. +`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, then register it with `defineAgentTelemetryEvent

({ owner, comment, properties })` when every emission path goes through an Agent-scoped `ITelemetryService` view, or `defineTelemetryEvent

` otherwise (including events with any non-Agent emission path, e.g. `image_compress` from the kap-server prompt routes), documenting every property. For agent-scope events the registered interface is the business payload only: ambient `agent_id` is declared once in `AgentTelemetryEventContext` and composed into the wire schema, so it must not appear in the payload or at call sites. 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) +### Context (sessionId / agent_id / turn_id) -The service carries a bound context (`sessionId` / `agentId` / `turnId`) that is merged into every event. Bind it at construction or derive a scoped view: +The root service carries a bound context (`sessionId`) that is merged into every event, and each Agent scope gets its own telemetry view seeded with `agent_id` (by `agentLifecycle`), so Agent-scoped services emit their identity without call-site plumbing. Mutable per-agent request context (`mode` / `provider_type` / `protocol` / `turn_id` / `trace_id`) lives in `IAgentTelemetryContextService` and is snapshot into a per-turn view at turn launch. Derive a scoped view with `withContext`: ```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 +const child = telemetry.withContext({ agent_id: 'agent-0' }); +child.track2('tool_call', { turn_id: 1, tool_call_id: 'c1', tool_name: 'bash', outcome: 'success', duration_ms: 12 }); // wire carries sessionId + agent_id ``` -`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`. +`withContext(patch)` returns a lightweight forwarding view: transport state (appenders, enabled flag) stays with the root, so later `addAppender` / `setEnabled` calls apply to every view, and per-call properties override bound context on key collision. `setContext(patch)` on the root mutates the root context and propagates to appenders that implement `setContext`; on a view it mutates only that view's own context. ## Appenders (destinations) @@ -88,8 +89,9 @@ telemetry.addAppender(new CloudAppender({ // production ## 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`. +- Telemetry is layer-1 root: do not inject any business-domain service into it, and keep the facade at `App` scope (only the ambient context service binds at `Agent`). - 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`). +- Agent identity is ambient: agent-scope events go through `defineAgentTelemetryEvent` and get `agent_id` from the scoped telemetry view — do not pass `agent_id` at business call sites (per-event identities such as `subagent_created` and the cron events are the exception). diff --git a/.agents/skills/gen-changesets/SKILL.md b/.agents/skills/gen-changesets/SKILL.md index b5fb0fd50..800d0c3d7 100644 --- a/.agents/skills/gen-changesets/SKILL.md +++ b/.agents/skills/gen-changesets/SKILL.md @@ -21,7 +21,7 @@ All other `@moonshot-ai/*` packages are treated as internal packages, including 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. 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. +6. `@moonshot-ai/vis` / `vis-server` / `vis-web` are ignored by changesets and should not be handled. `@moonshot-ai/kimi-inspect` (a private dev app that never ships) is likewise ignored and must never appear in a changeset frontmatter. ## Workflow diff --git a/.changeset/align-v2-print-run-lifecycle.md b/.changeset/align-v2-print-run-lifecycle.md new file mode 100644 index 000000000..647a4ff2a --- /dev/null +++ b/.changeset/align-v2-print-run-lifecycle.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Align `kimi -p` on the experimental engine with the default engine's run lifecycle: the print background policy now defaults to steer with no practical turn or time cap, background task and per-turn step limits are lifted unless configured, and the run stays alive while cron tasks still have future fires so their steered turns can run. diff --git a/.changeset/config.json b/.changeset/config.json index 0f9da8d75..9aaa6ce29 100644 --- a/.changeset/config.json +++ b/.changeset/config.json @@ -9,7 +9,8 @@ "ignore": [ "@moonshot-ai/vis", "@moonshot-ai/vis-server", - "@moonshot-ai/vis-web" + "@moonshot-ai/vis-web", + "@moonshot-ai/kimi-inspect" ], "snapshot": { "useCalculatedVersion": true, diff --git a/.changeset/debug-zip-timestamped-filename.md b/.changeset/debug-zip-timestamped-filename.md deleted file mode 100644 index 3ddb2f955..000000000 --- a/.changeset/debug-zip-timestamped-filename.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@moonshot-ai/kimi-code": patch ---- - -Fix `/export-debug-zip` and `kimi export` overwriting the previous ZIP archive when run repeatedly on the same session; the default export filename now includes a timestamp. diff --git a/.changeset/kosong-persistence-bridge.md b/.changeset/kosong-persistence-bridge.md new file mode 100644 index 000000000..d6d58e93f --- /dev/null +++ b/.changeset/kosong-persistence-bridge.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Decouple provider and model management from config persistence on the experimental engine: the runtime keeps its own provider/model registry, and a dedicated sync layer hydrates it from config.toml at startup and writes runtime changes (added providers, discovered models, default-model selection) back to disk. diff --git a/.changeset/mcp-global-timeouts.md b/.changeset/mcp-global-timeouts.md new file mode 100644 index 000000000..16a9f1b15 --- /dev/null +++ b/.changeset/mcp-global-timeouts.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": minor +--- + +Add global default MCP server timeouts in `config.toml` and env vars. diff --git a/.changeset/plan-revision-blobs.md b/.changeset/plan-revision-blobs.md new file mode 100644 index 000000000..2c3b1a3f8 --- /dev/null +++ b/.changeset/plan-revision-blobs.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Record plan content as versioned facts: each plan review submission offloads the document to a versioned per-agent plan directory and journals a reference record, so the transcript surfaces plan revisions (marker + badge with review path) and rebuilds them after restarts. diff --git a/.changeset/record-unexecuted-tool-calls.md b/.changeset/record-unexecuted-tool-calls.md deleted file mode 100644 index b23bf49d3..000000000 --- a/.changeset/record-unexecuted-tool-calls.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@moonshot-ai/kimi-code": patch ---- - -Fix repeated request rejections after an interrupted model response by recording tool calls that never ran and closing them with an interrupted result. diff --git a/.changeset/remove-superpowers-tip.md b/.changeset/remove-superpowers-tip.md new file mode 100644 index 000000000..59ba5fb9a --- /dev/null +++ b/.changeset/remove-superpowers-tip.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Remove the toolbar tip that suggested trying the "superpowers" plugin. diff --git a/.changeset/secure-fetch-url-ssrf.md b/.changeset/secure-fetch-url-ssrf.md deleted file mode 100644 index eba3c7622..000000000 --- a/.changeset/secure-fetch-url-ssrf.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@moonshot-ai/kimi-code": patch ---- - -Fix the built-in URL fetch tool's network safeguards: crafted domains and redirect chains can no longer reach loopback or internal network services. diff --git a/.changeset/session-work-changed-global-push.md b/.changeset/session-work-changed-global-push.md new file mode 100644 index 000000000..1717fa9a6 --- /dev/null +++ b/.changeset/session-work-changed-global-push.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Push every session's work status (busy, pending interaction, last turn outcome) to all connected WebSocket clients without requiring a per-session subscription, and serve the same aggregate on the session list API. diff --git a/.changeset/slim-reset-baseline.md b/.changeset/slim-reset-baseline.md new file mode 100644 index 000000000..40fdeb059 --- /dev/null +++ b/.changeset/slim-reset-baseline.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Stop embedding historical turns in the transcript WS baseline reset; it now carries only global state and the stream watermark, and clients page history through the REST transcript API. diff --git a/.changeset/transcript-event-dedup.md b/.changeset/transcript-event-dedup.md new file mode 100644 index 000000000..ee1a23691 --- /dev/null +++ b/.changeset/transcript-event-dedup.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": minor +--- + +Extend the transcript protocol with step usage and timing, streamed tool input and progress, subagent outcomes, agent status, and the prompt queue; WebSocket connections subscribed to the transcript protocol no longer receive the equivalent legacy session events. diff --git a/.changeset/transcript-filter-decouple.md b/.changeset/transcript-filter-decouple.md new file mode 100644 index 000000000..4826ceb66 --- /dev/null +++ b/.changeset/transcript-filter-decouple.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Decouple the transcript WebSocket stream from the legacy agent filter: transcript frames are now governed by the per-agent transcript grades alone, so a connection no longer silently drops transcript data for agents missing from its event allowlist. diff --git a/.changeset/transcript-plan-endpoint.md b/.changeset/transcript-plan-endpoint.md new file mode 100644 index 000000000..2cb95f6a7 --- /dev/null +++ b/.changeset/transcript-plan-endpoint.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": minor +--- + +Add a server endpoint to look up the plan content and review outcome of ExitPlanMode calls. Query `GET /api/v1/sessions/{session_id}/transcript/plan` with `agent_id`, plus an optional `tool_call_id` to narrow to one call. diff --git a/.changeset/transcript-subscribe-v2.md b/.changeset/transcript-subscribe-v2.md new file mode 100644 index 000000000..3c76ab50a --- /dev/null +++ b/.changeset/transcript-subscribe-v2.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": minor +--- + +Move transcript stream subscriptions on the server WebSocket protocol to a dedicated `subscribe_v2` control frame paired with an agent-grained `unsubscribe_v2`; the `transcript` and `transcript_since` fields on `client_hello` and `subscribe` are no longer accepted. diff --git a/.changeset/transcript-user-messages.md b/.changeset/transcript-user-messages.md new file mode 100644 index 000000000..79859ea1c --- /dev/null +++ b/.changeset/transcript-user-messages.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": minor +--- + +Add a session API endpoint that returns all turn-opening user messages of a session, grouped per agent. Query `GET /api/v1/sessions/{session_id}/transcript/user-messages` (optionally with `?agent_id=` for a single agent) to fetch them. diff --git a/.changeset/web-drop-workspace-git-badges.md b/.changeset/web-drop-workspace-git-badges.md deleted file mode 100644 index 49c42059d..000000000 --- a/.changeset/web-drop-workspace-git-badges.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@moonshot-ai/kimi-code": patch ---- - -web: Remove per-workspace git repo badges and branch labels; branch, PR, and diff status remain shown for the active session. diff --git a/.changeset/web-services-env-config.md b/.changeset/web-services-env-config.md new file mode 100644 index 000000000..910966d5b --- /dev/null +++ b/.changeset/web-services-env-config.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": minor +--- + +Add environment variables to configure the web search and web fetch services without OAuth login. diff --git a/.changeset/wire-facts-cold-fold.md b/.changeset/wire-facts-cold-fold.md new file mode 100644 index 000000000..2b09228cb --- /dev/null +++ b/.changeset/wire-facts-cold-fold.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Persist task lifecycle and interaction records in session wire journals, and rebuild tasks, interactions, todos, and goal/plan meta when loading a cold transcript, so transcript state survives server restarts (older sessions are unaffected). diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3ff91c303..07da0bd4f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -28,48 +28,6 @@ jobs: - name: Smoke test CLI bundle run: pnpm -C apps/kimi-code run smoke - vscode-vsix-package: - name: VSIX package audit (${{ matrix.target }}) - runs-on: ${{ matrix.os }} - strategy: - fail-fast: false - matrix: - include: - - os: ubuntu-latest - target: all - - os: macos-latest - target: darwin-arm64 - - os: windows-latest - target: win32-x64 - - 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 - - name: Build and audit target VSIX - run: pnpm --filter kimi-code run package:platform -- --target "${{ matrix.target }}" - - name: Run installed VSIX Extension Host smoke (Linux) - if: runner.os == 'Linux' - run: xvfb-run -a pnpm --filter kimi-code run test:extension-host -- --version 1.100.0 - - name: Run installed VSIX Extension Host smoke on stable (Linux) - if: runner.os == 'Linux' - run: xvfb-run -a pnpm --filter kimi-code run test:extension-host -- --version stable - - name: Run installed VSIX Extension Host smoke - if: runner.os != 'Linux' - run: pnpm --filter kimi-code run test:extension-host -- --version 1.100.0 - - uses: actions/upload-artifact@v4 - with: - name: vscode-vsix-${{ matrix.target }} - path: apps/vscode/artifacts/vsix/*.vsix - if-no-files-found: error - test: runs-on: ubuntu-latest strategy: diff --git a/.github/workflows/desktop-build.yml b/.github/workflows/desktop-build.yml deleted file mode 100644 index 6c993662b..000000000 --- a/.github/workflows/desktop-build.yml +++ /dev/null @@ -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 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e5c4c6d37..7b96653ed 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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: diff --git a/AGENTS.md b/AGENTS.md index da149b954..c4b9d1ef9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -17,14 +17,17 @@ This is a TypeScript monorepo built for agent-assisted development. Keep the roo - `apps/kimi-code`: the CLI / TUI application. The interactive TUI runs on `@moonshot-ai/agent-core-v2` through the in-app facade `src/core/` (`CoreHarness`/`CoreSession`); the remaining paths (`kimi -p`, ACP, misc subcommands) still consume v1 through `@moonshot-ai/kimi-code-sdk`. It must not depend directly on `@moonshot-ai/agent-core` (v1). When writing or modifying its terminal UI, use the `write-tui` skill (`.agents/skills/write-tui/SKILL.md`). - `apps/kimi-web`: the browser web UI, a peer to the TUI. Vue 3 + Vite + vue-i18n; talks to the server over REST + WebSocket under `/api/v1`. It must not depend on `@moonshot-ai/agent-core` (wire types are re-implemented locally). Debug against the two engines via the root `pnpm dev:v1` / `pnpm dev:v2` backend scripts — the dev Sidebar shows the active backend and switches it at runtime. See `apps/kimi-web/AGENTS.md`. - `apps/vis`, `apps/vis/server`, `apps/vis/web`: visual debugging tools for sessions and replays. +- `apps/kimi-inspect`: web inspector for the kap-server `/api/v1/debug` RPC surface — workspace/session browser, per-session chat, and Service panels (data + trigger buttons) for the Session and Agent scopes. A left icon rail (`src/components/NavRail.tsx`) switches top-level views: the Chat workspace, the Model Catalog (`src/components/ModelCatalogView.tsx` — every Provider with its Models and the default marker, via `IModelCatalog` / `IModelService` channel proxies), and App Services (`src/components/AppServicesView.tsx` — the app-scope Service reflection, full width; session/agent scopes stay in the Chat view's right `Inspector`, whose agent tab also carries a Plan lookup card — `PlanCard` in `src/components/Inspector.tsx` — querying `GET /sessions/{id}/transcript/plan` (one tool_call_id, or every plan of the agent) via `src/transcript/api.ts`'s `fetchTranscriptPlan`). Expanding a Model opens the model inspector inside that view: provider/model config layers plus the resolved runtime view with per-value provenance (config / override / builtin / env / synthesized), served on demand by `IModelCatalog.inspect` — the same resolution pass the runtime's `get` serves, traced via `ResolutionTraceCollector` and assembled by `kosong/model/inspection.ts`. Built on its own old-klient-style channel layer (`src/channel/`: the VS Code `ProxyChannel` model — service-bound `IChannel`, HTTP `ProxyChannel` for calls routed to `/api/v1/debug`), typed by `agent-core-v2` Service interfaces; `GET /api/v1/debug/channels` loads the whole wire protocol 1:1 (every scoped Service, no whitelist). There is no Service-event push channel: panels fetch/refresh on demand (`Sidebar` polls react-query on a 15 s interval), and a connection failure shows a blocking "Debug surface unavailable" screen instead of falling back anywhere. Session-level coarse status is the one exception: `src/activity/` holds a second `/api/v1/ws` client (`GlobalEventsWs`) that subscribes to nothing and consumes the server-pushed global facts — `event.session.work_changed` updates a per-session activity map (`SessionActivityHub` + subscribe/version store, seeded on connect/reconnect from `GET /api/v1/sessions`), while `event.session.created` / `session.meta.updated` invalidate the `['sessions']` query; the `Sidebar` session rows render `running` / `approval` / `question` / `failed` badges from it via `useSessionActivities`. The Vite dev server proxies `/api` to a running kap-server (`KIMI_SERVER_URL`, default `http://127.0.0.1:58627`) and exposes `GET /__inspect/servers` (`vite/serverDiscovery.ts`), which scans the local kap-server instance registry (`~/.kimi-code/server/instances` + legacy `lock`) and the home token so the app can zero-config auto-connect and switch servers from the header dropdown at runtime. The per-session chat (`src/components/ChatView.tsx`) renders turn-granularly from the **transcript** surface instead of context memory: full state is read from `GET /api/v1/sessions/{id}/transcript` (initial load = newest page, refreshes re-read from the tail backwards), older history auto-pages with `before_turn` via an IntersectionObserver sentinel at the top of the scroll view, and each timeline item is wrapped in `content-visibility: auto` + `contain-intrinsic-size` so the browser virtualizes off-screen rendering natively (no windowing library); `/api/v1/ws` is an incremental channel (`transcript.ops`, grade `block` — the cheapest grade that still carries whole-state frame upserts, dropping per-token `append` frames; `transcript.reset` is ignored by the store, surfaced only to the audit recorder via the optional `onReset` handler). The channel tracks the op-batch watermark: a dedicated `subscribe_v2` control frame carries the per-agent grades and the `transcript_since` cursor, a seq gap / reconnect / `resync_required` / append gap triggers a point-to-point catch-up (`fetchTranscriptOps` → `GET .../transcript/ops?since_seq=`), and any legacy/incomplete answer falls back to the full REST refresh. Convergence reuses `@moonshot-ai/transcript`'s L2 reducer (`src/transcript/`: REST/WS clients + store; the data model and reducer come from the package, nothing is re-implemented locally). The Transcript audit panel (`src/components/audit/`, always docked right of the chat) replays how the visible store was built: an `AuditTrail` (`src/audit/`) records every step — each REST page (request + replace/prepend), every WS frame (`transcript.ops` live/buffered/flushed/catchup, `transcript.reset`), loss signals, and prompt/cancel actions — with the resulting immutable `AgentState` per entry; the panel offers a draggable timeline plus a Diff tab (structural diff vs the previous entry: added/modified/removed colored, long strings tail-truncated, all fields kept), a full State view, and the raw Event payload. - `packages/agent-core`: the unified agent engine, including Agent, Session, profile, skills, tools, plan, permission, background, records, the in-process DI service layer (`src/services/`), and other core capabilities. - `packages/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/klient`: the client SDK — a contract-driven facade over agent-core-v2 with aggregated `global.*` / `session(id).*` / `agent(id).*` methods, zod validation on every call, and klient-level typed event forwarding. Transport is chosen once at creation via subpath entry (`@moonshot-ai/klient/http|ipc|memory`); all three return the same `Klient`. The package also hosts the e2e suites: dual-backend session/agent suites (`test/e2e/dual/`, in-memory + in-process server), `/api/v2` wire tests (`test/e2e/v2/`), the legacy `/api/v1` live suites (`test/e2e/legacy/`), and the docker e2e runner (`pnpm --filter @moonshot-ai/klient docker:e2e`). See `packages/klient/AGENTS.md`. +- `packages/transcript`: the isomorphic transcript rendering data layer — agent-granular L1 store, idempotent L2 operations, `off/turn/block/delta` L3 subscription granularity, framework-free L4 view registry, and turn-cursor pagination. Pure TypeScript (browser-safe, no engine imports) and the sole owner of all transcript contract types (`src/contract/`); consumed by `packages/kap-server` (engine events → transcript, REST + WS surface; live stores backfill history from the persisted per-agent wire records — main on first attach, any agent on demand, cold sessions rebuild any agent — with 0-based turn ordinals matching the engine's). The cold rebuild is a two-level fold over `wire.jsonl` as the single source of truth: `history/groupTurns.ts` (context messages → turn tree) plus `history/foldFacts.ts` (non-context records → tasks, interactions, todos, goal/plan/swarm meta, and end-appended markers/taskrefs; interactions left pending at shutdown fold to `cancelled`). Plan content is a recorded fact too: each ExitPlanMode review submission offloads the document to `agents//plan//v.md` and persists a reference-only `plan.revision` record (`{id, version, path, sha256, bytes}`), which projects — live and cold — to a `plan.revision` marker and the `modes.plan` badge (`{reviewPath, version}`). It also owns the op-batch sequencing contract (`transcriptSeqSchema` in `contract/schema.ts`): a per-(session, agent) monotonic batch `seq` on `transcript.ops` / `transcript.reset` / the REST transcript response, the `transcript_since` subscription cursor, and the `GET .../transcript/ops` catch-up response shape — every field optional so pre-seq peers fall back to loss-signal-driven refreshes. Beyond the timeline, the model carries wire-equivalent detail: steps carry `usage` / `finishReason` / `timing` (LLM latencies) / `retry` / interrupt reason, turns carry `durationMs` / `error` / `usage`, tool frames carry the streamed `inputText` and the latest `progress`, tasks carry subagent `resultSummary` / `error` / `stateReason` / `usage`, `meta.agent` mirrors the agent status slices (model / usage / context / permission / phase), a global `prompts` entity (op `prompt.upsert`) tracks the prompt queue, and `hook.result` lands as a `'hook'` marker. These live-projected fields are NOT backfilled by the cold rebuild (known limitation). +- `packages/kap-server`: the Kimi Code server, backed by the DI × Scope agent engine (`@moonshot-ai/agent-core-v2`). Exposes sessions over REST + WebSocket (`/api/v1` + `/api/v1/ws`); bootstrapped from `src/start.ts` and consumed by `apps/kimi-code`. The RPC surface is `/api/v1/debug/*` — a reflection dispatcher over the ENTIRE scoped DI registry (every Service callable, no whitelist; `src/transport/registerDebugRoutes.ts` + `serviceDispatcherRoutes.ts`), mounted only with `--debug-endpoints` on a loopback bind and gated by the global bearer auth; repo dev scripts pass the flag. Its transcript surface implements the op-batch sequencing contract: `TranscriptService.dispatchOps` assigns every dispatched batch a per-agent consecutive `seq` and retains it in a bounded in-memory journal (`TRANSCRIPT_OPS_JOURNAL_CAPACITY`, dies with the live store); WS `transcript.ops`/`transcript.reset` payloads carry the seq/watermark, a `transcript_since` subscription cursor (carried, with the per-agent grades, by the `subscribe_v2` control frame — the only transcript subscription channel; its agent-grained counterpart `unsubscribe_v2` detaches listed agents' streams, or the whole session's when `agent_ids` is absent, letting the detached agents' legacy events flow again) replays journaled batches instead of a baseline reset when the journal covers it, and `GET /sessions/{id}/transcript/ops?since_seq=` serves point-to-point catch-up (`complete: false` = journal can't cover or session cold → caller falls back to a full refresh). Beside the paged route, `GET /sessions/{id}/transcript/plan?agent_id=[&tool_call_id=]` projects an agent's ExitPlanMode plan info (content / path / options / review outcome; `tool_call_id` narrows to one call, omitted lists every recoverable plan) from the first available fact — the linked approval interaction's persisted request display, the live tool frame's display, or the tool result output text. The baseline `transcript.reset` itself is items-empty (`TRANSCRIPT_RESET_TAIL_TURNS = 0`): it carries only global state + the watermark + `has_more_older`, because history always pages in over REST. When a WS connection subscribes to the transcript protocol (grade ≠ `off` for an agent), the broadcaster suppresses the transcript-projected `session_event` types for that connection × agent (`TRANSCRIPT_PROJECTED_EVENT_TYPES` + `suppressedByTranscript` in `sessionEventBroadcaster.ts`; cursor replay via `getBufferedSince` applies the same filter). Suppression is only a per-connection send view — the journal still records everything, and connections without transcript grades are unaffected. The session's work aggregate behind `event.session.work_changed` (`busy` / `main_turn_active` / `pending_interaction` / `last_turn_reason`) is owned by the core's `ISessionActivityView` (`sessionActivity` domain, Session scope): the broadcaster only schedules the wire emission around turn frames (`busy:false` lands after `turn.ended`), and `resolveSessionFacts` (`src/routes/sessions.ts`) reads the same view — never fold per-agent activity at the edge. Delivery split on `/api/v1/ws`: global events (`session.meta.updated` and the `event.session.*` / `event.workspace.*` / `event.config.*` families, including every activated session's `event.session.work_changed`) fan out to EVERY established connection — `WsConnectionV1` registers itself via `broadcaster.addGlobalTarget` on construction and unregisters on close — while session/agent-grained events only reach connections subscribed to that session (subject to `agent_filter` and the transcript suppression above); transcript frames are a separate channel governed by the per-agent grades alone and bypass `agent_filter` entirely. +- `packages/klient`: the client SDK — a contract-driven facade over agent-core-v2 with aggregated `global.*` / `session(id).*` / `agent(id).*` methods, zod validation on every call, and klient-level typed event forwarding. Transport is chosen once at creation via subpath entry (`@moonshot-ai/klient/ipc|memory`); both return the same `Klient`. The package also hosts the e2e suites: the legacy `/api/v1` live suites (`test/e2e/legacy/`) and the docker e2e runner (`pnpm --filter @moonshot-ai/klient docker:e2e`). See `packages/klient/AGENTS.md`. +- `packages/server-e2e`: live e2e tests and scenarios against a running server (`KIMI_SERVER_URL`, default `http://127.0.0.1:58627`). See `packages/server-e2e/AGENTS.md`. ## Environment Requirements diff --git a/README.md b/README.md index d17d143e3..e3e17adb8 100644 --- a/README.md +++ b/README.md @@ -19,12 +19,6 @@ Install with the official script. No Node.js required. curl -fsSL https://code.kimi.com/kimi-code/install.sh | bash ``` -- **Homebrew (macOS/Linux)**: - -```sh -brew install kimi-code -``` - - **Windows (PowerShell)**: ```powershell diff --git a/README.zh-CN.md b/README.zh-CN.md index e60fd158e..cf583f9b8 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -22,12 +22,6 @@ Kimi Code CLI 是一个运行在终端里的 AI 编程 agent,可以帮你读 curl -fsSL https://code.kimi.com/kimi-code/install.sh | bash ``` -- **Homebrew(macOS / Linux)**: - -```sh -brew install kimi-code -``` - - **Windows(PowerShell)**: ```powershell diff --git a/apps/kimi-code/CHANGELOG.md b/apps/kimi-code/CHANGELOG.md index b54a7b4e0..7d21def21 100644 --- a/apps/kimi-code/CHANGELOG.md +++ b/apps/kimi-code/CHANGELOG.md @@ -1,5 +1,145 @@ # @moonshot-ai/kimi-code +## 0.29.0 + +### Minor Changes + +- [#1992](https://github.com/MoonshotAI/kimi-code/pull/1992) [`a8f1ca3`](https://github.com/MoonshotAI/kimi-code/commit/a8f1ca3f1016a3e84986f297367e833bc731ac39) Thanks [@RealKai42](https://github.com/RealKai42)! - Support selecting a thinking effort level from ACP clients: the thinking picker now lists the current model's declared levels (for example off / low / medium / high) instead of only an on/off toggle. Use the thinking selector in your ACP client (e.g. Zed) to pick a level; the legacy on/off values keep working. + +- [#1735](https://github.com/MoonshotAI/kimi-code/pull/1735) [`ce0e3ce`](https://github.com/MoonshotAI/kimi-code/commit/ce0e3ceb04223bdaad8e8931bad46eff561055b6) Thanks [@7Sageer](https://github.com/7Sageer)! - Let custom agent files restrict which sub-agent types they may delegate to (v2 engine only). + +- [#1735](https://github.com/MoonshotAI/kimi-code/pull/1735) [`ce0e3ce`](https://github.com/MoonshotAI/kimi-code/commit/ce0e3ceb04223bdaad8e8931bad46eff561055b6) Thanks [@7Sageer](https://github.com/7Sageer)! - Support custom agents defined as Markdown files with frontmatter, usable as the main agent or a sub-agent (v2 engine only). + +- [#1735](https://github.com/MoonshotAI/kimi-code/pull/1735) [`ce0e3ce`](https://github.com/MoonshotAI/kimi-code/commit/ce0e3ceb04223bdaad8e8931bad46eff561055b6) Thanks [@7Sageer](https://github.com/7Sageer)! - Add global tool gating to constrain which tools agents may use, with a per-session override (v2 engine only). + +- [#2012](https://github.com/MoonshotAI/kimi-code/pull/2012) [`d67a200`](https://github.com/MoonshotAI/kimi-code/commit/d67a2003abf2d8d802dcf24f806e0a811724b83e) Thanks [@sailist](https://github.com/sailist)! - Add a GET /api/v1/fs:content server endpoint that serves any file on the host by absolute path as raw content with Content-Type, ETag, and Range support. + +- [#1999](https://github.com/MoonshotAI/kimi-code/pull/1999) [`4c763f6`](https://github.com/MoonshotAI/kimi-code/commit/4c763f6763acb67a73d133f7450d092e71d63692) Thanks [@RealKai42](https://github.com/RealKai42)! - Videos attached to a prompt — pasted in the TUI or uploaded in the web UI — now reach the model together with the prompt, with no extra tool round trip, and stay playable in the chat after a reload. + +- [#1735](https://github.com/MoonshotAI/kimi-code/pull/1735) [`ce0e3ce`](https://github.com/MoonshotAI/kimi-code/commit/ce0e3ceb04223bdaad8e8931bad46eff561055b6) Thanks [@7Sageer](https://github.com/7Sageer)! - Support overriding the default main-agent system prompt with a user-level file for every session (v2 engine only). + +### Patch Changes + +- [#1997](https://github.com/MoonshotAI/kimi-code/pull/1997) [`74da87a`](https://github.com/MoonshotAI/kimi-code/commit/74da87a457c2964694a844dd22a4925f5113b167) Thanks [@sailist](https://github.com/sailist)! - Add agent.created and agent.disposed events to the server session event stream, and expose each agent's disposal time in the transcript API. + +- [#2030](https://github.com/MoonshotAI/kimi-code/pull/2030) [`ec88d35`](https://github.com/MoonshotAI/kimi-code/commit/ec88d352e8f4dc5e8ffd1212f016138458f69893) Thanks [@RealKai42](https://github.com/RealKai42)! - Fix catalog-imported Claude models being wrongly locked into always-on thinking, and stop offering a misleading thinking Off option for models that cannot truly disable reasoning (such as Gemini 3). Also normalizes configured thinking effort values and unifies context-usage reporting. + +- [#2015](https://github.com/MoonshotAI/kimi-code/pull/2015) [`b5efba7`](https://github.com/MoonshotAI/kimi-code/commit/b5efba7abcaf4041f81ec520097a61e6546e8c50) Thanks [@RealKai42](https://github.com/RealKai42)! - Import many more providers from the models.dev catalog: vendor SDKs like xai and openrouter now import instead of being refused (with a "guessed" note), deprecated and alpha models are filtered out, per-model gateway protocol and endpoint overrides are honored, and context limits are correct (input limit for compaction, total window for completion). Imports lacking a usable endpoint now ask for one via `--base-url` or a prompt. + +- [#1993](https://github.com/MoonshotAI/kimi-code/pull/1993) [`37eda4e`](https://github.com/MoonshotAI/kimi-code/commit/37eda4e59aebc8ecafa91be3f43f971ed63963a3) Thanks [@RealKai42](https://github.com/RealKai42)! - Add environment variable overrides for agent loop and background task limits. Set KIMI_LOOP_MAX_STEPS_PER_TURN, KIMI_LOOP_MAX_RETRIES_PER_STEP, or KIMI_CODE_BACKGROUND_MAX_RUNNING_TASKS to take priority over the [loop_control] and [background] config. + +- [#1993](https://github.com/MoonshotAI/kimi-code/pull/1993) [`37eda4e`](https://github.com/MoonshotAI/kimi-code/commit/37eda4e59aebc8ecafa91be3f43f971ed63963a3) Thanks [@RealKai42](https://github.com/RealKai42)! - Fix config environment overrides (such as KIMI_IMAGE_MAX_EDGE_PX or KIMI_SUBAGENT_TIMEOUT_MS) being persisted into config.toml by config API writes while the env var is set, and keeping the old value after the env var is changed to an invalid value or removed. + +- [#2050](https://github.com/MoonshotAI/kimi-code/pull/2050) [`8250e59`](https://github.com/MoonshotAI/kimi-code/commit/8250e590f3ed5990c233ef5a2c7666468f0bcb05) Thanks [@sailist](https://github.com/sailist)! - Remove references to the non-existent `kimi resume` command from the scheduled-task tool descriptions. + +- [#1970](https://github.com/MoonshotAI/kimi-code/pull/1970) [`6dd4fd3`](https://github.com/MoonshotAI/kimi-code/commit/6dd4fd33688b37904d5302436fc2daaf09d66c7d) Thanks [@sailist](https://github.com/sailist)! - Fix cancelled model requests being wrapped as retryable provider errors, so interrupting a request no longer triggers silent retries. + +- [#1970](https://github.com/MoonshotAI/kimi-code/pull/1970) [`6dd4fd3`](https://github.com/MoonshotAI/kimi-code/commit/6dd4fd33688b37904d5302436fc2daaf09d66c7d) Thanks [@sailist](https://github.com/sailist)! - Send the session prompt cache key to OpenAI and OpenAI Responses providers, restoring provider-side prompt cache affinity that previously only reached Kimi and Anthropic. + +- [#1999](https://github.com/MoonshotAI/kimi-code/pull/1999) [`4c763f6`](https://github.com/MoonshotAI/kimi-code/commit/4c763f6763acb67a73d133f7450d092e71d63692) Thanks [@RealKai42](https://github.com/RealKai42)! - Fix ReadMediaFile failing on videos when the provider has no file upload channel — such videos now fall back to inline delivery. + +- [#1968](https://github.com/MoonshotAI/kimi-code/pull/1968) [`71bcfba`](https://github.com/MoonshotAI/kimi-code/commit/71bcfba54a6836f4b6d4e26babde67576b293a64) Thanks [@RealKai42](https://github.com/RealKai42)! - Fix sessions getting stuck on every turn with a provider "message must not be empty" error after a content-filtered response. + +- [#2022](https://github.com/MoonshotAI/kimi-code/pull/2022) [`154e082`](https://github.com/MoonshotAI/kimi-code/commit/154e0824880c8573433e4ec7ada083744dbfe9f9) Thanks [@wbxl2000](https://github.com/wbxl2000)! - web: Show transparent images over a checkerboard canvas so white and black content stays visible in both light and dark themes. + +- [#1990](https://github.com/MoonshotAI/kimi-code/pull/1990) [`115b096`](https://github.com/MoonshotAI/kimi-code/commit/115b0968cefede7fac1494c6f0154ea5545a89da) Thanks [@liruifengv](https://github.com/liruifengv)! - Fix goal mode continuation prompts leaking into the transcript when resuming a session. + +- [#1970](https://github.com/MoonshotAI/kimi-code/pull/1970) [`6dd4fd3`](https://github.com/MoonshotAI/kimi-code/commit/6dd4fd33688b37904d5302436fc2daaf09d66c7d) Thanks [@sailist](https://github.com/sailist)! - Rework the model wire layer in the experimental v2 engine into a small set of protocol bases plus declarative provider trait definitions, so adding a provider no longer means copying adapter code, and per-turn request intent (cache key, thinking effort, sampling) flows as request parameters instead of cloned model objects. The never-functional `[platforms]` config section and the `provider.platformId` field are removed; credential resolution is now a two-layer model → provider lookup. + +- [#1976](https://github.com/MoonshotAI/kimi-code/pull/1976) [`e458323`](https://github.com/MoonshotAI/kimi-code/commit/e45832398d0d9cad98dbad1cbf1e5b103a20aace) Thanks [@liruifengv](https://github.com/liruifengv)! - Improve TUI performance and resume speed for long-running sessions. + +- [#1991](https://github.com/MoonshotAI/kimi-code/pull/1991) [`92576e4`](https://github.com/MoonshotAI/kimi-code/commit/92576e4d850ada51a24e72fe76a83cc512df922a) Thanks [@7Sageer](https://github.com/7Sageer)! - Reconnect a dropped MCP server connection automatically when one of its tools is called, and retry the call once. + +- [#1970](https://github.com/MoonshotAI/kimi-code/pull/1970) [`6dd4fd3`](https://github.com/MoonshotAI/kimi-code/commit/6dd4fd33688b37904d5302436fc2daaf09d66c7d) Thanks [@sailist](https://github.com/sailist)! - Add read-only model resolution inspection and a live connectivity probe to the server's RPC surface, reporting per-field value provenance (config, override, builtin, env, synthesized) for internal debugging tools. + +- [#2015](https://github.com/MoonshotAI/kimi-code/pull/2015) [`b5efba7`](https://github.com/MoonshotAI/kimi-code/commit/b5efba7abcaf4041f81ec520097a61e6546e8c50) Thanks [@RealKai42](https://github.com/RealKai42)! - Fix thinking levels being offered for models that do not support them (e.g. phantom levels on Kimi K3): levels now come from each model's declared capabilities. Models that cannot disable reasoning (e.g. gpt-5) no longer offer an Off option, and turning thinking Off on models that support it (e.g. xai grok) now truly disables reasoning. + +- [#1735](https://github.com/MoonshotAI/kimi-code/pull/1735) [`ce0e3ce`](https://github.com/MoonshotAI/kimi-code/commit/ce0e3ceb04223bdaad8e8931bad46eff561055b6) Thanks [@7Sageer](https://github.com/7Sageer)! - Warn when a tool allow/deny list entry can never match any tool, for example a misspelled name (v2 engine only). + +- [#2005](https://github.com/MoonshotAI/kimi-code/pull/2005) [`a3699dd`](https://github.com/MoonshotAI/kimi-code/commit/a3699dd6aa7b41efd3129a117007d195282379fd) Thanks [@7Sageer](https://github.com/7Sageer)! - Add an `active` flag to each tool in the server's tool listing API. + +- [#1995](https://github.com/MoonshotAI/kimi-code/pull/1995) [`73eb5f8`](https://github.com/MoonshotAI/kimi-code/commit/73eb5f89e06fb15d42c7585a147eb1c5caef0725) Thanks [@liruifengv](https://github.com/liruifengv)! - Remove red coloring from syntax highlighting in code previews and markdown code blocks. + +- [#2014](https://github.com/MoonshotAI/kimi-code/pull/2014) [`576d650`](https://github.com/MoonshotAI/kimi-code/commit/576d65038035570bea90b58d5824bcd60ca11258) Thanks [@liruifengv](https://github.com/liruifengv)! - Add a reminder for third-party install sources to use the official installer in the update prompt. + +## 0.28.1 + +### Patch Changes + +- [#934](https://github.com/MoonshotAI/kimi-code/pull/934) [`c5b6103`](https://github.com/MoonshotAI/kimi-code/commit/c5b6103bb9b0a163d48cbce0034c3fc7dea7c344) Thanks [@tt-a1i](https://github.com/tt-a1i)! - Allow ACP sessions to start with configured non-OAuth model credentials instead of requiring terminal login. + +- [#1967](https://github.com/MoonshotAI/kimi-code/pull/1967) [`ad8cc85`](https://github.com/MoonshotAI/kimi-code/commit/ad8cc8525198a08bc1181cee9a15bbb4521cd9bc) Thanks [@sailist](https://github.com/sailist)! - Run web servers foreground-only end to end: the /web slash command now always starts a new server, and the `kimi web kill` / `kimi web ps` subcommands are removed — foreground servers stop with Ctrl+C. `kimi server kill` remains as a deprecated fallback that only stops servers started by a version before 0.28.0. + +- [#1948](https://github.com/MoonshotAI/kimi-code/pull/1948) [`f6f4192`](https://github.com/MoonshotAI/kimi-code/commit/f6f4192957ace3f0cceb734a04b3b26b1d2f88be) Thanks [@sailist](https://github.com/sailist)! - Fix running subagents not observing permission mode switches made after they started. + +## 0.28.0 + +### Minor Changes + +- [#1826](https://github.com/MoonshotAI/kimi-code/pull/1826) [`a41a09c`](https://github.com/MoonshotAI/kimi-code/commit/a41a09c33c8e432fbc306f5882692c967ed5ea17) Thanks [@sailist](https://github.com/sailist)! - Replace the `kimi server` command tree with `kimi web`: the server runs in the foreground (the background daemon and OS-service lifecycle commands are removed), and multiple servers can now share one home directory, each taking the next free port. Manage instances with `kimi web kill [server-id|all]`, `kimi web ps`, and `kimi web rotate-token`; any `kimi server …` invocation prints a deprecation notice and exits 1. + +- [#1933](https://github.com/MoonshotAI/kimi-code/pull/1933) [`11c1683`](https://github.com/MoonshotAI/kimi-code/commit/11c1683a1cd2adab276562419d2d353629063d80) Thanks [@liruifengv](https://github.com/liruifengv)! - Thinking effort persists only levels below the model's top tier (max). + +### Patch Changes + +- [#1867](https://github.com/MoonshotAI/kimi-code/pull/1867) [`3086e47`](https://github.com/MoonshotAI/kimi-code/commit/3086e4703992fbbe7a41379405ee243713ad9ced) Thanks [@RealKai42](https://github.com/RealKai42)! - Rename the stale "afk" reference to "auto" in the built-in MCP config skill guidance. + +- [#1867](https://github.com/MoonshotAI/kimi-code/pull/1867) [`3086e47`](https://github.com/MoonshotAI/kimi-code/commit/3086e4703992fbbe7a41379405ee243713ad9ced) Thanks [@RealKai42](https://github.com/RealKai42)! - Correct the YOLO and Auto permission mode descriptions in CLI --help output and in the ACP session mode selector shown by IDE clients. + +- [#1867](https://github.com/MoonshotAI/kimi-code/pull/1867) [`3086e47`](https://github.com/MoonshotAI/kimi-code/commit/3086e4703992fbbe7a41379405ee243713ad9ced) Thanks [@RealKai42](https://github.com/RealKai42)! - web: Correct the YOLO and Auto permission mode descriptions in the slash command list and the mobile permission sheet. + +- [#1867](https://github.com/MoonshotAI/kimi-code/pull/1867) [`3086e47`](https://github.com/MoonshotAI/kimi-code/commit/3086e4703992fbbe7a41379405ee243713ad9ced) Thanks [@RealKai42](https://github.com/RealKai42)! - Fix the YOLO and Auto permission mode descriptions to match their actual behavior: YOLO auto-approves tool actions but the agent may still ask questions, while Auto is fully autonomous and never asks. + +- [#1867](https://github.com/MoonshotAI/kimi-code/pull/1867) [`3086e47`](https://github.com/MoonshotAI/kimi-code/commit/3086e4703992fbbe7a41379405ee243713ad9ced) Thanks [@RealKai42](https://github.com/RealKai42)! - Correct the YOLO mode notice shown when replaying a session: tool actions are auto-approved, but the agent may still ask questions. + +- [#1843](https://github.com/MoonshotAI/kimi-code/pull/1843) [`a3e773f`](https://github.com/MoonshotAI/kimi-code/commit/a3e773f90ce66abe6db229607440c20769537c93) Thanks [@7Sageer](https://github.com/7Sageer)! - Fix the web backend ignoring symbolic links when loading AGENTS.md files and reading files. + +- [#1940](https://github.com/MoonshotAI/kimi-code/pull/1940) [`d71bf9e`](https://github.com/MoonshotAI/kimi-code/commit/d71bf9e5a56b5978316e715f7c131c784967d562) Thanks [@wbxl2000](https://github.com/wbxl2000)! - web: Add a note in the model switcher that switching models or thinking effort invalidates the existing prompt cache. + +## 0.27.0 + +### Minor Changes + +- [#1822](https://github.com/MoonshotAI/kimi-code/pull/1822) [`a5c568d`](https://github.com/MoonshotAI/kimi-code/commit/a5c568dc7a84962bae70a16858709c453fc90a07) Thanks [@liruifengv](https://github.com/liruifengv)! - Add the /copy slash command to copy the last assistant message to the clipboard. + +- [#1824](https://github.com/MoonshotAI/kimi-code/pull/1824) [`bfecd01`](https://github.com/MoonshotAI/kimi-code/commit/bfecd0128fe7d88971a84095e24ef8a56ba34e71) Thanks [@liruifengv](https://github.com/liruifengv)! - Using an API key for Kimi coding models now also fetches the latest model list automatically. + +### Patch Changes + +- [#1811](https://github.com/MoonshotAI/kimi-code/pull/1811) [`cec15e2`](https://github.com/MoonshotAI/kimi-code/commit/cec15e2188b24e0f904e5ca660a2e72c06364647) Thanks [@liruifengv](https://github.com/liruifengv)! - Fix Esc and Ctrl+C cancelling compaction instead of closing an open /btw panel. + +- [#1806](https://github.com/MoonshotAI/kimi-code/pull/1806) [`9b49694`](https://github.com/MoonshotAI/kimi-code/commit/9b496946dcb3c7fa9507e6d5c251c1941e44a316) Thanks [@sailist](https://github.com/sailist)! - Mount the dev-only /api/v1/debug RPC surface behind the --debug-endpoints flag, exposing every scoped service for local debugging on loopback binds. Pass --debug-endpoints to kimi server run to enable it. + +- [#1788](https://github.com/MoonshotAI/kimi-code/pull/1788) [`365ba00`](https://github.com/MoonshotAI/kimi-code/commit/365ba0001de206863ff1de8e106c85d7f187c192) Thanks [@RealKai42](https://github.com/RealKai42)! - Fix `/export-debug-zip` and `kimi export` overwriting the previous ZIP archive when run repeatedly on the same session; the default export filename now includes a timestamp. + +- [#1840](https://github.com/MoonshotAI/kimi-code/pull/1840) [`fa7e4ba`](https://github.com/MoonshotAI/kimi-code/commit/fa7e4ba4218703bb1ef3112ab2493496983b0539) Thanks [@7Sageer](https://github.com/7Sageer)! - Fix AGENTS.md files installed as symbolic links being ignored by the web backend. + +- [#1829](https://github.com/MoonshotAI/kimi-code/pull/1829) [`1b907b0`](https://github.com/MoonshotAI/kimi-code/commit/1b907b07cdcc0e9cba5203fe40dacae85a4b768d) Thanks [@RealKai42](https://github.com/RealKai42)! - Fix whitespace-only thinking content rendering as a blank bullet line in the transcript, both while streaming and when replaying session history. + +- [#1809](https://github.com/MoonshotAI/kimi-code/pull/1809) [`56a321d`](https://github.com/MoonshotAI/kimi-code/commit/56a321d4d127c0b4cf7a3e15e2959ebf3eded192) Thanks [@sailist](https://github.com/sailist)! - web: Fix duplicate workspace groups on Windows when the same folder is opened with different path spellings, such as a different drive-letter casing; all of the folder's sessions now list under the single merged group. + +- [#1847](https://github.com/MoonshotAI/kimi-code/pull/1847) [`56ba8e0`](https://github.com/MoonshotAI/kimi-code/commit/56ba8e0196a3053ad1115a7e8f8b8c4c0cd1b320) Thanks [@wbxl2000](https://github.com/wbxl2000)! - web: Fix LaTeX formulas rendering as garbled overlapping text when the web UI is accessed over the network; the server's content security policy now allows the inline styles that math and code highlighting rely on, while scripts remain strictly restricted. + +- [#1816](https://github.com/MoonshotAI/kimi-code/pull/1816) [`44f3341`](https://github.com/MoonshotAI/kimi-code/commit/44f334191989183d21920f6867c405581347c748) Thanks [@sailist](https://github.com/sailist)! - Harden the embedded key-value engine's durability: WAL compaction now always terminates under sustained write storms instead of chasing the tail forever, a committed write can no longer slip through a compaction rotation undetected, torn WAL tails no longer misplace later disk-mode value pointers, read-only opens never create or modify database files or compact under a live writer, corrupt index-definition files no longer force a full rebuild, stale compaction temp files are cleaned on open, and the process lock can no longer be taken over by several processes at once. + +- [#1816](https://github.com/MoonshotAI/kimi-code/pull/1816) [`44f3341`](https://github.com/MoonshotAI/kimi-code/commit/44f334191989183d21920f6867c405581347c748) Thanks [@sailist](https://github.com/sailist)! - Speed up the embedded key-value engine under stress: queries with skip/limit now stream candidates instead of decoding every match first, LRU eviction picks victims in O(1) instead of scanning every key, bursts of simultaneously expired TTL keys are drained within seconds, existence checks and size counting no longer read values when they only need metadata, and one oversized token can no longer poison the full-text index. + +- [#1816](https://github.com/MoonshotAI/kimi-code/pull/1816) [`44f3341`](https://github.com/MoonshotAI/kimi-code/commit/44f334191989183d21920f6867c405581347c748) Thanks [@sailist](https://github.com/sailist)! - Cluster readers of the embedded key-value engine now catch up incrementally by replaying only newly appended WAL frames after another process writes, instead of fully reopening the shard on every read; cross-process read latency drops by orders of magnitude at larger shard sizes, and readers still fall back to a full reopen after WAL rotation or truncation. + +- [#1816](https://github.com/MoonshotAI/kimi-code/pull/1816) [`44f3341`](https://github.com/MoonshotAI/kimi-code/commit/44f334191989183d21920f6867c405581347c748) Thanks [@sailist](https://github.com/sailist)! - Keep the embedded key-value engine writable when a WAL compaction rotation fails mid-way instead of wedging it until reopen, stop a rolled-back write from erasing a concurrently committed value for the same key, let the RESP server survive aborted connections, recover after oversized requests, and answer each pipelined command independently, and keep the previous full-text index intact when a postings rebuild fails. + +- [#1808](https://github.com/MoonshotAI/kimi-code/pull/1808) [`b53e00d`](https://github.com/MoonshotAI/kimi-code/commit/b53e00db91872efc602743d07d2283f7938eaea2) Thanks [@wbxl2000](https://github.com/wbxl2000)! - Include the underlying network cause (DNS failure, refused connection, TLS or timeout errors) in OAuth connection error messages instead of a bare "fetch failed". + +- [#1790](https://github.com/MoonshotAI/kimi-code/pull/1790) [`373abb0`](https://github.com/MoonshotAI/kimi-code/commit/373abb02f03ef817e2e1937e1cdc4423ef0cd149) Thanks [@RealKai42](https://github.com/RealKai42)! - Fix repeated request rejections after an interrupted model response by recording tool calls that never ran and closing them with an interrupted result. + +- [#1791](https://github.com/MoonshotAI/kimi-code/pull/1791) [`3144972`](https://github.com/MoonshotAI/kimi-code/commit/31449728b72df94e22bcb2de350a1e7624895e30) Thanks [@sailist](https://github.com/sailist)! - Fix the built-in URL fetch tool's network safeguards: crafted domains and redirect chains can no longer reach loopback or internal network services. + +- [#1787](https://github.com/MoonshotAI/kimi-code/pull/1787) [`319001a`](https://github.com/MoonshotAI/kimi-code/commit/319001ae5cde6df383579214a126564b9ed2b114) Thanks [@sailist](https://github.com/sailist)! - web: Remove per-workspace git repo badges and branch labels; branch, PR, and diff status remain shown for the active session. + +- [#1838](https://github.com/MoonshotAI/kimi-code/pull/1838) [`9e12484`](https://github.com/MoonshotAI/kimi-code/commit/9e1248416faa22d9f0b777b91ad092bbf1e19182) Thanks [@wbxl2000](https://github.com/wbxl2000)! - web: Remember the thinking level per model, fixing an empty and unresponsive thinking picker when the active model does not support a previously stored level. + +- [#1833](https://github.com/MoonshotAI/kimi-code/pull/1833) [`03021b6`](https://github.com/MoonshotAI/kimi-code/commit/03021b6db7166c750dd34043edaa85c423d3202f) Thanks [@wbxl2000](https://github.com/wbxl2000)! - web: Fix queued messages silently re-sending previously uploaded files when a session is reopened. + ## 0.26.0 ### Minor Changes diff --git a/apps/kimi-code/package.json b/apps/kimi-code/package.json index 7b8a06914..13b9c64c3 100644 --- a/apps/kimi-code/package.json +++ b/apps/kimi-code/package.json @@ -1,6 +1,6 @@ { "name": "@moonshot-ai/kimi-code", - "version": "0.26.0", + "version": "0.29.0", "description": "The Starting Point for Next-Gen Agents", "license": "MIT", "author": "Moonshot AI", @@ -37,8 +37,8 @@ "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", + "#/cli/sub/web": "./src/cli/sub/web/index.ts", + "#/cli/sub/web/*": "./src/cli/sub/web/*.ts", "#/generated/vis-web-asset": [ "./src/generated/vis-web-asset.ts", "./src/generated/vis-web-asset.d.ts" @@ -63,9 +63,9 @@ "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": "tsx --tsconfig ./tsconfig.dev.json --import ../../build/register-raw-text-loader.mjs ./src/main.ts web --no-open --debug-endpoints", + "dev:kap-server": "tsx --tsconfig ./tsconfig.dev.json --import ../../build/register-raw-text-loader.mjs ./src/main.ts web --no-open --debug-endpoints", + "dev:kap-server:multi": "tsx --tsconfig ./tsconfig.dev.json --import ../../build/register-raw-text-loader.mjs ./src/main.ts web --no-open --debug-endpoints", "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", diff --git a/apps/kimi-code/scripts/dev-server-restart.mjs b/apps/kimi-code/scripts/dev-server-restart.mjs index 8169925cf..4beb2bc6f 100644 --- a/apps/kimi-code/scripts/dev-server-restart.mjs +++ b/apps/kimi-code/scripts/dev-server-restart.mjs @@ -1,12 +1,12 @@ #!/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. +// Spawns `tsx ./src/main.ts web --no-open …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/web/run.ts) which releases the instance +// registration and closes WS conns before exit, so a fresh start can +// re-acquire 58627 without a stale-entry 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 @@ -31,8 +31,8 @@ const tsxArgs = [ '--import', '../../build/register-raw-text-loader.mjs', './src/main.ts', - 'server', - 'run', + 'web', + '--no-open', ...cliArgs, ]; diff --git a/apps/kimi-code/src/cli/commands.ts b/apps/kimi-code/src/cli/commands.ts index 32a65eb0e..6ed555193 100644 --- a/apps/kimi-code/src/cli/commands.ts +++ b/apps/kimi-code/src/cli/commands.ts @@ -1,6 +1,6 @@ import { CLI_COMMAND_NAME } from '#/constant/app'; import { registerMigrateCommand } from '#/migration/index'; -import { Command, Option } from 'commander'; +import { Command, InvalidArgumentError, Option } from 'commander'; import type { CLIOptions } from './options'; import { registerAcpCommand } from './sub/acp'; @@ -8,8 +8,8 @@ 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'; +import { registerWebCommand } from './sub/web'; export type MainCommandHandler = (opts: CLIOptions) => void; export type MigrateCommandHandler = () => void; @@ -46,8 +46,8 @@ export function createProgram( ) .option('-c, --continue', 'Continue the previous session for the working directory.', false) .addOption(new Option('-C').hideHelp().default(false)) - .option('-y, --yolo', 'Automatically approve all actions.', false) - .option('--auto', 'Start in auto permission mode.', false) + .option('-y, --yolo', 'Auto-approve regular tool calls; the agent may still ask questions.', false) + .option('--auto', 'Start in auto permission mode: fully autonomous, the agent will not ask questions.', false) .addOption( new Option( '-m, --model ', @@ -74,6 +74,33 @@ export function createProgram( .argParser((value: string, previous: string[] | undefined) => [...(previous ?? []), value]) .default([]), ) + .addOption( + new Option( + '--agent ', + 'Agent profile to use for this invocation (v2 engine only). Custom profiles are discovered from agent directories or loaded via --agent-file.', + ) + .argParser((value: string, previous: string | undefined) => { + if (previous !== undefined) { + throw new InvalidArgumentError('--agent may only be specified once.'); + } + return value; + }) + .conflicts('agentFile'), + ) + .addOption( + new Option( + '--agent-file ', + 'Load an agent definition from a Markdown file and select it (v2 engine only).', + ) + .argParser((value: string, previous: string[] | undefined) => { + if ((previous?.length ?? 0) > 0) { + throw new InvalidArgumentError('--agent-file may only be specified once.'); + } + return [value]; + }) + .conflicts('agent') + .default([]), + ) .addOption( new Option( '--add-dir

', @@ -89,7 +116,7 @@ export function createProgram( registerExportCommand(program); registerProviderCommand(program); registerAcpCommand(program); - registerServerCommand(program); + registerWebCommand(program); registerLoginCommand(program); registerDoctorCommand(program); registerVisCommand(program); @@ -133,6 +160,8 @@ export function createProgram( outputFormat: raw['outputFormat'] as CLIOptions['outputFormat'], prompt: raw['prompt'] as string | undefined, skillsDirs: raw['skillsDir'] as string[], + agent: raw['agent'] as string | undefined, + agentFiles: raw['agentFile'] as string[], addDirs: raw['addDir'] as string[], }; diff --git a/apps/kimi-code/src/cli/experimental-v2.ts b/apps/kimi-code/src/cli/experimental-v2.ts index 155325241..f3b2f1095 100644 --- a/apps/kimi-code/src/cli/experimental-v2.ts +++ b/apps/kimi-code/src/cli/experimental-v2.ts @@ -7,7 +7,7 @@ * `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 + * Note: `kimi web` always boots kap-server (the agent-core-v2 engine * server) — it no longer consults this switch. */ diff --git a/apps/kimi-code/src/cli/options.ts b/apps/kimi-code/src/cli/options.ts index ae524abf7..6e422c3e2 100644 --- a/apps/kimi-code/src/cli/options.ts +++ b/apps/kimi-code/src/cli/options.ts @@ -1,3 +1,5 @@ +import { isKimiV2Enabled } from './experimental-v2'; + export type UIMode = 'shell' | 'print'; export type PromptOutputFormat = 'text' | 'stream-json'; @@ -44,6 +46,8 @@ export interface CLIOptions { outputFormat: PromptOutputFormat | undefined; prompt: string | undefined; skillsDirs: string[]; + agent: string | undefined; + agentFiles: string[]; addDirs?: string[]; } @@ -83,6 +87,26 @@ export function validateOptions( if (promptMode && opts.plan) { throw new OptionConflictError('Cannot combine --prompt with --plan.'); } + if (opts.agent !== undefined && opts.agent.trim().length === 0) { + throw new OptionConflictError('Agent cannot be empty.'); + } + if (opts.agentFiles.length > 1) { + throw new OptionConflictError('--agent-file may only be specified once.'); + } + if (opts.agentFiles.some((file) => file.trim().length === 0)) { + throw new OptionConflictError('Agent file path cannot be empty.'); + } + if (opts.agent !== undefined && opts.agentFiles.length > 0) { + throw new OptionConflictError('Cannot combine --agent with --agent-file.'); + } + if ( + (opts.agent !== undefined || opts.agentFiles.length > 0) && + (!promptMode || !isKimiV2Enabled(env)) + ) { + throw new OptionConflictError( + '--agent/--agent-file are only available with the v2 engine (kimi -p with KIMI_CODE_EXPERIMENTAL_FLAG=1).', + ); + } if (promptMode && opts.session === '') { throw new OptionConflictError('Cannot use --session without an id in prompt mode.'); } diff --git a/apps/kimi-code/src/cli/prompt-render.ts b/apps/kimi-code/src/cli/prompt-render.ts index 0ef505810..0e2f35238 100644 --- a/apps/kimi-code/src/cli/prompt-render.ts +++ b/apps/kimi-code/src/cli/prompt-render.ts @@ -25,9 +25,10 @@ interface HookResultEventLike { /** * 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. + * `turn.step.retrying` event fields the stream-json meta line surfaces. Both + * drivers forward retries to `writeRetrying`: v1 from its SDK event stream, + * v2 from the native `turn.step.retrying` `DomainEvent` (same field names), + * after discarding the failed attempt's partial output. */ interface RetryingEventLike { readonly failedAttempt: number; diff --git a/apps/kimi-code/src/cli/run-shell.ts b/apps/kimi-code/src/cli/run-shell.ts index 64f18d490..98bfcdd24 100644 --- a/apps/kimi-code/src/cli/run-shell.ts +++ b/apps/kimi-code/src/cli/run-shell.ts @@ -233,6 +233,13 @@ export async function runShell( } removeCrashHandlers(); restoreStty(); + if (tui.exitForegroundTask !== undefined) { + // `/web` starting a new server: the TUI has shut down cleanly; hand the + // terminal to the foreground server instead of exiting. The task runs + // until the server stops (Ctrl+C), then this process exits. + await tui.exitForegroundTask(exitCode); + return; + } process.exit(exitCode); }; try { diff --git a/apps/kimi-code/src/cli/sub/provider.ts b/apps/kimi-code/src/cli/sub/provider.ts index 509ec2d22..3ade36aa4 100644 --- a/apps/kimi-code/src/cli/sub/provider.ts +++ b/apps/kimi-code/src/cli/sub/provider.ts @@ -21,13 +21,12 @@ import { } from '@moonshot-ai/kimi-code-oauth'; import { applyCatalogProvider, - catalogBaseUrl, catalogProviderModels, CatalogFetchError, createKimiHarness, DEFAULT_CATALOG_URL, fetchCatalog, - inferWireType, + resolveCatalogImport, type Catalog, type CatalogProviderEntry, type KimiConfig, @@ -67,6 +66,7 @@ interface CatalogAddOptions { readonly apiKey?: string; readonly defaultModel?: string; readonly url?: string; + readonly baseUrl?: string; } export async function handleProviderAdd( @@ -276,9 +276,15 @@ export async function handleCatalogList( for (const [id, entry] of entries) { const modelCount = entry.models === undefined ? 0 : Object.keys(entry.models).length; - const wire = inferWireType(entry) ?? '?'; + const resolution = resolveCatalogImport(entry); + const wireLabel = + resolution.kind === 'invalid' + ? '?' + : resolution.guessed + ? `${resolution.wire} (guessed)` + : resolution.wire; deps.stdout.write( - `${id} wire=${wire} models=${String(modelCount)} ${entry.name ?? ''}\n`, + `${id} wire=${wireLabel} models=${String(modelCount)} ${entry.name ?? ''}\n`, ); } } @@ -310,11 +316,37 @@ export async function handleCatalogAdd( deps.exit(1); } - const wire = inferWireType(entry); - if (wire === undefined) { - deps.stderr.write(`Provider "${providerId}" has an unsupported wire type in the catalog.\n`); + const resolution = resolveCatalogImport(entry, opts.baseUrl); + if (resolution.kind === 'invalid') { + switch (resolution.reason) { + case 'unknown-explicit-type': + deps.stderr.write( + `Provider "${providerId}" declares protocol "${entry.type}" in the catalog, which this client version does not support.\n`, + ); + break; + case 'proprietary-sdk': + deps.stderr.write( + `Provider "${providerId}" uses a proprietary SDK this client cannot speak (e.g. Amazon Bedrock or Cohere); it cannot be imported from the catalog.\n`, + ); + break; + case 'empty-base-url': + deps.stderr.write('--base-url cannot be empty.\n'); + break; + case 'placeholder-base-url': + deps.stderr.write( + `Base URL "${opts.baseUrl}" contains an env placeholder. Pass --base-url with the resolved value.\n`, + ); + break; + } deps.exit(1); } + if (resolution.kind === 'needs-base-url') { + deps.stderr.write( + `The catalog does not declare an endpoint for "${providerId}". Pass --base-url (e.g. the vendor's OpenAI-compatible base URL).\n`, + ); + deps.exit(1); + } + const { wire, baseUrl } = resolution; const models = catalogProviderModels(entry); if (models.length === 0) { @@ -346,7 +378,6 @@ export async function handleCatalogAdd( config = await harness.removeProvider(providerId); } - const baseUrl = catalogBaseUrl(entry, wire); // `applyCatalogProvider` always overwrites both `defaultModel` and // `[thinking]`. The values we pass here are temporary; we restore // a consistent state in the post-apply block below. @@ -391,6 +422,11 @@ export async function handleCatalogAdd( deps.stdout.write( `Imported ${displayName} (${providerId}) with ${String(models.length)} model${models.length === 1 ? '' : 's'} from ${url}.\n`, ); + if (resolution.guessed) { + deps.stdout.write( + `Note: the catalog does not declare a protocol for "${providerId}"; guessed "openai". Edit "type" in config.toml if requests fail.\n`, + ); + } if (opts.defaultModel !== undefined) { deps.stdout.write(`Default model set to ${providerId}/${opts.defaultModel}.\n`); } @@ -481,11 +517,15 @@ export function registerProviderCommand(parent: Command, deps?: Partial', 'API key for the provider. Falls back to KIMI_REGISTRY_API_KEY.') .option('--default-model ', 'Mark the imported model as default_model after import.') + .option( + '--base-url ', + 'Override the catalog endpoint. Required when the catalog declares none (or an env placeholder).', + ) .option('--url ', `Override catalog URL. Defaults to ${DEFAULT_CATALOG_URL}.`) .action( async ( providerId: string, - options: { apiKey?: string; defaultModel?: string; url?: string }, + options: { apiKey?: string; defaultModel?: string; url?: string; baseUrl?: string }, ) => { const resolved = resolveDeps(deps); await runAction(resolved, () => @@ -493,6 +533,7 @@ export function registerProviderCommand(parent: Command, deps?: Partial { - 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 { - 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 { - 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. `/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 { - 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 { - 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 ''; - } -} diff --git a/apps/kimi-code/src/cli/sub/server/index.ts b/apps/kimi-code/src/cli/sub/server/index.ts deleted file mode 100644 index 0e162fd59..000000000 --- a/apps/kimi-code/src/cli/sub/server/index.ts +++ /dev/null @@ -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 }; diff --git a/apps/kimi-code/src/cli/sub/server/kill.ts b/apps/kimi-code/src/cli/sub/server/kill.ts deleted file mode 100644 index 92e5f00b0..000000000 --- a/apps/kimi-code/src/cli/sub/server/kill.ts +++ /dev/null @@ -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; - /** 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; - stdout: Pick; - 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 { - 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, -): Promise { - 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 { - 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(), -}; diff --git a/apps/kimi-code/src/cli/sub/server/lifecycle.ts b/apps/kimi-code/src/cli/sub/server/lifecycle.ts deleted file mode 100644 index 84352cebf..000000000 --- a/apps/kimi-code/src/cli/sub/server/lifecycle.ts +++ /dev/null @@ -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; - stderr: Pick; -} - -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 ', `Bind port (default ${DEFAULT_SERVER_PORT})`, String(DEFAULT_SERVER_PORT)) - .option( - '--log-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>, -): Promise { - 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 { - 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 { - try { - return await mgr.status(); - } catch { - return undefined; - } -} - -function withStatusDetails( - result: Record, - status: ServiceStatus | undefined, - fallback?: { host: string; port: number }, -): Record & { 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); -} diff --git a/apps/kimi-code/src/cli/sub/server/ps.ts b/apps/kimi-code/src/cli/sub/server/ps.ts deleted file mode 100644 index 33933e7cd..000000000 --- a/apps/kimi-code/src/cli/sub/server/ps.ts +++ /dev/null @@ -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 { - 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 { - 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)}…`; -} diff --git a/apps/kimi-code/src/cli/sub/server/web-alias.ts b/apps/kimi-code/src/cli/sub/server/web-alias.ts deleted file mode 100644 index eb4831890..000000000 --- a/apps/kimi-code/src/cli/sub/server/web-alias.ts +++ /dev/null @@ -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 }, - ); -} diff --git a/apps/kimi-code/src/cli/sub/server/access-urls.ts b/apps/kimi-code/src/cli/sub/web/access-urls.ts similarity index 97% rename from apps/kimi-code/src/cli/sub/server/access-urls.ts rename to apps/kimi-code/src/cli/sub/web/access-urls.ts index 0c6233fe8..f0edbf909 100644 --- a/apps/kimi-code/src/cli/sub/server/access-urls.ts +++ b/apps/kimi-code/src/cli/sub/web/access-urls.ts @@ -1,7 +1,7 @@ /** * Build the clickable/copyable access URLs for the running server. * - * Shared by the `server run` ready banner and `server rotate-token` so both + * Shared by the `kimi web` ready banner and `kimi web 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. diff --git a/apps/kimi-code/src/cli/sub/web/deprecated-server.ts b/apps/kimi-code/src/cli/sub/web/deprecated-server.ts new file mode 100644 index 000000000..23c8e63b6 --- /dev/null +++ b/apps/kimi-code/src/cli/sub/web/deprecated-server.ts @@ -0,0 +1,38 @@ +/** + * Deprecated `kimi server` shim. + * + * The `kimi server` command tree was replaced by `kimi web` (a foreground + * server opened in the browser). Any `kimi server …` invocation — bare or + * with any legacy subcommand/flags — lands here, prints the deprecation + * notice, and exits 1. The shim itself is scheduled for removal in the next + * major version of Kimi Code. + * + * One subcommand stays functional: `kimi server kill`, the cleanup path for + * background servers started by pre-0.28.0 builds (recorded in the legacy + * single-instance lock, which the instance registry never sees). + */ + +import type { Command } from 'commander'; + +import { registerLegacyKillCommand } from './legacy-kill'; + +export const DEPRECATED_SERVER_NOTICE = + '`kimi server` has been deprecated and no longer works.\n' + + 'Use `kimi web` instead — it runs the local server in the foreground and opens the web UI (`--no-open` to skip).\n' + + 'To stop a server started by a version before 0.28.0, use `kimi server kill`.\n' + + 'This notice will be removed in the next major version of Kimi Code.\n'; + +export function registerDeprecatedServerCommand(program: Command): void { + const server = program + .command('server') + .description('Deprecated — use `kimi web` instead.') + // Swallow every legacy subcommand/flag (`run`, `kill`, `--port`, …) so + // they all land in the same notice instead of a commander parse error. + .allowUnknownOption(true) + .allowExcessArguments(true) + .action(() => { + process.stderr.write(DEPRECATED_SERVER_NOTICE); + process.exit(1); + }); + registerLegacyKillCommand(server); +} diff --git a/apps/kimi-code/src/cli/sub/web/index.ts b/apps/kimi-code/src/cli/sub/web/index.ts new file mode 100644 index 000000000..8cf840671 --- /dev/null +++ b/apps/kimi-code/src/cli/sub/web/index.ts @@ -0,0 +1,27 @@ +/** + * `kimi web` — run the local Kimi server (REST + WebSocket + web UI) in the + * foreground and open the web UI in the default browser. + * + * The command itself is the runner (`kimi web` = start the server + open the + * browser; `--no-open` to skip). The server stays attached to the terminal + * and stops with Ctrl+C, so there is no kill/ps subcommand; the only + * management subcommand is `web rotate-token` (rotate the home-wide bearer + * token). Servers left behind by pre-0.28.0 builds are cleaned up with + * `kimi server kill`. + */ + +import type { Command } from 'commander'; + +import { registerDeprecatedServerCommand } from './deprecated-server'; +import { registerRotateTokenCommand } from './rotate-token'; +import { buildWebCommand } from './run'; + +export function registerWebCommand(program: Command): void { + const web = buildWebCommand( + program + .command('web') + .description('Run the local Kimi server and open the web UI.'), + ); + registerRotateTokenCommand(web); + registerDeprecatedServerCommand(program); +} diff --git a/apps/kimi-code/src/cli/sub/web/legacy-kill.ts b/apps/kimi-code/src/cli/sub/web/legacy-kill.ts new file mode 100644 index 000000000..97cc1675a --- /dev/null +++ b/apps/kimi-code/src/cli/sub/web/legacy-kill.ts @@ -0,0 +1,263 @@ +/** + * `kimi server kill` — deprecated; only stops a server started by an old + * (pre-`kimi web`, i.e. before 0.28.0) build. + * + * Servers started by current builds run in the foreground attached to a + * terminal (Ctrl+C stops them), so they need no kill command. Builds before + * the `kimi web` command tree could leave a background daemon behind; those + * recorded themselves in the legacy single-instance lock at + * `/server/lock`, which the instance registry never sees. + * This command is the cleanup path for exactly those servers. + * + * The kill 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; old builds may not have the route, or may not + * answer at all). + * 2. PID path — signal the pid recorded in the lock (SIGTERM → wait → + * SIGKILL). SIGKILL is the hard guarantee: it cannot be + * caught or ignored. + * + * The lock file is removed once the recorded pid is confirmed dead (or was + * dead already), so the cleanup is complete after one run. + */ + +import { readFile, unlink } from 'node:fs/promises'; +import { join } from 'node:path'; + +import type { Command } from 'commander'; + +import { getDataDir } from '#/utils/paths'; + +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; + +/** + * The first release whose servers run in the foreground (`kimi web`) and + * register under `server/instances/`. Servers from older builds are the only + * ones this command can — and should — kill. + */ +export const LEGACY_SERVER_MAX_VERSION = '0.28.0'; + +/** Deprecation notice printed on every `kimi server kill` run. */ +export const DEPRECATED_KILL_NOTICE = + '`kimi server kill` is deprecated: it only stops servers started by a version before 0.28.0. Servers started by `kimi web` run in the foreground — stop them with Ctrl+C.\n'; + +/** + * The fields of the legacy `/server/lock` this command needs. The full + * on-disk shape also carried `started_at` / `host_version` / `entry`, which + * are irrelevant to killing the process. + */ +export interface LegacyServerLock { + pid: number; + host?: string; + port?: number; +} + +export interface LegacyKillDeps { + /** Read and parse the legacy lock; undefined when missing or unparseable. */ + readLock(): Promise; + /** Delete the lock file. Best-effort semantics live with the caller. */ + removeLock(): Promise; + requestShutdown(origin: string, token: string | undefined): Promise; + /** 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; + stdout: Pick; + stderr: Pick; + now(): number; +} + +export function registerLegacyKillCommand(server: Command): void { + server + .command('kill') + .description( + 'Deprecated — stop a server started by a version before 0.28.0 (recorded in the legacy server lock). Servers started by `kimi web` run in the foreground — stop them with Ctrl+C.', + ) + // Swallow legacy argument shapes (`kimi server kill `, flags): + // the legacy lock records a single server, so they carry no meaning here. + .allowUnknownOption(true) + .allowExcessArguments(true) + .action(async () => { + try { + await handleLegacyKillCommand(DEFAULT_LEGACY_KILL_DEPS); + } catch (error) { + process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`); + process.exit(1); + } + }); +} + +export async function handleLegacyKillCommand(deps: LegacyKillDeps): Promise { + deps.stderr.write(DEPRECATED_KILL_NOTICE); + + const lock = await deps.readLock(); + if (lock === undefined) { + deps.stdout.write('No running legacy Kimi server.\n'); + return; + } + + if (!deps.pidAlive(lock.pid)) { + // Stale lock from a server that died without releasing it; sweep it so the + // cleanup is done in one run. + await deps.removeLock().catch(() => {}); + deps.stdout.write('No running legacy Kimi server.\n'); + return; + } + + const outcome = await killLegacyServer(lock, deps); + await deps.removeLock().catch(() => {}); + deps.stdout.write(`Legacy Kimi server (pid ${String(lock.pid)}) ${outcome}.\n`); +} + +/** + * Kill the locked server via the API path (best-effort graceful shutdown) + * followed by the PID path (SIGTERM → wait → SIGKILL). Resolves with how the + * process went down; throws when the pid survives SIGKILL. + */ +async function killLegacyServer( + lock: LegacyServerLock, + deps: LegacyKillDeps, +): Promise<'stopped' | 'killed'> { + const { pid } = lock; + + // 1. API path — best-effort graceful shutdown. Ignore every outcome: an old + // build may not have the route, may be wedged, or may drop the connection + // as it exits. The bearer token is best-effort too: if it can't be read + // the API call 401s and the PID path below still guarantees the kill. + if (lock.port !== undefined) { + const origin = serverOrigin(lock.host ?? '127.0.0.1', lock.port); + await deps.requestShutdown(origin, deps.resolveToken()).catch(() => {}); + } + + // 2. PID path — SIGTERM, wait, then SIGKILL. + deps.signalPid(pid, 'SIGTERM'); + + if (await waitForExit(pid, TERM_GRACE_MS, deps)) { + return 'stopped'; + } + + deps.signalPid(pid, 'SIGKILL'); + + if (await waitForExit(pid, KILL_GRACE_MS, deps)) { + return 'killed'; + } + + throw new Error( + `Failed to stop legacy Kimi server (pid ${String(pid)}); insufficient permissions?`, + ); +} + +async function waitForExit( + pid: number, + timeoutMs: number, + deps: Pick, +): Promise { + 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 { + 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); + } +} + +/** Path of the legacy single-instance lock under the CLI's data dir. */ +export function legacyLockPath(homeDir: string): string { + return join(homeDir, 'server', 'lock'); +} + +/** Read + decode the legacy lock; undefined on missing/unparseable input. */ +export async function readLegacyLock(lockPath: string): Promise { + let raw: string; + try { + raw = await readFile(lockPath, 'utf8'); + } catch { + return undefined; + } + try { + const parsed = JSON.parse(raw) as Partial<{ pid: unknown; host: unknown; port: unknown }>; + // Only accept a positive safe-integer pid: on POSIX, 0 and negative pids + // have process-GROUP semantics, so signaling a corrupt lock's pid could + // hit this CLI's own group or an unrelated one. + if (typeof parsed.pid !== 'number' || !Number.isSafeInteger(parsed.pid) || parsed.pid <= 0) { + return undefined; + } + return { + pid: parsed.pid, + host: typeof parsed.host === 'string' ? parsed.host : undefined, + port: typeof parsed.port === 'number' ? parsed.port : undefined, + }; + } catch { + return undefined; + } +} + +const DEFAULT_LEGACY_KILL_DEPS: LegacyKillDeps = { + readLock: () => readLegacyLock(legacyLockPath(getDataDir())), + removeLock: () => unlink(legacyLockPath(getDataDir())), + requestShutdown: requestShutdownViaApi, + resolveToken: () => tryResolveServerToken(getDataDir()), + signalPid, + pidAlive, + sleep: (ms) => + new Promise((resolve) => { + setTimeout(resolve, ms); + }), + stdout: process.stdout, + stderr: process.stderr, + now: () => Date.now(), +}; diff --git a/apps/kimi-code/src/cli/sub/server/networks.ts b/apps/kimi-code/src/cli/sub/web/networks.ts similarity index 100% rename from apps/kimi-code/src/cli/sub/server/networks.ts rename to apps/kimi-code/src/cli/sub/web/networks.ts diff --git a/apps/kimi-code/src/cli/sub/server/rotate-token.ts b/apps/kimi-code/src/cli/sub/web/rotate-token.ts similarity index 79% rename from apps/kimi-code/src/cli/sub/server/rotate-token.ts rename to apps/kimi-code/src/cli/sub/web/rotate-token.ts index 0acbdcc7c..eeaa63016 100644 --- a/apps/kimi-code/src/cli/sub/server/rotate-token.ts +++ b/apps/kimi-code/src/cli/sub/web/rotate-token.ts @@ -1,12 +1,12 @@ /** - * `kimi server rotate-token` — generate a new persistent server token. + * `kimi web rotate-token` — generate a new persistent server token. * * Rewrites `/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 { getLiveServerInstance, rotateServerToken } from '@moonshot-ai/kap-server'; import chalk from 'chalk'; import type { Command } from 'commander'; @@ -14,7 +14,6 @@ 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 @@ -35,11 +34,11 @@ export function registerRotateTokenCommand(server: Command): void { // 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)) { + // come from the instance registry; otherwise there is nothing to + // connect to yet. + const instance = await getLiveServerInstance(); + if (instance !== undefined) { + for (const { label, url: href } of accessUrlLines(instance.host, instance.port, token)) { // De-emphasize the `#token=…` fragment so the host/port stands out. const [base, frag] = splitTokenFragment(href); const rendered = diff --git a/apps/kimi-code/src/cli/sub/server/run.ts b/apps/kimi-code/src/cli/sub/web/run.ts similarity index 54% rename from apps/kimi-code/src/cli/sub/server/run.ts rename to apps/kimi-code/src/cli/sub/web/run.ts index b7a9ffb03..1a4fcbaa8 100644 --- a/apps/kimi-code/src/cli/sub/server/run.ts +++ b/apps/kimi-code/src/cli/sub/web/run.ts @@ -1,14 +1,11 @@ /** - * `kimi server run` — starts the local server. + * `kimi web` — run the local server in the foreground and open the web UI. * - * 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`. + * The server always runs in the current process, attached to the terminal, + * and shuts down cleanly on SIGINT/SIGTERM. `--no-open` skips the browser. + * Multiple instances can share the home directory: each registers itself in + * the instance registry and takes the next free port (see kap-server's + * `startServer`). */ import { join } from 'node:path'; @@ -17,7 +14,7 @@ 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 { type Command } from 'commander'; import { CLI_SHUTDOWN_TIMEOUT_MS } from '#/constant/app'; import { getNativeWebAssetsDir } from '#/native/web-assets'; @@ -37,7 +34,6 @@ import { isLoopbackHost, splitTokenFragment, } from './access-urls'; -import { ensureDaemon, type EnsureDaemonResult } from './daemon'; import { type NetworkAddress } from './networks'; import { DEFAULT_FOREGROUND_LOG_LEVEL, @@ -64,10 +60,8 @@ interface RoutedServer { close(): Promise; } -export interface RunCliOptions extends ServerCliOptions { +export interface WebCliOptions extends ServerCliOptions { open?: boolean; - /** Run the server in-process instead of spawning a background daemon. */ - foreground?: boolean; } export interface StartForegroundHooks { @@ -75,16 +69,7 @@ export interface StartForegroundHooks { 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; - }>; +export interface WebCommandDeps { /** Foreground runner; defaults to the real in-process runner when omitted. */ startServerForeground?: ( options: ParsedServerOptions, @@ -119,8 +104,8 @@ 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 { +/** Build the `web` command, mounting the runner action on `cmd` itself. */ +export function buildWebCommand(cmd: Command): Command { return cmd .option( '--port ', @@ -135,11 +120,6 @@ export function buildRunCommand(cmd: Command, options: { defaultOpen: boolean }) '--allowed-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.', @@ -169,30 +149,10 @@ export function buildRunCommand(cmd: Command, options: { defaultOpen: boolean }) '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 ', - 'Idle-shutdown grace in ms (daemon mode, internal).', - ).hideHelp(), - ) - .action(async (opts: RunCliOptions) => { + .option('--no-open', 'Do not open the web UI in the default browser.', true) + .action(async (opts: WebCliOptions) => { try { - await handleRunCommand(opts); + await handleWebCommand(opts); } catch (error) { process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`); process.exit(1); @@ -200,75 +160,36 @@ export function buildRunCommand(cmd: Command, options: { defaultOpen: boolean }) }); } -export async function handleRunCommand( - opts: RunCliOptions, - deps: RunCommandDeps = DEFAULT_RUN_COMMAND_DEPS, +export async function handleWebCommand( + opts: WebCliOptions, + deps: WebCommandDeps = DEFAULT_WEB_COMMAND_DEPS, ): Promise { 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` - ); + const run = deps.startServerForeground ?? startServerForeground; + await run(parsed, { + onReady: (origin) => { + // Resolve the persistent token only once the server is up: a fresh + // server writes `server.token` on first boot, so reading it beforehand + // would miss first-time starts and the browser would hit the auth gate. + // 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 URL. + const token = parsed.dangerousBypassAuth ? undefined : deps.resolveToken?.(); + deps.stdout.write( + parsed.logLevel === DEFAULT_FOREGROUND_LOG_LEVEL + ? formatReadyBanner(origin, parsed.host, { + token, + networkAddresses: deps.networkAddresses, + dangerousBypassAuth: parsed.dangerousBypassAuth, + }) + : formatReadyLine(origin, token, parsed.dangerousBypassAuth), + ); + if (opts.open === true) { + deps.openUrl(token !== undefined ? buildWebUrl(origin, token) : origin); + } + }, + }); } function formatReadyLine( @@ -293,66 +214,27 @@ function formatDangerNoticeLines(): string[] { 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.')}`, + ` ${danger('If you are unsure, stop this process now with ')}${dangerBold('Ctrl+C')}${danger('.')}`, ]; } /** - * `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 { - 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 { - 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). + * `kimi web` — 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 { - return runServerInProcess(options, { daemon: false }, hooks.onReady); + return runServerInProcess(options, 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. + * Start the server in the current process and block until shutdown. + * `onReady` fires once the server is listening. */ async function runServerInProcess( options: ParsedServerOptions, - mode: { daemon: boolean }, onReady?: (origin: string) => void, ): Promise { const version = getVersion(); @@ -363,23 +245,9 @@ async function runServerInProcess( 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 { if (stopping) return; stopping = true; - idle?.cancel(); running?.logger.info({ reason }, 'server shutting down'); try { await running?.close(); @@ -418,22 +286,6 @@ async function runServerInProcess( 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}`, @@ -441,7 +293,7 @@ async function runServerInProcess( close: () => v2.close(), }; - track('server_started', { daemon: mode.daemon }); + track('server_started', { daemon: false }); process.once('SIGINT', () => { void shutdown('SIGINT'); @@ -450,12 +302,7 @@ async function runServerInProcess( 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'); + running.logger.info({ address: running.address }, 'server ready'); onReady?.(running.address); @@ -464,45 +311,6 @@ async function runServerInProcess( }); } -/** - * 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(); } @@ -522,7 +330,7 @@ interface FormatReadyBannerOptions { dangerousBypassAuth?: boolean; } -function formatReadyBanner( +export function formatReadyBanner( origin: string, host: string, opts: FormatReadyBannerOptions = {}, @@ -580,13 +388,13 @@ function formatReadyBanner( // Auxiliary controls last. lines.push(` ${label('Logs: ')}${muted('off')}${dim(' use --log-level info to enable')}`); - lines.push(` ${label('Stop: ')}${muted('kimi server kill')}`); + // The server always runs in the foreground attached to this terminal. + lines.push(` ${label('Stop: ')}${muted('Ctrl+C')}`); lines.push(''); return lines.join('\n'); } -const DEFAULT_RUN_COMMAND_DEPS: RunCommandDeps = { - startServerBackground, +const DEFAULT_WEB_COMMAND_DEPS: WebCommandDeps = { startServerForeground, openUrl: defaultOpenUrl, resolveToken: () => { diff --git a/apps/kimi-code/src/cli/sub/server/shared.ts b/apps/kimi-code/src/cli/sub/web/shared.ts similarity index 56% rename from apps/kimi-code/src/cli/sub/server/shared.ts rename to apps/kimi-code/src/cli/sub/web/shared.ts index bcf96d22c..79dfff7d4 100644 --- a/apps/kimi-code/src/cli/sub/server/shared.ts +++ b/apps/kimi-code/src/cli/sub/web/shared.ts @@ -1,8 +1,7 @@ /** - * Shared helpers for `kimi server …` subcommands. + * Shared helpers for `kimi web` and its subcommands. * - * Owns the default host/port, option parsers, and health/readiness probes that - * `run`, `web`, and `status` all use. + * Owns the default host/port, option parsers, and health/readiness probes. */ import { readFileSync } from 'node:fs'; @@ -22,13 +21,6 @@ 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', @@ -54,18 +46,6 @@ export interface ParsedServerOptions { 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 { @@ -83,24 +63,11 @@ export interface ServerCliOptions { 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, + host: parseHost(opts.host), port: parsePort(opts.port, '--port', DEFAULT_SERVER_PORT), logLevel: parseLogLevel(opts.logLevel ?? DEFAULT_FOREGROUND_LOG_LEVEL), debugEndpoints: opts.debugEndpoints === true, @@ -108,10 +75,7 @@ export function parseServerOptions(opts: ServerCliOptions): ParsedServerOptions allowRemoteShutdown: opts.allowRemoteShutdown === true, allowRemoteTerminals: opts.allowRemoteTerminals === true, dangerousBypassAuth: opts.dangerousBypassAuth === true, - allowedHosts, - keepAlive, - daemon: opts.daemon === true, - idleGraceMs: parseIdleGraceMs(opts.idleGraceMs), + allowedHosts: parseAllowedHostArgs(opts.allowedHost), }; } @@ -129,15 +93,6 @@ function parseHost(raw: string | boolean | undefined): string { 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); @@ -170,76 +125,6 @@ export function normalizeServerOrigin(value: string): string { 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 { - 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 { - 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 { - 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('
${target.version}).\n` + `Detected install source: ${sourceDesc}\n` + - `To update manually, run: ${installCommand}\n` + `To update manually, run: ${installCommand}\n` + + (source === 'homebrew' ? THIRD_PARTY_SOURCE_NOTE : '') ); } diff --git a/apps/kimi-code/src/cli/v2/run-v2-print.ts b/apps/kimi-code/src/cli/v2/run-v2-print.ts index b284511ee..ff0c96c59 100644 --- a/apps/kimi-code/src/cli/v2/run-v2-print.ts +++ b/apps/kimi-code/src/cli/v2/run-v2-print.ts @@ -16,6 +16,8 @@ * Selected by `runPrompt` when `KIMI_CODE_EXPERIMENTAL_FLAG` is set. */ +import { readFile } from 'node:fs/promises'; + import { IAgentGoalService, IAgentLifecycleService, @@ -24,17 +26,25 @@ import { IAgentPromptService, IAgentTaskService, IAuthSummaryService, + IBootstrapService, IConfigService, IEventBus, IOAuthToolkit, + ISessionCronService, ISessionIndex, ISessionLifecycleService, ITelemetryService, + PRINT_MAX_TURNS_DEFAULT, + PRINT_WAIT_CEILING_S_DEFAULT, + agentCatalogRuntimeOptionsSeed, + applyPrintModeConfigDefaults, bootstrap, createCloudAppender, ensureMainAgent, hostRequestHeadersSeed, logSeed, + parseAgentFileText, + resolveAgentPath, resolveAgentTaskConfig, resolveKimiHome, resolveLoggingConfig, @@ -84,10 +94,14 @@ import { } 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; +/** + * Slack on top of a scheduled cron fire time while waiting for the steered + * turn: covers the 1s tick poll interval plus fire → inject → turn-launch + * latency. + */ +const CRON_FIRE_GRACE_MS = 5_000; export async function runV2Print( opts: CLIOptions, @@ -120,11 +134,20 @@ export async function runV2Print( // `--skillsDir` (v1 print parity): explicit skill dirs replace default // user / project discovery for this process. ...skillCatalogRuntimeOptionsSeed(opts.skillsDirs), + // `--agent-file`: explicit agent definition files, registered with the + // highest-precedence source for this process. Passed through unresolved — + // the engine expands `~` and resolves relative paths against the session + // workDir (mirroring `--skills-dir`). + ...agentCatalogRuntimeOptionsSeed(opts.agentFiles), ]); const auth = app.accessor.get(IOAuthToolkit); const configService = app.accessor.get(IConfigService); await configService.ready; + // Print-mode config defaults (task timeouts / loop step cap / subagent + // timeout → unbounded) before anything resolves a session; only keys the + // user left unset are filled, in the memory layer. + await applyPrintModeConfigDefaults(configService); const defaultModel = configService.get('defaultModel') ?? undefined; let telemetryEnabled = true; try { @@ -236,6 +259,63 @@ async function resolveNativeSession( const lifecycle = app.accessor.get(ISessionLifecycleService); const index = app.accessor.get(ISessionIndex); + // `--agent` selects a catalog profile by name; otherwise `--agent-file` + // implicitly selects the profile that file defines. The file + // is parsed here (fatal on error) so a bad file fails before any turn. + let agentProfileName = opts.agent; + const agentFile = opts.agentFiles[0]; + if (agentProfileName === undefined && agentFile !== undefined) { + const agentFilePath = resolveAgentPath( + agentFile, + workDir, + app.accessor.get(IBootstrapService).osHomeDir, + ); + let agentFileText: string; + try { + agentFileText = await readFile(agentFilePath, 'utf8'); + } catch (error) { + throw new Error( + `Failed to read agent file "${agentFilePath}": ${error instanceof Error ? error.message : String(error)}`, + { cause: error }, + ); + } + try { + agentProfileName = parseAgentFileText({ + path: agentFilePath, + source: 'explicit', + text: agentFileText, + }).name; + } catch (error) { + throw new Error( + `Invalid agent file "${agentFilePath}": ${error instanceof Error ? error.message : String(error)}`, + { cause: error }, + ); + } + } + + // `--agent` / `--agent-file` bind an explicit profile; without them the + // historical setModel path (default profile on first bind) is kept. A + // same-name re-select on a resumed session keeps the profile and only applies + // an explicitly requested model; a different name is rejected by the + // engine's first-bind guard inside `bind`. + const applyProfileSelection = async ( + profile: IAgentProfileService, + model: string | undefined, + ): Promise => { + if (agentProfileName !== undefined) { + if (profile.data().profileName === agentProfileName) { + if (model !== undefined) await profile.setModel(model); + return; + } + await profile.bind({ + profile: agentProfileName, + model: requireConfiguredModel(model ?? profile.getModel(), defaultModel), + }); + } else if (model !== undefined) { + await profile.setModel(model); + } + }; + const resumeById = async (id: string): Promise => { const session = await lifecycle.resume(id); if (session === undefined) { @@ -273,9 +353,7 @@ async function resolveNativeSession( 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); - } + await applyProfileSelection(profile, opts.model); const currentModel = profile.getModel(); const { restorePermission } = forceAuto(agent); return { @@ -294,9 +372,7 @@ async function resolveNativeSession( 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); - } + await applyProfileSelection(profile, opts.model); const currentModel = profile.getModel(); const { restorePermission } = forceAuto(agent); return { @@ -314,9 +390,12 @@ async function resolveNativeSession( const session = await lifecycle.create({ workDir, additionalDirs: opts.addDirs?.length ? opts.addDirs : undefined, + mainAgentBinding: { + profile: agentProfileName ?? 'agent', + model, + }, }); const agent = await ensureMainAgent(session); - await agent.accessor.get(IAgentProfileService).setModel(model); agent.accessor.get(IAgentPermissionModeService).setMode('auto'); return { session, @@ -382,11 +461,12 @@ async function runNativeTurn( const configService = app.accessor.get(IConfigService); const taskConfig = resolveAgentTaskConfig(configService); const goalService = agent.accessor.get(IAgentGoalService); + const cronService = session.accessor.get(ISessionCronService); try { await applyPrintBackgroundPolicy({ mode: resolvePrintBackgroundMode(configService), - ceilingS: taskConfig?.printWaitCeilingS ?? DEFAULT_PRINT_WAIT_CEILING_S, - maxTurns: taskConfig?.printMaxTurns ?? DEFAULT_PRINT_MAX_TURNS, + ceilingS: taskConfig?.printWaitCeilingS ?? PRINT_WAIT_CEILING_S_DEFAULT, + maxTurns: taskConfig?.printMaxTurns ?? PRINT_MAX_TURNS_DEFAULT, countPending: () => countPendingBackgroundTasks(session), drain: () => drainBackgroundTasks(session, taskConfig?.printWaitCeilingS), turnEndings, @@ -394,6 +474,7 @@ async function runNativeTurn( warn: (message) => stderr.write(`Warning: ${message}\n`), now: () => Date.now(), goalActive: () => goalService.getGoal().goal?.status === 'active', + cronNextFireAt: () => cronService.getNextFireTime(), }); } catch (error) { // A steered turn that fails fails the run (v1 parity). Anything else @@ -476,6 +557,7 @@ function dispatchNativeEvent( return; case 'turn.step.retrying': writer.discardAssistant(); + writer.writeRetrying(event); return; case 'assistant.delta': writer.writeAssistantDelta(event.delta); @@ -590,54 +672,106 @@ export interface PrintBackgroundPolicyInput { * background policy. */ readonly goalActive?: () => boolean; + /** + * Reports the next scheduled cron fire time (epoch ms), or `null` when no + * cron task has a future fire. While it returns non-null the policy keeps + * the process alive — the cron tick timer itself is unref'd — waiting for + * the fire to steer a new turn, then re-evaluating (a fired one-shot task + * disappears; a recurring one reports its advanced next fire). Cron + * liveness is independent of the background mode: it applies under + * `exit`/`drain` too (v1 parity). Omitted = no cron waiting. + */ + readonly cronNextFireAt?: () => number | null; } /** - * Apply the print-mode (`kimi -p`) background-task policy after the main turn - * completes. Mirrors v1's `Session.handlePrintMainTurnCompleted`: + * Apply the print-mode (`kimi -p`) background-resource policy after the main + * turn completes. A single loop re-evaluates the Session's live resources in + * order on every round and stays alive while any of them is pending: * - 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. + * - cron : while `cronNextFireAt` reports a future fire, keep waiting — + * the cron tick timer is unref'd, so the process must hold the + * event loop itself (v1 parity, independent of the mode). The + * fire steers a new turn; a steered turn that does not complete + * fails the run. Each round re-reads the next fire time, so a + * fired one-shot task ends the wait while a recurring one keeps + * it. A fire time that stays unchanged and in the past across + * two consecutive rounds means the tick is wedged: warn once and + * stop cron waiting instead of spinning. + * - mode : 'exit' → return immediately; + * '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. + * The steer ceiling deadline is set once on entry, so goal/cron waiting + * consumes the same budget. */ export async function applyPrintBackgroundPolicy( input: PrintBackgroundPolicyInput, ): Promise { - 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 deadline = input.now() + input.ceilingS * 1000; + let turns = 0; + // Cron anti-spin guard: the last fire time seen already in the past. Two + // consecutive rounds with the same past fire time mean the tick never ran. + let lastPastFireAt: number | undefined; + let cronWedged = false; + for (;;) { + // (a) goal: while a goal is `active`, keep waiting for its continuation + // turns. 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. A continuation turn that does not complete pauses/blocks + // the goal, so the condition exits on the next check. + while (input.goalActive?.() === true) { const ended = await input.turnEndings.next( - Math.min(goalDeadline - input.now(), GOAL_WAIT_POLL_MS), + Math.min(deadline - input.now(), GOAL_WAIT_POLL_MS), input.skipTurnId, ); - if (ended === null && input.now() >= goalDeadline) { + if (ended === null && input.now() >= deadline) { 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 (;;) { + // (b) cron: keep the process alive until the pending fire steered a turn + // (one-shot tasks vanish after firing; recurring ones advance their next + // fire), then re-evaluate from the top. + if (!cronWedged && input.cronNextFireAt !== undefined) { + const fireAt = input.cronNextFireAt(); + if (fireAt !== null) { + if (fireAt <= input.now() && lastPastFireAt === fireAt) { + cronWedged = true; + input.warn( + 'print cron wait: next fire time stuck in the past; cron tick appears wedged, giving up on cron', + ); + } else { + if (fireAt <= input.now()) lastPastFireAt = fireAt; + const ended = await input.turnEndings.next( + Math.max(fireAt - input.now(), 0) + CRON_FIRE_GRACE_MS, + input.skipTurnId, + ); + if (ended !== null && ended.reason !== 'completed') { + throw new PrintSteeredTurnFailedError(formatTurnEndingFailure(ended)); + } + // Fire observed (or its grace elapsed without a turn): re-read the + // next fire time from the top. + continue; + } + } + } + + // (c) background-task mode. + if (input.mode === 'exit') return; + if (input.mode === 'drain') { + await input.drain(); + return; + } + + // 'steer' turns += 1; if (input.now() >= deadline) { input.warn(`print steer ceiling reached (${input.ceilingS}s), finishing`); @@ -682,7 +816,7 @@ async function drainBackgroundTasks( const ceilingMs = typeof ceilingS === 'number' && Number.isFinite(ceilingS) && ceilingS > 0 ? ceilingS * 1000 - : DEFAULT_PRINT_WAIT_CEILING_S * 1000; + : PRINT_WAIT_CEILING_S_DEFAULT * 1000; const deadline = Date.now() + ceilingMs; const seen = new Set(); diff --git a/apps/kimi-code/src/constant/app.ts b/apps/kimi-code/src/constant/app.ts index 9c33fc65e..b14c7edb5 100644 --- a/apps/kimi-code/src/constant/app.ts +++ b/apps/kimi-code/src/constant/app.ts @@ -7,7 +7,7 @@ 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 +// Telemetry ui_mode for the `kimi web` host. Same product // as the CLI (CLI_USER_AGENT_PRODUCT); the surface is distinguished by ui_mode. export const WEB_UI_MODE = 'web'; @@ -81,6 +81,9 @@ export const KIMI_CODE_PLUGIN_MARKETPLACE_URL = `${KIMI_CODE_CDN_BASE}/plugins/m 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`; export const KIMI_CODE_INSTALL_PS1_URL = `${KIMI_CODE_CDN_BASE}/install.ps1`; +// Official download page, referenced by prompt copy that steers users away +// from third-party install sources. +export const KIMI_CODE_OFFICIAL_INSTALL_URL = 'https://www.kimi.com/code'; // Native install commands, split by platform. Use these for prompt copy and spawn calls only; do not assemble the strings elsewhere. export const NATIVE_INSTALL_COMMAND_UNIX = `curl -fsSL ${KIMI_CODE_INSTALL_SH_URL} | bash`; diff --git a/apps/kimi-code/src/core/catalog.ts b/apps/kimi-code/src/core/catalog.ts index 5da853b40..f70433c46 100644 --- a/apps/kimi-code/src/core/catalog.ts +++ b/apps/kimi-code/src/core/catalog.ts @@ -17,6 +17,7 @@ export { effectiveModelAlias, fetchCatalog, inferWireType, + resolveCatalogImport, type Catalog, type CatalogModel, } from '@moonshot-ai/kimi-code-sdk'; diff --git a/apps/kimi-code/src/core/harness.ts b/apps/kimi-code/src/core/harness.ts index 85f00d9cd..f8b435d81 100644 --- a/apps/kimi-code/src/core/harness.ts +++ b/apps/kimi-code/src/core/harness.ts @@ -24,7 +24,7 @@ import { IBootstrapService, IConfigService, IFlagService, - IModelResolver, + IModelCatalog, IPluginService, IProviderService, ISessionContext, @@ -33,7 +33,7 @@ import { ISessionLifecycleService, ISessionMetadata, ISessionWorkspaceContext, - IWorkspaceRegistry, + IWorkspaceService, logSeed, MAIN_AGENT_ID, resolveConfigPath, @@ -127,6 +127,13 @@ export interface CreateSessionOptions { export interface ResumeSessionInput { readonly id: string; readonly additionalDirs?: readonly string[]; + /** + * Limit each returned agent replay to the most recent N user turns. Omit to + * return the full replay. Lets UI callers that only render the tail skip + * folding and rehydrating the entire history (v1 `ResumeSessionPayload` + * parity, #1976). + */ + readonly replayTurnLimit?: number; readonly sessionStartedProperties?: TelemetryProperties; } @@ -218,7 +225,7 @@ export class CoreHarness { let resolved: Model | undefined; if (model.length > 0) { try { - resolved = app.get(IModelResolver).resolve(model); + resolved = app.get(IModelCatalog).get(model); } catch { // Provider/auth not ready (e.g. logged out): degrade like getStatus. resolved = undefined; @@ -246,7 +253,7 @@ export class CoreHarness { // `ISessionLifecycleService.resume` refuses sessions whose workspace is // unknown to the registry, so skipping this would make the session // impossible to resume later. - await app.get(IWorkspaceRegistry).createOrTouch(options.workDir); + await app.get(IWorkspaceService).createOrTouch(options.workDir); const handle = await app.get(ISessionLifecycleService).create({ sessionId: id, workDir: options.workDir }); try { const main = await ensureMainAgent(handle); @@ -299,7 +306,10 @@ export class CoreHarness { // session_started / session_resume. const active = this.activeSessions.get(id); if (active !== undefined) return active.session; - const session = await this.resumeInternal(id, { additionalDirs: input.additionalDirs }); + const session = await this.resumeInternal(id, { + additionalDirs: input.additionalDirs, + replayTurnLimit: input.replayTurnLimit, + }); this.trackSessionStarted(id, true, input.sessionStartedProperties); this.trackSessionEvent(id, 'session_resume'); return session; @@ -537,7 +547,7 @@ export class CoreHarness { /** Cold-load a session and register it; shared by resume and reload. */ private async resumeInternal( id: string, - input: { additionalDirs?: readonly string[] }, + input: { additionalDirs?: readonly string[]; replayTurnLimit?: number }, ): Promise { const handle = await this.deps.app.accessor.get(ISessionLifecycleService).resume(id); if (handle === undefined) { @@ -548,15 +558,18 @@ export class CoreHarness { for (const dir of input.additionalDirs ?? []) { handle.accessor.get(ISessionWorkspaceContext).addAdditionalDir(dir); } - return this.hydrateSession(handle); + return this.hydrateSession(handle, input.replayTurnLimit); } /** Ensure main, rebuild the resume snapshot, and register a `CoreSession`. */ - private async hydrateSession(handle: ISessionScopeHandle): Promise { + private async hydrateSession( + handle: ISessionScopeHandle, + replayTurnLimit?: number, + ): Promise { const main = await ensureMainAgent(handle); // TODO(v2-gap): G-30 — v2 resume has no warning channel; // `resumeState.warning` stays undefined. - const resumeState = await buildResumedSessionState(handle, main); + const resumeState = await buildResumedSessionState(handle, main, replayTurnLimit); const summary = await this.projectLiveSummary(handle); return this.registerSession(handle, summary, resumeState); } diff --git a/apps/kimi-code/src/core/index.ts b/apps/kimi-code/src/core/index.ts index e094555a0..ec9f31d61 100644 --- a/apps/kimi-code/src/core/index.ts +++ b/apps/kimi-code/src/core/index.ts @@ -16,6 +16,7 @@ export * from './event-types'; export * from './events'; export * from './harness'; export * from './replay'; +export * from './replay-turns'; export * from './session'; export * from './types'; export { diff --git a/apps/kimi-code/src/core/replay-turns.ts b/apps/kimi-code/src/core/replay-turns.ts new file mode 100644 index 000000000..9545e0af2 --- /dev/null +++ b/apps/kimi-code/src/core/replay-turns.ts @@ -0,0 +1,72 @@ +/** + * User-turn boundary detection over an agent's replay records, ported from the + * v1 engine (`agent-core/agent/replay/turns.ts`, #1976) onto the v2 + * `PromptOrigin` union (`task` replaces v1's `background_task`). The v2 facade + * assembles replays app-side (`#/core/replay`), so the predicate lives here + * instead of in the engine package; the TUI consumes it through `#/core/index` + * instead of keeping a local copy. + * + * A record starts a new user turn when it is a user-role message that came + * from an actual user action — a typed prompt, a user-invoked skill/plugin + * slash command, or a `!` shell command's input line. System-originated user + * messages (compaction summaries, cron fires, hook results, retries, goal + * reminders, task results, injections) continue the current turn instead — + * with one exception: `goal_continuation` prompts. The goal driver fires one + * synthetic continuation prompt per goal turn (agent/goal/goalService.ts), and + * the goal system itself counts those as turns, so replay trimming treats them + * as turn boundaries; otherwise a 100-round goal would count as a single user + * turn and resume would replay the entire run. + */ + +import type { AgentReplayRecord } from './types'; + +/** Source of truth for turn-boundary detection. */ +export function isAgentReplayUserTurnRecord(record: AgentReplayRecord): boolean { + if (record.type !== 'message') return false; + const { message } = record; + if (message.role !== 'user') return false; + switch (message.origin?.kind) { + case undefined: + case 'user': + return true; + case 'skill_activation': + return message.origin.trigger === 'user-slash'; + case 'plugin_command': + return message.origin.trigger === 'user-slash'; + case 'shell_command': + // A `!` command's input is a user-turn anchor; its output is not. + return message.origin.phase === 'input'; + case 'task': + case 'compaction_summary': + case 'cron_job': + case 'cron_missed': + case 'hook_result': + case 'injection': + case 'retry': + return false; + case 'system_trigger': + // The goal driver fires one synthetic continuation prompt per goal turn + // (agent/goal/goalService.ts) — real rounds of work the goal system + // itself counts as turns. All other system triggers are reminders that + // continue the current turn. + return message.origin.name === 'goal_continuation'; + } +} + +/** + * Keep only the most recent `maxTurns` user turns of a replay. `undefined` + * keeps the full replay; `0` or negative returns an empty replay. + */ +export function limitAgentReplayByTurns( + records: readonly AgentReplayRecord[], + maxTurns?: number, +): readonly AgentReplayRecord[] { + if (maxTurns === undefined) return records; + if (maxTurns <= 0) return []; + const turnStarts = records.flatMap((record, index) => + isAgentReplayUserTurnRecord(record) ? [index] : [], + ); + if (turnStarts.length <= maxTurns) return records; + // Guarded above: length > maxTurns ≥ 1, so the index is in range. + return records.slice(turnStarts[turnStarts.length - maxTurns]!); +} diff --git a/apps/kimi-code/src/core/replay.ts b/apps/kimi-code/src/core/replay.ts index 2e2745c31..ee3c0c90e 100644 --- a/apps/kimi-code/src/core/replay.ts +++ b/apps/kimi-code/src/core/replay.ts @@ -24,6 +24,7 @@ import { IAgentScopeContext, IAgentSwarmService, IAgentTaskService, + IAgentToolPolicyService, IAgentToolRegistryService, IAgentUsageService, IAppendLogStore, @@ -39,6 +40,7 @@ import { type WireRecord, } from '@moonshot-ai/agent-core-v2'; +import { limitAgentReplayByTurns } from './replay-turns'; import { reduceTranscript, rehydrateTranscript, @@ -56,21 +58,28 @@ import type { /** * Assemble the per-agent resume snapshots. v2 resume restores only the main * agent (subagents are lazily re-created on their next prompt), so the map - * carries a single `main` entry. + * carries a single `main` entry. `replayTurnLimit` trims each replay to the + * most recent N user turns (see `#/core/replay-turns`); omit for the full + * replay. */ export async function buildResumedAgents( session: ISessionScopeHandle, mainAgent: IAgentScopeHandle, + replayTurnLimit?: number, ): Promise> { const { accessor } = mainAgent; const profile = accessor.get(IAgentProfileService); const data = profile.data(); const history = accessor.get(IAgentContextMemoryService).get(); - const replay = await buildReplayFromWireRecords(accessor); + const replay = limitAgentReplayByTurns( + await buildReplayFromWireRecords(accessor), + replayTurnLimit, + ); + const toolPolicy = accessor.get(IAgentToolPolicyService); const tools: Array = accessor .get(IAgentToolRegistryService) .list() - .map((tool) => ({ ...tool, active: profile.isToolActive(tool.name, tool.source) })); + .map((tool) => ({ ...tool, active: toolPolicy.isToolActive(tool.name, tool.source) })); const state: ResumedAgentState = { type: 'main', config: { @@ -172,8 +181,9 @@ function toReplayMessage(message: TranscriptMessage) { export async function buildResumedSessionState( session: ISessionScopeHandle, mainAgent: IAgentScopeHandle, + replayTurnLimit?: number, ): Promise { - const agents = await buildResumedAgents(session, mainAgent); + const agents = await buildResumedAgents(session, mainAgent, replayTurnLimit); const meta = await session.accessor.get(ISessionMetadata).read(); return { sessionMetadata: projectSessionMetadata(meta), agents }; } diff --git a/apps/kimi-code/src/core/session.ts b/apps/kimi-code/src/core/session.ts index 9fb665428..5777f7bf4 100644 --- a/apps/kimi-code/src/core/session.ts +++ b/apps/kimi-code/src/core/session.ts @@ -23,11 +23,12 @@ import { IAgentProfileService, IAgentPromptService, IAgentRPCService, + IAgentShellCommandService, IAgentSwarmService, IAgentTaskService, IAgentUsageService, IConfigService, - IModelResolver, + IModelCatalog, ISessionApprovalService, ISessionBtwService, ISessionContext, @@ -184,13 +185,13 @@ export class CoreSession { ): Promise { const agent = await this.agent(options.agentId); return await agent.accessor - .get(IAgentRPCService) - .runShellCommand({ command, commandId: options.commandId }); + .get(IAgentShellCommandService) + .run({ command, commandId: options.commandId }); } async cancelShellCommand(commandId: string, options?: { agentId?: string }): Promise { const agent = await this.agent(options?.agentId); - await agent.accessor.get(IAgentRPCService).cancelShellCommand({ commandId }); + agent.accessor.get(IAgentShellCommandService).cancel(commandId); } /** Returns the number of history entries actually undone. */ @@ -296,7 +297,7 @@ export class CoreSession { maxContextTokens = 0; } else { try { - maxContextTokens = accessor.get(IModelResolver).resolve(defaultModel).capabilities.max_context_tokens; + maxContextTokens = accessor.get(IModelCatalog).get(defaultModel).capabilities.max_context_tokens; } catch { maxContextTokens = 0; } diff --git a/apps/kimi-code/src/core/types.ts b/apps/kimi-code/src/core/types.ts index 32eb90f73..cd584b172 100644 --- a/apps/kimi-code/src/core/types.ts +++ b/apps/kimi-code/src/core/types.ts @@ -27,7 +27,7 @@ import type { GoalSnapshot, GoalStatus, GoalToolResult, - IAgentRPCService, + IAgentShellCommandService, ModelCapability, PermissionData, PermissionMode, @@ -283,6 +283,7 @@ export type PromptPart = | { type: 'tool_result'; tool_call_id: string; output: unknown; is_error?: boolean } | { type: 'image'; source: PromptPartMediaSource } | { type: 'video'; source: PromptPartMediaSource } + | { type: 'video_url'; videoUrl: { url: string } } | { type: 'file'; file_id: string; name: string; media_type: string; size: number } | { type: 'thinking'; thinking: string; signature?: string }; @@ -291,8 +292,8 @@ type PromptPartMediaSource = | { kind: 'base64'; media_type: string; data: string } | { kind: 'file'; file_id: string }; -/** Result of `CoreSession.runShellCommand` (the v2 RPC facade's shape). */ -export type ShellCommandResult = Awaited>; +/** Result of `CoreSession.runShellCommand` (the v2 shell-command service's shape). */ +export type ShellCommandResult = Awaited>; /** * Session warning surfaced by `getSessionWarnings`. Mirrors the v1 wire diff --git a/apps/kimi-code/src/main.ts b/apps/kimi-code/src/main.ts index d0f36e1db..860c27dc7 100644 --- a/apps/kimi-code/src/main.ts +++ b/apps/kimi-code/src/main.ts @@ -130,6 +130,8 @@ const MIGRATE_CLI_OPTIONS: CLIOptions = { outputFormat: undefined, prompt: undefined, skillsDirs: [], + agent: undefined, + agentFiles: [], }; export function main(): void { diff --git a/apps/kimi-code/src/tui/banner/banner-provider.ts b/apps/kimi-code/src/tui/banner/banner-provider.ts index 087b02934..daf54f952 100644 --- a/apps/kimi-code/src/tui/banner/banner-provider.ts +++ b/apps/kimi-code/src/tui/banner/banner-provider.ts @@ -1,24 +1,29 @@ import { createHash } from 'node:crypto'; -import { gte, valid } from 'semver'; +import { eq, gte, lt, valid } from 'semver'; import { KIMI_CODE_TIPS_BANNER_URL } from '#/constant/app'; import type { BannerDisplay, BannerState } from '#/tui/types'; import type { BannerDisplayState } from './state'; -interface TipsBannerFallbackItem { +interface BannerVersionFields { + banner_min_version?: string | null; + banner_max_version?: string | null; + banner_version?: string | null; +} + +interface TipsBannerFallbackItem extends BannerVersionFields { banner_id?: string | null; enabled?: boolean; banner_title?: string | null; banner_maintext?: string; banner_subtext?: string | null; - banner_min_version?: string | null; banner_display?: unknown; banner_display_ttl_hours?: unknown; } -interface TipsBannerJson { +interface TipsBannerJson extends BannerVersionFields { banner_id?: string | null; banner_enabled?: boolean; banner_title?: string | null; @@ -26,7 +31,6 @@ interface TipsBannerJson { banner_subtext?: string | null; banner_start_time?: string | null; banner_end_time?: string | null; - banner_min_version?: string | null; banner_display?: unknown; banner_display_ttl_hours?: unknown; banner_fallback_enabled?: boolean; @@ -102,13 +106,27 @@ function isWithinWindow(start: Date | null, end: Date | null, now: Date): boolea return true; } -function meetsMinVersion(minVersion: unknown, clientVersion: string): boolean { - if (minVersion === undefined || minVersion === null) return true; - if (typeof minVersion !== 'string' || minVersion.length === 0) return true; - const min = valid(minVersion); +type VersionConstraintCompare = (current: string, target: string) => boolean; + +function meetsVersionConstraint( + constraint: unknown, + clientVersion: string, + compare: VersionConstraintCompare, +): boolean { + if (constraint === undefined || constraint === null) return true; + if (typeof constraint !== 'string' || constraint.length === 0) return true; + const target = valid(constraint); const current = valid(clientVersion); - if (min === null || current === null) return false; - return gte(current, min); + if (target === null || current === null) return false; + return compare(current, target); +} + +function meetsVersion(banner: BannerVersionFields, clientVersion: string): boolean { + return ( + meetsVersionConstraint(banner.banner_min_version, clientVersion, gte) && + meetsVersionConstraint(banner.banner_max_version, clientVersion, lt) && + meetsVersionConstraint(banner.banner_version, clientVersion, eq) + ); } function parseBannerDisplay(value: unknown): BannerDisplay { @@ -179,7 +197,7 @@ function pickActiveBanner( now: Date, ): BannerState | null { if (json.banner_enabled !== true) return null; - if (!meetsMinVersion(json.banner_min_version, clientVersion)) return null; + if (!meetsVersion(json, clientVersion)) return null; const start = parseDate(json.banner_start_time); const end = parseDate(json.banner_end_time); if (!isWithinWindow(start, end, now)) return null; @@ -209,7 +227,7 @@ function pickFallbackCandidates( if (typeof raw !== 'object' || raw === null) continue; const item = raw as TipsBannerFallbackItem; if (item.enabled !== true) continue; - if (!meetsMinVersion(item.banner_min_version, clientVersion)) continue; + if (!meetsVersion(item, clientVersion)) continue; const mainText = normalizeText(item.banner_maintext); if (mainText === null) continue; const display = parseBannerDisplay(item.banner_display); diff --git a/apps/kimi-code/src/tui/commands/config.ts b/apps/kimi-code/src/tui/commands/config.ts index 9fe1e2128..5669c98fa 100644 --- a/apps/kimi-code/src/tui/commands/config.ts +++ b/apps/kimi-code/src/tui/commands/config.ts @@ -61,7 +61,7 @@ function currentTuiConfig(host: SlashCommandHost): TuiConfig { }; } -function effectiveModelForHost(host: SlashCommandHost, model: ModelAlias): ModelAlias { +export function effectiveModelForHost(host: SlashCommandHost, model: ModelAlias): ModelAlias { const providerType = host.state.appState.availableProviders[model.provider]?.type; // Flat models (no named provider, e.g. inline base_url served by a v2 // backend) have no provider entry to look up; their own protocol declaration @@ -148,7 +148,7 @@ export async function handleYoloCommand(host: SlashCommandHost, args: string): P } await session.setPermission('yolo'); host.setAppState({ permissionMode: 'yolo' }); - host.showNotice('YOLO mode: ON', 'AI auto-approves safe actions, asks for approval on risky ones.'); + host.showNotice('YOLO mode: ON', 'Tool actions auto-approved; the agent may still ask you questions.'); return; } @@ -171,7 +171,7 @@ export async function handleYoloCommand(host: SlashCommandHost, args: string): P } else { await session.setPermission('yolo'); host.setAppState({ permissionMode: 'yolo' }); - host.showNotice('YOLO mode: ON', 'AI auto-approves safe actions, asks for approval on risky ones.'); + host.showNotice('YOLO mode: ON', 'Tool actions auto-approved; the agent may still ask you questions.'); } } @@ -192,7 +192,7 @@ export async function handleAutoCommand(host: SlashCommandHost, args: string): P } await session.setPermission('auto'); host.setAppState({ permissionMode: 'auto' }); - host.showNotice('Auto mode: ON', 'Run all actions automatically, including risky ones.'); + host.showNotice('Auto mode: ON', 'All actions auto-approved; the agent will not ask you questions.'); return; } @@ -215,7 +215,7 @@ export async function handleAutoCommand(host: SlashCommandHost, args: string): P } else { await session.setPermission('auto'); host.setAppState({ permissionMode: 'auto' }); - host.showNotice('Auto mode: ON', 'Run all actions automatically, including risky ones.'); + host.showNotice('Auto mode: ON', 'All actions auto-approved; the agent will not ask you questions.'); } } @@ -512,7 +512,12 @@ async function performModelSwitch( let persisted = false; if (persist) { try { - persisted = await persistModelSelection(host, effectiveAlias, effectiveEffort); + persisted = await persistModelSelection( + host, + effectiveAlias, + effectiveEffort, + effectiveEffortChanged, + ); } catch (error) { const msg = formatErrorMessage(error); host.showError(`Switched to ${displayName}, but failed to save default: ${msg}`); @@ -541,14 +546,22 @@ async function persistModelSelection( host: SlashCommandHost, alias: string, effort: ThinkingEffort, + effortChanged: boolean, ): Promise { const config = await host.harness.getConfig({ reload: true }); - const patch = thinkingEffortToConfig(effort); + const model = host.state.appState.availableModels[alias]; + const full = thinkingEffortToConfig( + effort, + model === undefined ? undefined : effectiveModelForHost(host, model).supportEfforts, + ); + // Re-confirming the effort shown when the picker opened is not an explicit + // choice — persist the model but leave the stored effort preference alone. + const patch = effortChanged ? full : { enabled: full.enabled }; const thinking = thinkingView(config); if ( defaultModelView(config) === alias && thinking?.enabled === patch.enabled && - thinking?.effort === patch.effort + (!effortChanged || thinking?.effort === patch.effort) ) { return false; } diff --git a/apps/kimi-code/src/tui/commands/copy.ts b/apps/kimi-code/src/tui/commands/copy.ts new file mode 100644 index 000000000..bb77b2515 --- /dev/null +++ b/apps/kimi-code/src/tui/commands/copy.ts @@ -0,0 +1,41 @@ +import { copyTextToClipboard } from '#/utils/clipboard/clipboard-text'; +import type { TranscriptEntry } from '../types'; +import { formatErrorMessage } from '../utils/event-payload'; +import type { SlashCommandHost } from './dispatch'; + +/** + * Visible text of the last assistant transcript entry, newest first; empty + * string when none. Sourced from the rendered transcript rather than the + * model context so it survives compaction and session resume: after + * `/compact` the context keeps user messages plus a user-role summary only, + * while the last reply is still on screen. Only entries tagged `modelText` + * count — hook-result and goal-completion cards share kind 'assistant' but + * are not replies. + */ +export function findLastAssistantText(entries: readonly TranscriptEntry[]): string { + for (let i = entries.length - 1; i >= 0; i--) { + const entry = entries[i]; + if (entry === undefined || entry.kind !== 'assistant' || entry.modelText !== true) continue; + if (entry.content.trim().length > 0) return entry.content; + } + return ''; +} + +export async function handleCopyCommand(host: SlashCommandHost): Promise { + const text = findLastAssistantText(host.state.transcriptEntries); + if (text.length === 0) { + host.showStatus('No assistant message to copy.', 'warning'); + return; + } + + try { + const method = await copyTextToClipboard(text); + host.showStatus( + method === 'native' + ? `Copied to clipboard (${String(text.length)} characters).` + : `Copied via terminal escape sequence (unverified, ${String(text.length)} characters).`, + ); + } catch (error) { + host.showError(`Failed to copy to clipboard: ${formatErrorMessage(error)}`); + } +} diff --git a/apps/kimi-code/src/tui/commands/dispatch.ts b/apps/kimi-code/src/tui/commands/dispatch.ts index 767bf596e..16a3c3934 100644 --- a/apps/kimi-code/src/tui/commands/dispatch.ts +++ b/apps/kimi-code/src/tui/commands/dispatch.ts @@ -21,6 +21,7 @@ import type { import { formatErrorMessage } from '../utils/event-payload'; import { handleLoginCommand, handleLogoutCommand } from './auth'; import { handleBtwCommand } from './btw'; +import { handleCopyCommand } from './copy'; import { handleAutoCommand, handleCompactCommand, @@ -61,6 +62,7 @@ import { handleWebCommand } from './web'; export { handleLoginCommand, handleLogoutCommand } from './auth'; export { handleBtwCommand } from './btw'; +export { handleCopyCommand } from './copy'; export { handleAddDirCommand } from './add-dir'; export { handleAutoCommand, @@ -140,6 +142,13 @@ export interface SlashCommandHost { // Dispatch stop(exitCode?: number): Promise; setExitOpenUrl(url: string): void; + /** + * Register a task that takes over the process after the TUI has shut down + * (instead of exiting): the runner awaits it and only exits when it returns. + * Used by `/web` to keep a freshly started server attached to this terminal + * until Ctrl+C. + */ + setExitForegroundTask(task: (exitCode: number) => Promise): void; showHelpPanel(): void; createNewSession(): Promise; showSessionPicker(): Promise; @@ -356,6 +365,9 @@ async function handleBuiltInSlashCommand( case 'export-debug-zip': await handleExportDebugZipCommand(host); return; + case 'copy': + await handleCopyCommand(host); + return; case 'login': await handleLoginCommand(host); return; diff --git a/apps/kimi-code/src/tui/commands/index.ts b/apps/kimi-code/src/tui/commands/index.ts index 784ef7e61..7449dba9b 100644 --- a/apps/kimi-code/src/tui/commands/index.ts +++ b/apps/kimi-code/src/tui/commands/index.ts @@ -9,6 +9,7 @@ export * from './types'; export { dispatchInput, type SlashCommandHost } from './dispatch'; export { handleLoginCommand, handleLogoutCommand } from './auth'; export { handleBtwCommand } from './btw'; +export { handleCopyCommand } from './copy'; export { handleCompactCommand, handleEditorCommand, diff --git a/apps/kimi-code/src/tui/commands/prompts.ts b/apps/kimi-code/src/tui/commands/prompts.ts index ac84dff1c..58d5d4f7e 100644 --- a/apps/kimi-code/src/tui/commands/prompts.ts +++ b/apps/kimi-code/src/tui/commands/prompts.ts @@ -1,6 +1,6 @@ import { catalogModelToAlias, - inferWireType, + resolveCatalogImport, type Catalog, type CatalogModel, type ModelAlias, @@ -128,10 +128,35 @@ export function promptApiKey( }); } +/** + * Asks for the provider endpoint the catalog did not declare (or declared + * only as an env placeholder) — required for catalog imports whose protocol + * was guessed, where the built-in default endpoint would point at the wrong + * host. Esc cancels the import. + */ +export function promptBaseUrl(host: SlashCommandHost, platformName: string): Promise { + return new Promise((resolve) => { + const dialog = new ApiKeyInputDialogComponent( + platformName, + ['The catalog declares no endpoint for this provider — enter its base URL.'], + (result: ApiKeyInputResult) => { + host.restoreEditor(); + resolve(result.kind === 'ok' ? result.value : undefined); + }, + { + title: `Enter base URL for ${platformName}`, + mask: false, + emptyHint: 'Base URL cannot be empty.', + }, + ); + host.mountEditorReplacement(dialog); + }); +} + export function promptCatalogProviderSelection(host: SlashCommandHost, catalog: Catalog): Promise { return new Promise((resolve) => { const options: ChoiceOption[] = Object.entries(catalog) - .filter(([, entry]) => inferWireType(entry) !== undefined) + .filter(([, entry]) => resolveCatalogImport(entry).kind !== 'invalid') .map(([id, entry]) => ({ value: id, label: entry.name ?? id, diff --git a/apps/kimi-code/src/tui/commands/provider.ts b/apps/kimi-code/src/tui/commands/provider.ts index fdc284645..8341ca11d 100644 --- a/apps/kimi-code/src/tui/commands/provider.ts +++ b/apps/kimi-code/src/tui/commands/provider.ts @@ -6,12 +6,11 @@ import { } from '@moonshot-ai/kimi-code-oauth'; import { applyCatalogProvider, - catalogBaseUrl, catalogProviderModels, CatalogFetchError, DEFAULT_CATALOG_URL, fetchCatalog, - inferWireType, + resolveCatalogImport, type Catalog, type ThinkingEffort, } from '#/core/index'; @@ -31,8 +30,10 @@ import { DEFAULT_OAUTH_PROVIDER_NAME } from '../constant/kimi-tui'; import { modelsView, providersView } from '../utils/core-config-view'; import { formatErrorMessage } from '../utils/event-payload'; import { thinkingEffortToConfig } from '../utils/thinking-config'; +import { effectiveModelForHost } from './config'; import { promptApiKey, + promptBaseUrl, promptCatalogProviderSelection, } from './prompts'; import type { SlashCommandHost } from './dispatch'; @@ -192,15 +193,34 @@ async function handleCatalogProviderAdd(host: SlashCommandHost): Promise { return; } - const apiKey = await promptApiKey(host, entry.name ?? providerId); - if (apiKey === undefined) return; - - const wire = inferWireType(entry); - if (wire === undefined) { - host.showError(`Provider "${providerId}" has unsupported wire type.`); + let resolution = resolveCatalogImport(entry); + if (resolution.kind === 'needs-base-url') { + const entered = await promptBaseUrl(host, entry.name ?? providerId); + if (entered === undefined) return; + resolution = resolveCatalogImport(entry, entered); + } + if (resolution.kind !== 'ok') { + if (resolution.kind === 'invalid') { + if (resolution.reason === 'unknown-explicit-type') { + host.showError( + `Provider "${providerId}" declares protocol "${entry.type}" in the catalog, which this client version does not support.`, + ); + } else if (resolution.reason === 'proprietary-sdk') { + host.showError( + `Provider "${providerId}" uses a proprietary SDK this client cannot speak (e.g. Amazon Bedrock or Cohere); it cannot be imported from the catalog.`, + ); + } else { + host.showError( + `Base URL contains an env placeholder or is empty. Enter the resolved URL instead.`, + ); + } + } return; } - const baseUrl = catalogBaseUrl(entry, wire); + const { wire, baseUrl } = resolution; + + const apiKey = await promptApiKey(host, entry.name ?? providerId); + if (apiKey === undefined) return; // Persist the provider and all its models immediately after the api key is // entered. The model selector that follows is just a convenience to pick the @@ -232,6 +252,11 @@ async function handleCatalogProviderAdd(host: SlashCommandHost): Promise { await host.authFlow.refreshConfigAfterLogin(); host.track('connect', { provider: providerId, method: 'catalog' }); host.showStatus(`Provider added: ${entry.name ?? providerId}`); + if (resolution.guessed) { + host.showStatus( + `Protocol guessed as "openai" for ${providerId} — edit "type" in config.toml if requests fail.`, + ); + } // Build a merged model dictionary that includes existing models plus the // newly-persisted provider's models, so the tabbed selector shows every @@ -263,9 +288,17 @@ async function setDefaultModel( alias: string, effort: ThinkingEffort, ): Promise { + // Resolve efforts the same way the /model path does (effectiveModelForHost + // applies overrides and the protocol-profile inference): catalog entries for + // e.g. Anthropic models declare no support_efforts on the alias, and without + // the inference a top-tier pick would slip through as a persisted effort. + const model = host.state.appState.availableModels[alias]; await host.harness.setConfig({ defaultModel: alias, - thinking: thinkingEffortToConfig(effort), + thinking: thinkingEffortToConfig( + effort, + model === undefined ? undefined : effectiveModelForHost(host, model).supportEfforts, + ), }); await host.authFlow.refreshConfigAfterLogin(); host.track('model_switch', { model: alias }); diff --git a/apps/kimi-code/src/tui/commands/registry.ts b/apps/kimi-code/src/tui/commands/registry.ts index 8e5a16386..063bcd7bf 100644 --- a/apps/kimi-code/src/tui/commands/registry.ts +++ b/apps/kimi-code/src/tui/commands/registry.ts @@ -136,14 +136,14 @@ export const BUILTIN_SLASH_COMMANDS = [ { name: 'yolo', aliases: ['yes'], - description: 'Toggle YOLO mode: AI auto-approves safe actions, asks for approval on risky ones.', + description: 'Toggle YOLO mode: auto-approve tool actions, but the agent may still ask questions.', priority: 101, availability: 'always', }, { name: 'auto', aliases: [], - description: 'Toggle Auto mode: run all actions automatically, including risky ones.', + description: 'Toggle Auto mode: fully autonomous, agent decides everything without asking.', priority: 99, availability: 'always', }, @@ -384,10 +384,16 @@ export const BUILTIN_SLASH_COMMANDS = [ description: 'Export current session as a debug ZIP archive', priority: 40, }, + { + name: 'copy', + aliases: [], + description: 'Copy the last assistant message to the clipboard', + priority: 40, + }, { name: 'web', aliases: [], - description: 'Open the current session in the Web UI and exit the terminal', + description: 'Open the current session in the Web UI by starting a new server', priority: 40, availability: 'always', }, diff --git a/apps/kimi-code/src/tui/commands/undo.ts b/apps/kimi-code/src/tui/commands/undo.ts index 84e8156b4..027bde455 100644 --- a/apps/kimi-code/src/tui/commands/undo.ts +++ b/apps/kimi-code/src/tui/commands/undo.ts @@ -107,8 +107,9 @@ async function undoByCount(host: SlashCommandHost, count: number): Promise { const session = host.session; @@ -25,62 +25,52 @@ export async function handleWebCommand(host: SlashCommandHost): Promise { host.showError(NO_ACTIVE_SESSION_MESSAGE); return; } - const sessionId = session.id; - const confirmed = await new Promise((resolve) => { - const picker = new ChoicePickerComponent({ - title: 'Open current session in the Web UI?', - hint: '↑↓ navigate · Enter select · Esc cancel', - options: [ - { - value: WEB_CONFIRM, - label: 'Continue', - description: - 'Start the Kimi server (background daemon if needed), open this session in your default browser, and exit the terminal UI.', - }, - { - value: WEB_CANCEL, - label: 'Cancel', - description: 'Stay in the terminal UI.', - }, - ], - onSelect: (value) => { - resolve(value === WEB_CONFIRM); - }, - onCancel: () => { - resolve(false); - }, - }); - host.mountEditorReplacement(picker); - }); - host.restoreEditor(); - if (!confirmed) return; - - host.showStatus('Starting Kimi server and opening web UI…'); - let origin: string; - try { - ({ origin } = await ensureDaemon({})); - } catch (error) { - host.showError(`Failed to start server: ${formatErrorMessage(error)}`); - return; - } - - // Resolve the persistent token so the opened browser auto-authenticates via - // the `#token=` fragment — matching the `kimi web` subcommand. Show the URL - // and token in green under the status line so they can be copied before the - // terminal exits. Best-effort: an older/never-started server has no token - // file, so we fall back to the plain URL and skip the token line. - const token = tryResolveServerToken(getDataDir()); - const url = webSessionUrl(origin, sessionId, token); - host.showStatus(`open ${url}`, 'success'); - if (token !== undefined) { - host.showStatus(`Token: ${token}`, 'success'); - } - openUrl(url); - host.setExitOpenUrl(url); + startNewServerAfterExit(host, session.id); await host.stop(); } +/** + * Register the exit takeover that turns this process into the new server once + * the TUI has shut down (where `process.exit` would normally happen): the + * server stays attached to this terminal until Ctrl+C, and the session deep + * link opens from the ready hook once the server is actually listening. The + * terminal shows the same ready banner as `kimi web` plus the deep link. + */ +function startNewServerAfterExit(host: SlashCommandHost, sessionId: string): void { + host.setExitForegroundTask(async () => { + const options = parseServerOptions({}); + try { + await startServerForeground(options, { + onReady: (origin) => { + // Resolve the token here (after the server is listening): a fresh + // server writes `server.token` on first boot, so reading it earlier + // would miss first-time starts and the browser would hit the auth + // gate. + const token = tryResolveServerToken(getDataDir()); + const url = webSessionUrl(origin, sessionId, token); + process.stdout.write(formatReadyBanner(origin, options.host, { token })); + process.stdout.write(`\n ${sessionLine(url)}\n`); + openUrl(url); + }, + }); + } catch (error) { + process.stderr.write(`Failed to start server: ${formatErrorMessage(error)}\n`); + process.exit(1); + } + }); +} + +/** Styled `Session:` line for the foreground handoff; the token fragment is + * dimmed like in the ready banner so the host/path stands out. */ +function sessionLine(url: string): string { + const label = (text: string): string => chalk.bold.hex(darkColors.textDim)(text); + const accent = (text: string): string => chalk.hex(darkColors.accent)(text); + const dim = (text: string): string => chalk.hex(darkColors.textDim)(text); + const [base, frag] = splitTokenFragment(url); + return `${label('Session: ')}${accent(base)}${frag === '' ? '' : dim(frag)}`; +} + /** * Build the deep-link URL the web UI recognises for a session. When a token is * known it rides in the `#token=` fragment (never sent to the server, so never diff --git a/apps/kimi-code/src/tui/components/chrome/gutter-container.ts b/apps/kimi-code/src/tui/components/chrome/gutter-container.ts index 72b1e94b1..ed19793af 100644 --- a/apps/kimi-code/src/tui/components/chrome/gutter-container.ts +++ b/apps/kimi-code/src/tui/components/chrome/gutter-container.ts @@ -7,6 +7,12 @@ * prefixed with `left` plain spaces. Right padding is logical only — we * never emit trailing spaces, since terminals already paint background to * the edge and adding them would just churn the diff renderer. + * + * The render cache below validates per child (component identity + the + * identity of its rendered line array), so structural child-list changes — + * append, splice-removal, in-place replacement — are picked up correctly + * without a tree-wide `invalidate()`. Reserve `invalidate()` for global + * style changes that genuinely dirty every child (e.g. theme switches). */ import { Container } from '@moonshot-ai/pi-tui'; diff --git a/apps/kimi-code/src/tui/components/dialogs/api-key-input-dialog.ts b/apps/kimi-code/src/tui/components/dialogs/api-key-input-dialog.ts index d95d9ff93..2165dc48d 100644 --- a/apps/kimi-code/src/tui/components/dialogs/api-key-input-dialog.ts +++ b/apps/kimi-code/src/tui/components/dialogs/api-key-input-dialog.ts @@ -48,6 +48,8 @@ export class ApiKeyInputDialogComponent extends Container implements Focusable { private readonly onDone: (result: ApiKeyInputResult) => void; private readonly title: string; private readonly subtitleLines: readonly string[]; + private readonly mask: boolean; + private readonly emptyHint: string; private done = false; private emptyHinted = false; @@ -55,11 +57,14 @@ export class ApiKeyInputDialogComponent extends Container implements Focusable { platformName: string, subtitleLines: readonly string[], onDone: (result: ApiKeyInputResult) => void, + options?: { title?: string; mask?: boolean; emptyHint?: string }, ) { super(); this.onDone = onDone; - this.title = `Enter API key for ${platformName}`; + this.title = options?.title ?? `Enter API key for ${platformName}`; this.subtitleLines = subtitleLines; + this.mask = options?.mask ?? true; + this.emptyHint = options?.emptyHint ?? 'API key cannot be empty.'; this.input.onSubmit = (value) => { this.submit(value); }; @@ -96,7 +101,7 @@ export class ApiKeyInputDialogComponent extends Container implements Focusable { const border = (s: string): string => currentTheme.fg('primary', s); const titleStyled = currentTheme.boldFg('textStrong', this.title); - const subtitleSource = this.emptyHinted ? ['API key cannot be empty.'] : this.subtitleLines; + const subtitleSource = this.emptyHinted ? [this.emptyHint] : this.subtitleLines; const subtitleLines = subtitleSource.map((line) => truncateToWidth(currentTheme.fg('textDim', line), innerWidth, '…'), ); @@ -105,7 +110,8 @@ export class ApiKeyInputDialogComponent extends Container implements Focusable { const titleLine = truncateToWidth(titleStyled, innerWidth, '…'); const footerLine = truncateToWidth(footerStyled, innerWidth, '…'); const rawInputLine = this.input.render(innerWidth)[0] ?? '> '; - const inputLine = this.input.getValue() === '' ? rawInputLine : maskInputLine(rawInputLine); + const inputLine = + this.mask && this.input.getValue() !== '' ? maskInputLine(rawInputLine) : rawInputLine; const contentLines: string[] = [ titleLine, diff --git a/apps/kimi-code/src/tui/components/dialogs/permission-selector.ts b/apps/kimi-code/src/tui/components/dialogs/permission-selector.ts index 00c338275..a20c22deb 100644 --- a/apps/kimi-code/src/tui/components/dialogs/permission-selector.ts +++ b/apps/kimi-code/src/tui/components/dialogs/permission-selector.ts @@ -8,15 +8,15 @@ const PERMISSION_OPTIONS: readonly ChoiceOption[] = [ label: 'Manual', description: 'Approve every action yourself.', }, - { - value: 'auto', - label: 'Auto', - description: 'Run all actions automatically, including risky ones.', - }, { value: 'yolo', label: 'YOLO', - description: 'AI decides which actions need your approval.', + description: 'Auto-approve tool actions, but the agent may still ask questions.', + }, + { + value: 'auto', + label: 'Auto', + description: 'Fully autonomous — agent decides everything without asking.', }, ]; diff --git a/apps/kimi-code/src/tui/components/media/code-highlight.ts b/apps/kimi-code/src/tui/components/media/code-highlight.ts index bfef3d91b..deec7a259 100644 --- a/apps/kimi-code/src/tui/components/media/code-highlight.ts +++ b/apps/kimi-code/src/tui/components/media/code-highlight.ts @@ -7,6 +7,8 @@ import { extname } from 'node:path'; import { highlight, supportsLanguage } from 'cli-highlight'; +import { codeHighlightTheme } from '#/tui/theme/highlight-theme'; + const EXT_LANG_MAP: Record = { ts: 'typescript', tsx: 'typescript', @@ -45,7 +47,7 @@ export function highlightLines(code: string, lang: string | undefined): string[] const normalizedLang = lang?.trim().toLowerCase(); if (!normalizedLang || !supportsLanguage(normalizedLang)) return code.split('\n'); try { - return highlight(code, { language: normalizedLang, ignoreIllegals: true }).split('\n'); + return highlight(code, { language: normalizedLang, ignoreIllegals: true, theme: codeHighlightTheme }).split('\n'); } catch { return code.split('\n'); } diff --git a/apps/kimi-code/src/tui/components/messages/step-summary.ts b/apps/kimi-code/src/tui/components/messages/step-summary.ts index 325ae6eb2..62d5add2f 100644 --- a/apps/kimi-code/src/tui/components/messages/step-summary.ts +++ b/apps/kimi-code/src/tui/components/messages/step-summary.ts @@ -3,21 +3,24 @@ import type { Component } from '@moonshot-ai/pi-tui'; import { currentTheme } from '#/tui/theme'; /** - * A collapsed summary of older steps within a turn. Accumulates counts of - * merged steps (thinking blocks and tool calls) and renders them as a single - * muted line, e.g. `… thinking 5 times, call 50 tools`. + * A collapsed summary of older content within a turn. Accumulates counts of + * merged steps (thinking blocks and tool calls) and folded assistant messages, + * rendering them as a single muted line, e.g. + * `… thinking 5 times, call 50 tools, 12 messages`. */ export class StepSummaryComponent implements Component { private thinking = 0; private tool = 0; + private message = 0; get isEmpty(): boolean { - return this.thinking === 0 && this.tool === 0; + return this.thinking === 0 && this.tool === 0 && this.message === 0; } - addCounts(thinking: number, tool: number): void { + addCounts(thinking: number, tool: number, message = 0): void { this.thinking += thinking; this.tool += tool; + this.message += message; } invalidate(): void {} @@ -26,6 +29,7 @@ export class StepSummaryComponent implements Component { const parts: string[] = []; if (this.thinking > 0) parts.push(`thinking ${this.thinking} times`); if (this.tool > 0) parts.push(`call ${this.tool} tools`); + if (this.message > 0) parts.push(`${this.message} messages`); if (parts.length === 0) return []; return [currentTheme.dim(`\u2026 ${parts.join(', ')}`)]; } diff --git a/apps/kimi-code/src/tui/components/messages/user-message.ts b/apps/kimi-code/src/tui/components/messages/user-message.ts index cec7fbd13..e7241e963 100644 --- a/apps/kimi-code/src/tui/components/messages/user-message.ts +++ b/apps/kimi-code/src/tui/components/messages/user-message.ts @@ -96,3 +96,17 @@ export class UserMessageComponent implements Component { function isImageLine(line: string): boolean { return line.includes('\u001B_G') || line.includes('\u001B]1337;File='); } + +/** + * Invisible turn-boundary marker for replay. Some replayed records start a + * new turn without anything to show — the goal driver's synthetic + * continuation prompt is model-facing and never rendered live — but the + * transcript still needs a mounted boundary component so step/assistant + * folding (and window trimming) can find the turn edges. Renders zero lines. + */ +export class ReplayTurnBoundaryComponent implements Component { + invalidate(): void {} + render(_width: number): string[] { + return []; + } +} diff --git a/apps/kimi-code/src/tui/constant/tips.ts b/apps/kimi-code/src/tui/constant/tips.ts index f9d0a7f27..a235ab6ce 100644 --- a/apps/kimi-code/src/tui/constant/tips.ts +++ b/apps/kimi-code/src/tui/constant/tips.ts @@ -20,7 +20,6 @@ export const WORKING_TIPS: readonly ToolbarTip[] = [ { text: '/tasks to check progress and status for background tasks', priority: 2 }, { text: '/init: generate AGENTS.md', priority: 2 }, { text: 'Try /dance for a hidden Easter egg' }, - { text: '/plugins: manage plugins — try the "superpowers" plugin', solo: true, priority: 3 }, { text: '/plugins: manage plugins — try the "Kimi Datasource" for reliable financial, economic, and academic data', solo: true, diff --git a/apps/kimi-code/src/tui/controllers/editor-keyboard.ts b/apps/kimi-code/src/tui/controllers/editor-keyboard.ts index caeac5655..602261c18 100644 --- a/apps/kimi-code/src/tui/controllers/editor-keyboard.ts +++ b/apps/kimi-code/src/tui/controllers/editor-keyboard.ts @@ -122,15 +122,8 @@ export class EditorKeyboardController { return; } - if (host.state.appState.isCompacting) { - this.clearPendingExit(); - - if (this.clearEditorTextIfPresent()) return; - - this.cancelCurrentCompaction(); - return; - } - + // The btw panel stacks above the transcript, so Ctrl+C cancels/closes it + // before touching an in-flight compaction or stream. if (host.btwPanelController.cancelRunning()) { this.clearPendingExit(); return; @@ -140,6 +133,15 @@ export class EditorKeyboardController { return; } + if (host.state.appState.isCompacting) { + this.clearPendingExit(); + + if (this.clearEditorTextIfPresent()) return; + + this.cancelCurrentCompaction(); + return; + } + if (host.state.appState.streamingPhase !== 'idle') { this.clearPendingExit(); @@ -177,12 +179,14 @@ export class EditorKeyboardController { this.clearPendingUndoEsc(); return; } - if (host.state.appState.isCompacting) { - this.cancelCurrentCompaction(); + // The btw panel stacks above the transcript, so Esc dismisses it before + // touching an in-flight compaction or stream. + if (host.btwPanelController.closeOrCancel()) { this.clearPendingUndoEsc(); return; } - if (host.btwPanelController.closeOrCancel()) { + if (host.state.appState.isCompacting) { + this.cancelCurrentCompaction(); this.clearPendingUndoEsc(); return; } diff --git a/apps/kimi-code/src/tui/controllers/session-event-handler.ts b/apps/kimi-code/src/tui/controllers/session-event-handler.ts index b498208e4..77f07564c 100644 --- a/apps/kimi-code/src/tui/controllers/session-event-handler.ts +++ b/apps/kimi-code/src/tui/controllers/session-event-handler.ts @@ -464,12 +464,13 @@ export class SessionEventHandler { const { state, streamingUI } = this.host; // Encrypted / redacted reasoning (e.g. Kimi over the Anthropic-compatible // protocol) streams thinking deltas whose visible text is empty — only an - // opaque signature rides along. Such deltas carry nothing to render, so - // switching into the `thinking` pane mode here would stop the "waiting" + // opaque signature rides along. Models also occasionally stream whitespace- + // only thinking (e.g. a single space). Such deltas carry nothing to render, + // so switching into the `thinking` pane mode here would stop the "waiting" // moon spinner while no ThinkingComponent is ever created (it needs visible // text), leaving a blank, spinner-less gap until the first real text/tool // token arrives. Keep the moon up until actual thinking text shows up. - if (event.delta.length === 0 && !streamingUI.hasThinkingDraft()) return; + if (event.delta.trim().length === 0 && !streamingUI.hasThinkingDraft()) return; streamingUI.appendThinkingDelta(event.delta); this.host.patchLivePane({ mode: 'idle' }); if (state.appState.streamingPhase !== 'thinking') { @@ -828,8 +829,9 @@ export class SessionEventHandler { const children = state.transcriptContainer.children; const idx = children.indexOf(spinner); if (idx >= 0) { + // In-place replacement is picked up by the container's ref-checked + // render cache; a tree-wide invalidate is unnecessary (and costly). children[idx] = status; - state.transcriptContainer.invalidate(); } else { state.transcriptContainer.addChild(status); } diff --git a/apps/kimi-code/src/tui/controllers/session-replay.ts b/apps/kimi-code/src/tui/controllers/session-replay.ts index f64f2a8d2..a702026c0 100644 --- a/apps/kimi-code/src/tui/controllers/session-replay.ts +++ b/apps/kimi-code/src/tui/controllers/session-replay.ts @@ -11,6 +11,7 @@ import type { } from '#/core/index'; import { ToolCallComponent } from '../components/messages/tool-call'; +import { ReplayTurnBoundaryComponent } from '../components/messages/user-message'; import { currentTheme } from '../theme'; import type { TodoItem } from '../components/chrome/todo-panel'; import type { @@ -29,6 +30,7 @@ import { formatBackgroundAgentTranscript } from '../utils/background-agent-statu import { formatBackgroundTaskTranscript } from '../utils/background-task-status'; import { buildGoalCompletionMessage } from '../utils/goal-completion'; import { formatBashOutputForDisplay } from '../utils/shell-output'; +import { markTranscriptComponent } from '../utils/transcript-component-metadata'; import { appStateFromResumeAgent, backgroundOrigin, @@ -340,16 +342,19 @@ export class SessionReplayRenderer { this.renderCronMissed(context, message); return; } - if (isGoalForkClearedSystemReminder(message)) { - return; - } - const goalReminder = goalOutcomeReminderFromSystemMessage(message); - if (goalReminder !== null) { - if (goalReminder !== undefined) { - this.flushAssistant(context); - this.host.appendTranscriptEntry( - replayEntry(context, 'assistant', goalReminder, 'markdown'), - ); + // System-trigger messages (goal continuation prompts, goal outcome + // reminders, stop-hook reasons, …) are model-facing only: the live event + // stream never renders them, so replay must not leak them either. + if (message.origin?.kind === 'system_trigger') { + if (message.origin.name === 'goal_continuation') { + // The goal driver's synthetic "continue" prompt starts a new replay + // turn even though nothing visible is mounted: advance the turn and + // mark an invisible boundary so each goal round groups under its own + // turn and step/assistant folding can find the turn edges. + this.advanceTurn(context); + const boundary = new ReplayTurnBoundaryComponent(); + markTranscriptComponent(boundary, replayEntry(context, 'user', '', 'plain')); + this.host.state.transcriptContainer.addChild(boundary); } return; } @@ -608,7 +613,7 @@ export class SessionReplayRenderer { if (mode === 'yolo') { this.host.appendTranscriptEntry( replayEntry(context, 'status', 'YOLO mode: ON', 'notice', { - detail: 'All actions will be approved automatically. Use with caution.', + detail: 'Tool actions auto-approved; the agent may still ask you questions.', }), ); return; @@ -693,8 +698,9 @@ export class SessionReplayRenderer { (child) => child instanceof ToolCallComponent && child.toolCallView.id === toolCallId, ); if (childIndex >= 0) { + // Structural removal only: the container's ref-checked render cache + // detects the child-list change; no tree-wide invalidate needed. children.splice(childIndex, 1); - state.transcriptContainer.invalidate(); } } @@ -780,18 +786,6 @@ function isModelBlockedGoalLifecycle(change: GoalReplayLifecycleChange): boolean return change.status === 'blocked' && change.actor === 'model'; } -function goalOutcomeReminderFromSystemMessage(message: ContextMessage): string | undefined | null { - if (message.origin?.kind !== 'system_trigger') return null; - if (message.origin.name !== 'goal_completion' && message.origin.name !== 'goal_blocked') { - return null; - } - return undefined; -} - -function isGoalForkClearedSystemReminder(message: ContextMessage): boolean { - return message.origin?.kind === 'system_trigger' && message.origin.name === 'goal_fork_cleared'; -} - function extractCronPrompt(text: string): string { const open = '\n'; const close = '\n'; diff --git a/apps/kimi-code/src/tui/controllers/streaming-ui.ts b/apps/kimi-code/src/tui/controllers/streaming-ui.ts index 2c82701cc..928f324f3 100644 --- a/apps/kimi-code/src/tui/controllers/streaming-ui.ts +++ b/apps/kimi-code/src/tui/controllers/streaming-ui.ts @@ -36,6 +36,7 @@ export interface StreamingUIHost { shiftQueuedMessage(): QueuedMessage | undefined; pushTranscriptEntry(entry: TranscriptEntry): void; mergeCurrentTurnSteps(): void; + mergeCompletedTurnAssistants(): void; } export class StreamingUIController { @@ -555,6 +556,9 @@ export class StreamingUIController { const completedTurnKey = this._currentTurnId ?? `local:${String(state.appState.streamingStartTime)}`; this.finalizeLiveTextBuffers('idle'); + // The finished turn keeps only its conclusion-bearing tail; intermediate + // chatter folds into the step summary. + this.host.mergeCompletedTurnAssistants(); this.resetToolCallState(); this._currentTurnId = undefined; @@ -598,6 +602,7 @@ export class StreamingUIController { turnId: this._currentTurnId, renderMode: 'markdown' as const, content: '', + modelText: true, }; const component = new AssistantMessageComponent(); this._streamingBlock = { component, entry }; @@ -624,7 +629,11 @@ export class StreamingUIController { } onThinkingUpdate(fullText: string): void { - if (fullText.length === 0 && this._activeThinkingComponent === undefined) return; + // Skip thinking that carries nothing visible — empty (e.g. encrypted + // reasoning) or whitespace-only (a model occasionally streams a single + // space as thinking). Session replay funnels through here as well, so a + // stored whitespace-only think part never becomes a bare bullet line. + if (fullText.trim().length === 0 && this._activeThinkingComponent === undefined) return; const { state } = this.host; if (this._activeThinkingComponent === undefined) { this._pendingAgentGroup = null; @@ -842,8 +851,9 @@ export class StreamingUIController { const children = state.transcriptContainer.children; const idx = children.indexOf(solo); if (idx >= 0) { + // In-place replacement is picked up by the container's ref-checked + // render cache; a tree-wide invalidate is unnecessary (and costly). children[idx] = group; - state.transcriptContainer.invalidate(); } else { state.transcriptContainer.addChild(group); } @@ -899,8 +909,9 @@ export class StreamingUIController { const children = state.transcriptContainer.children; const idx = children.indexOf(solo); if (idx >= 0) { + // In-place replacement is picked up by the container's ref-checked + // render cache; a tree-wide invalidate is unnecessary (and costly). children[idx] = group; - state.transcriptContainer.invalidate(); } else { state.transcriptContainer.addChild(group); } diff --git a/apps/kimi-code/src/tui/controllers/subagent-event-handler.ts b/apps/kimi-code/src/tui/controllers/subagent-event-handler.ts index 080e94117..7534a7222 100644 --- a/apps/kimi-code/src/tui/controllers/subagent-event-handler.ts +++ b/apps/kimi-code/src/tui/controllers/subagent-event-handler.ts @@ -551,8 +551,9 @@ export class SubAgentEventHandler { const children = this.host.state.transcriptContainer.children; const index = children.indexOf(progress); if (index >= 0) { + // Structural removal only: GutterContainer's ref-checked render cache + // detects the child-list change; no tree-wide invalidate needed. children.splice(index, 1); - this.host.state.transcriptContainer.invalidate(); } this.host.updateActivityPane(); } diff --git a/apps/kimi-code/src/tui/kimi-tui.ts b/apps/kimi-code/src/tui/kimi-tui.ts index 74f04f4a1..a9f2c9677 100644 --- a/apps/kimi-code/src/tui/kimi-tui.ts +++ b/apps/kimi-code/src/tui/kimi-tui.ts @@ -86,7 +86,10 @@ import { import { StepSummaryComponent } from './components/messages/step-summary'; import { ThinkingComponent } from './components/messages/thinking'; import { ToolCallComponent } from './components/messages/tool-call'; -import { UserMessageComponent } from './components/messages/user-message'; +import { + ReplayTurnBoundaryComponent, + UserMessageComponent, +} from './components/messages/user-message'; import { ActivityPaneComponent, type ActivityPaneMode } from './components/panes/activity-pane'; import { QueuePaneComponent } from './components/panes/queue-pane'; import type { TuiConfig } from './config'; @@ -133,6 +136,7 @@ import { formatErrorMessage } from './utils/event-payload'; import { pickForegroundTasks } from './utils/foreground-task'; import { ImageAttachmentStore, type ImageAttachment } from './utils/image-attachment-store'; import { extractMediaAttachments, rewriteMediaPlaceholders } from './utils/image-placeholder'; +import { REPLAY_TURN_LIMIT } from './utils/message-replay'; import { hasPatchChanges } from './utils/object-patch'; import { sessionRowsForPicker } from './utils/session-picker-rows'; import { formatBashOutputForDisplay } from './utils/shell-output'; @@ -149,6 +153,8 @@ import { nextTranscriptId } from './utils/transcript-id'; import { TRANSCRIPT_EXPAND_TURNS, TRANSCRIPT_HYSTERESIS, + TRANSCRIPT_KEEP_RECENT_ASSISTANT, + TRANSCRIPT_KEEP_RECENT_ASSISTANT_COMPLETED, TRANSCRIPT_KEEP_RECENT_STEPS, TRANSCRIPT_MAX_TURNS, TRANSCRIPT_WINDOW_ENABLED, @@ -368,6 +374,13 @@ export class KimiTUI { /** URL opened in the browser just before exit (e.g. by `/web`); printed by onExit. */ public exitOpenUrl: string | undefined; + /** + * Task that takes over the process after the TUI shuts down, instead of + * exiting (`/web` starting a new server: the server keeps this terminal + * attached until Ctrl+C). Set via {@link setExitForegroundTask}. + */ + public exitForegroundTask: ((exitCode: number) => Promise) | undefined; + track(event: string, properties?: TelemetryProperties): void { this.harness.track(event, properties); } @@ -780,6 +793,7 @@ export class KimiTUI { session = await this.harness.resumeSession({ id: startup.sessionFlag, additionalDirs: createSessionOptions.additionalDirs, + replayTurnLimit: REPLAY_TURN_LIMIT, }); shouldReplayHistory = true; } else { @@ -789,6 +803,7 @@ export class KimiTUI { session = await this.harness.resumeSession({ id: target.id, additionalDirs: createSessionOptions.additionalDirs, + replayTurnLimit: REPLAY_TURN_LIMIT, }); shouldReplayHistory = true; } else { @@ -1139,7 +1154,18 @@ export class KimiTUI { this.showError(LLM_NOT_SET_MESSAGE); return; } - const extraction = extractMediaAttachments(text, this.imageStore); + let extraction: ReturnType; + try { + // Pasted videos are copied into the cache and expand to a `file://` + // `video_url` part; the engine resolves (uploads or degrades) them + // inside the turn, so submission stays fully synchronous. + extraction = extractMediaAttachments(text, this.imageStore); + } catch (error) { + // A video cache copy failed (unwritable cache dir, vanished source…); + // nothing was dispatched. + this.showError(`Failed to prepare media attachment: ${formatErrorMessage(error)}`); + return; + } if (!this.validateMediaCapabilities(extraction)) return; const session = this.session; if (session === undefined) { @@ -1582,6 +1608,10 @@ export class KimiTUI { this.exitOpenUrl = url; } + setExitForegroundTask(task: (exitCode: number) => Promise): void { + this.exitForegroundTask = task; + } + async getStartupMcpMs(): Promise { const session = this.session; if (session === undefined) return 0; @@ -1867,7 +1897,10 @@ export class KimiTUI { let session: CoreSession; try { - session = await this.harness.resumeSession({ id: targetSessionId }); + session = await this.harness.resumeSession({ + id: targetSessionId, + replayTurnLimit: REPLAY_TURN_LIMIT, + }); } catch (error) { const msg = formatErrorMessage(error); this.showError(`Failed to resume session ${targetSessionId}: ${msg}`); @@ -2210,7 +2243,8 @@ export class KimiTUI { if ( !(child instanceof UserMessageComponent) && !(child instanceof SkillActivationComponent) && - !(child instanceof PluginCommandComponent) + !(child instanceof PluginCommandComponent) && + !(child instanceof ReplayTurnBoundaryComponent) ) { return false; } @@ -2298,7 +2332,28 @@ export class KimiTUI { } mergeCurrentTurnSteps(): boolean { - if (TRANSCRIPT_KEEP_RECENT_STEPS <= 0) return false; + return this.foldCurrentTurnContent( + TRANSCRIPT_KEEP_RECENT_STEPS, + TRANSCRIPT_KEEP_RECENT_ASSISTANT, + ); + } + + /** + * Fold the just-finished turn's assistant messages down to the completed-turn + * cap: while a turn is live it may keep TRANSCRIPT_KEEP_RECENT_ASSISTANT + * messages mounted, but once it ends only the conclusion-bearing tail stays. + * Called when a turn finishes; the finished turn is still the current one at + * that point (no newer boundary exists yet). + */ + mergeCompletedTurnAssistants(): boolean { + return this.foldCurrentTurnContent( + TRANSCRIPT_KEEP_RECENT_STEPS, + TRANSCRIPT_KEEP_RECENT_ASSISTANT_COMPLETED, + ); + } + + private foldCurrentTurnContent(keepSteps: number, keepAssistants: number): boolean { + if (keepSteps <= 0 && keepAssistants <= 0) return false; const children = this.state.transcriptContainer.children; // Find the start of the current turn (last turn-starting user message). @@ -2311,22 +2366,34 @@ export class KimiTUI { } if (turnStart < 0) return false; - // Locate an existing summary, the assistant message, and the mergeable steps. + // Locate an existing summary, the assistant messages, and the mergeable steps. let summaryIndex = -1; const stepIndices: number[] = []; + const assistantIndices: number[] = []; for (let i = turnStart + 1; i < children.length; i++) { const child = children[i]!; if (child instanceof StepSummaryComponent) { summaryIndex = i; continue; } - if (child instanceof AssistantMessageComponent) continue; + if (child instanceof AssistantMessageComponent) { + assistantIndices.push(i); + continue; + } stepIndices.push(i); } - if (stepIndices.length <= TRANSCRIPT_KEEP_RECENT_STEPS) return false; - const mergeCount = stepIndices.length - TRANSCRIPT_KEEP_RECENT_STEPS; - const toMergeIndices = stepIndices.slice(0, mergeCount); + // Fold the oldest steps / assistant messages beyond their respective caps; + // the most recent ones stay mounted. Children are chronological, so the + // oldest of each kind sit at the front of their index lists. + const stepMergeCount = keepSteps > 0 ? Math.max(0, stepIndices.length - keepSteps) : 0; + const assistantMergeCount = + keepAssistants > 0 ? Math.max(0, assistantIndices.length - keepAssistants) : 0; + if (stepMergeCount === 0 && assistantMergeCount === 0) return false; + const toMergeIndices = [ + ...stepIndices.slice(0, stepMergeCount), + ...assistantIndices.slice(0, assistantMergeCount), + ]; let thinkingCount = 0; let toolCount = 0; @@ -2335,15 +2402,15 @@ export class KimiTUI { if (child instanceof ThinkingComponent) thinkingCount++; else if (child instanceof ToolCallComponent) toolCount++; } - if (thinkingCount === 0 && toolCount === 0) return false; + if (thinkingCount === 0 && toolCount === 0 && assistantMergeCount === 0) return false; let summary: StepSummaryComponent; if (summaryIndex >= 0) { summary = children[summaryIndex] as StepSummaryComponent; - summary.addCounts(thinkingCount, toolCount); + summary.addCounts(thinkingCount, toolCount, assistantMergeCount); } else { summary = new StepSummaryComponent(); - summary.addCounts(thinkingCount, toolCount); + summary.addCounts(thinkingCount, toolCount, assistantMergeCount); } // Rebuild children: keep everything except the merged steps, with the summary @@ -2368,7 +2435,8 @@ export class KimiTUI { } mergeAllTurnSteps(): void { - if (TRANSCRIPT_KEEP_RECENT_STEPS <= 0) return; + if (TRANSCRIPT_KEEP_RECENT_STEPS <= 0 && TRANSCRIPT_KEEP_RECENT_ASSISTANT_COMPLETED <= 0) + return; const children = this.state.transcriptContainer.children; const boundaries: number[] = []; @@ -2388,16 +2456,29 @@ export class KimiTUI { let summaryIndex = -1; const stepIndices: number[] = []; + const assistantIndices: number[] = []; for (let i = turnStart + 1; i < turnEnd; i++) { const child = children[i]!; if (child instanceof StepSummaryComponent) summaryIndex = i; - else if (child instanceof AssistantMessageComponent) continue; + else if (child instanceof AssistantMessageComponent) assistantIndices.push(i); else stepIndices.push(i); } - if (stepIndices.length > TRANSCRIPT_KEEP_RECENT_STEPS) { - const mergeCount = stepIndices.length - TRANSCRIPT_KEEP_RECENT_STEPS; - const toMergeIndices = stepIndices.slice(0, mergeCount); + const stepMergeCount = + TRANSCRIPT_KEEP_RECENT_STEPS > 0 + ? Math.max(0, stepIndices.length - TRANSCRIPT_KEEP_RECENT_STEPS) + : 0; + // Replayed turns are all completed turns, so the stricter completed-turn + // assistant cap applies (matching what live turns fold to on turn end). + const assistantMergeCount = + TRANSCRIPT_KEEP_RECENT_ASSISTANT_COMPLETED > 0 + ? Math.max(0, assistantIndices.length - TRANSCRIPT_KEEP_RECENT_ASSISTANT_COMPLETED) + : 0; + if (stepMergeCount > 0 || assistantMergeCount > 0) { + const toMergeIndices = [ + ...stepIndices.slice(0, stepMergeCount), + ...assistantIndices.slice(0, assistantMergeCount), + ]; let thinkingCount = 0; let toolCount = 0; for (const idx of toMergeIndices) { @@ -2408,10 +2489,10 @@ export class KimiTUI { let summary: StepSummaryComponent; if (summaryIndex >= 0) { summary = children[summaryIndex] as StepSummaryComponent; - summary.addCounts(thinkingCount, toolCount); + summary.addCounts(thinkingCount, toolCount, assistantMergeCount); } else { summary = new StepSummaryComponent(); - summary.addCounts(thinkingCount, toolCount); + summary.addCounts(thinkingCount, toolCount, assistantMergeCount); } newChildren.push(summary); for (const idx of toMergeIndices) toDispose.push(children[idx]!); diff --git a/apps/kimi-code/src/tui/theme/highlight-theme.ts b/apps/kimi-code/src/tui/theme/highlight-theme.ts new file mode 100644 index 000000000..e16f4b310 --- /dev/null +++ b/apps/kimi-code/src/tui/theme/highlight-theme.ts @@ -0,0 +1,17 @@ +/** + * Shared cli-highlight theme for code previews (Write/Edit tool calls, + * approval panels) and markdown code blocks. + * + * cli-highlight's DEFAULT_THEME paints `string`, `regexp` and `deletion` + * tokens red; reset exactly those tokens to `plain` so highlighted code + * contains no red at all. Tokens not listed here fall back to DEFAULT_THEME. + */ + +import { plain } from 'cli-highlight'; +import type { Theme } from 'cli-highlight'; + +export const codeHighlightTheme: Theme = { + string: plain, + regexp: plain, + deletion: plain, +}; diff --git a/apps/kimi-code/src/tui/theme/pi-tui-theme.ts b/apps/kimi-code/src/tui/theme/pi-tui-theme.ts index 1b53bce0c..91161a5b4 100644 --- a/apps/kimi-code/src/tui/theme/pi-tui-theme.ts +++ b/apps/kimi-code/src/tui/theme/pi-tui-theme.ts @@ -13,6 +13,7 @@ import chalk from 'chalk'; import { highlight, supportsLanguage } from 'cli-highlight'; import { currentTheme } from './theme'; +import { codeHighlightTheme } from './highlight-theme'; // pi-tui's renderer emits literal "### " / "#### " / ... markers for h3-h6 // headings (h1/h2 are rendered without the `#` prefix). The prefix arrives @@ -51,7 +52,7 @@ export function createMarkdownTheme(options?: { transient?: boolean }): Markdown const language = normalizedLang !== undefined && supportsLanguage(normalizedLang) ? normalizedLang : 'text'; try { - const highlighted = highlight(code, { language, ignoreIllegals: true }); + const highlighted = highlight(code, { language, ignoreIllegals: true, theme: codeHighlightTheme }); return highlighted.split('\n'); } catch { return code.split('\n'); diff --git a/apps/kimi-code/src/tui/types.ts b/apps/kimi-code/src/tui/types.ts index 870522934..8ed9a4c7d 100644 --- a/apps/kimi-code/src/tui/types.ts +++ b/apps/kimi-code/src/tui/types.ts @@ -168,6 +168,13 @@ export interface TranscriptEntry { turnId?: string; renderMode: 'markdown' | 'plain' | 'notice'; content: string; + /** + * True only for entries holding real model-authored text (created by the + * assistant stream). Derived cards — hook results, goal completions, goal + * reminders — share kind 'assistant' but are not replies, so /copy must + * skip them. + */ + modelText?: boolean; color?: ColorToken; detail?: string; /** Optional override for the leading bullet of a 'user' message entry. An empty string suppresses the bullet entirely (used by shell-command echoes so `$` replaces the sparkles marker). */ diff --git a/apps/kimi-code/src/tui/utils/image-placeholder.ts b/apps/kimi-code/src/tui/utils/image-placeholder.ts index 6244249e7..15c4d235b 100644 --- a/apps/kimi-code/src/tui/utils/image-placeholder.ts +++ b/apps/kimi-code/src/tui/utils/image-placeholder.ts @@ -1,18 +1,30 @@ /** - * Scan submitted text for media placeholders and produce - * the `PromptPart[]` we'll send to the SDK prompt endpoint. + * Scan submitted text for media placeholders and produce the prompt content + * we'll send to the SDK prompt endpoint. * - * Rules: + * `extractMediaAttachments` (sync) is the single expansion path for prompts: + * - image placeholders expand to inline image content parts (preceded by a + * compression caption when paste-time compression shrank the bytes — see + * `ImageAttachment.original`); + * - video placeholders are copied into the shared cache (`getCacheDir()`) + * and expand to a `video_url` part pointing at the cache copy with a + * `file://` url. The v1 engine resolves that local reference inside the + * turn — uploading it (the `ms://` inline form) or degrading to a + * `