commit 842e699a643d8a60647bd824d28255c56ad61a42 Author: Kaiyi Date: Fri May 22 15:54:50 2026 +0800 Kimi For Coding diff --git a/.agents/skills/gen-changesets/SKILL.md b/.agents/skills/gen-changesets/SKILL.md new file mode 100644 index 000000000..6a4d91e66 --- /dev/null +++ b/.agents/skills/gen-changesets/SKILL.md @@ -0,0 +1,109 @@ +--- +name: gen-changesets +description: Use when generating changesets in the kimi-code repository, including package bump selection, internal package and CLI bundle handling, bump levels, major confirmation, and English changelog wording. +--- + +# Generate Changesets + +`kimi-code` uses changesets to manage versions and changelogs. The current user-facing published package is: + +- `@moonshot-ai/kimi-code`: the CLI + +All other `@moonshot-ai/*` packages are treated as internal packages, including `@moonshot-ai/kimi-code-sdk`, `agent-core`, `kosong`, `kaos`, `kimi-code-oauth`, `kimi-telemetry`, and `migration-legacy`. + +## Core Rules + +1. **Inspect the actual changes first.** Use `git status` / `git diff --name-only` to identify which packages were actually changed. +2. **List packages that were actually changed.** Source code, build config, package metadata, and other changes that affect a package's output or behavior need a changeset entry for that package. +3. **Do not list unchanged internal packages.** For example, if `packages/node-sdk` was not changed, do not list `@moonshot-ai/kimi-code-sdk` just because another internal package changed. The SDK follows the same rule as other internal packages: list it only when it was actually changed. +4. **Internal package source changes that enter the CLI bundle must manually list the CLI.** `@moonshot-ai/kimi-code` inline-bundles `@moonshot-ai/*` source, but those internal packages are devDependencies from the CLI's perspective, so changesets will not automatically propagate bumps. If a change enters the CLI output, also list `@moonshot-ai/kimi-code`. +5. **Docs-only and tests-only changes usually do not need a changeset.** README, internal docs, and `test/` changes that do not enter package output do not trigger a CLI bump. +6. `@moonshot-ai/vis` / `vis-server` / `vis-web` are ignored by changesets and should not be handled. + +## Workflow + +1. If the change includes `pnpm-lock.yaml`, `pnpm-workspace.yaml`, root or workspace `package.json`, `.npmrc`, `flake.nix`, or `flake.lock`: + - First check `command -v nix >/dev/null 2>&1`. + - If `nix` exists, run `nix run .#update-pnpm-deps`. + - If `nix` is unavailable, skip this step and do not block the changeset; mention in the final response that the command was not run. +2. List the packages that were actually changed. +3. Choose a bump level for each package. +4. If an internal package change enters the CLI bundle, add `@moonshot-ai/kimi-code`. +5. Create a short kebab-case file under `.changeset/`. +6. Split unrelated changes into separate changesets; keep one logical change in one file. + +Format: + +```markdown +--- +"": patch +"": minor +--- + + +``` + +## Bump Levels + +| Level | When to use | +|---|---| +| `patch` | Bug fixes; build/package fixes; internal refactors that do not change behavior; wording tweaks; small dependency upgrades | +| `minor` | New backwards-compatible features or capabilities | +| `major` | Breaking changes: incompatible config changes, renamed or removed commands/arguments, behavior semantics changes, and similar | + +### Major Rule + +Never write `major` on your own. + +If you believe a change qualifies as major, stop first, explain why, and ask the user for confirmation. Only write `major` after the user explicitly agrees. If the user does not reply, replies ambiguously, or disagrees, fall back to `minor`; if `minor` is also unclear, fall back to `patch`. + +## Wording Rules + +- Changelog entries **must be written in English**. +- User-facing CLI wording should only be used when CLI users can perceive the change. +- Internal changes that do not affect CLI users can still share a changeset with the CLI, but the wording must describe the real change honestly and must not present it as a user-facing feature. +- Do not mention file names, class names, function names, PR numbers, or commit hashes. +- Avoid vague words such as `refactor`, `optimize`, and `improve`. Describe the actual change, or use more specific wording. + +## Common Examples + +An internal package fixes a bug visible to CLI users: + +```markdown +--- +"@moonshot-ai/agent-core": patch +"@moonshot-ai/kimi-code": patch +--- + +Fix occasional loss of tool call results in long conversations. +``` + +An internal package has an internal-only change, but it enters the CLI bundle: + +```markdown +--- +"@moonshot-ai/agent-core": patch +"@moonshot-ai/kimi-code": patch +--- + +Unify tool execution metadata handling. +``` + +Only SDK source changed, and the CLI does not use it: + +```markdown +--- +"@moonshot-ai/kimi-code-sdk": patch +--- + +Clarify session status typing for internal SDK callers. +``` + +## Red Flags + +- You are about to write `major` without asking the user. +- Internal package source enters the CLI bundle, but `@moonshot-ai/kimi-code` is missing. +- `packages/node-sdk` was not changed, but `@moonshot-ai/kimi-code-sdk` was listed for "internal package sync". +- The changelog entry is in Chinese. +- The wording claims more than the diff actually did. +- The CLI wording mentions internal package names, class names, or PR numbers. diff --git a/.agents/skills/gen-docs/SKILL.md b/.agents/skills/gen-docs/SKILL.md new file mode 100644 index 000000000..62830a652 --- /dev/null +++ b/.agents/skills/gen-docs/SKILL.md @@ -0,0 +1,87 @@ +--- +name: gen-docs +description: Update Kimi Code CLI user documentation after meaningful code changes that affect product behavior or user experience. +--- + +# Gen Docs + +## Overview + +This repository maintains bilingual user documentation under `docs/`. `docs/en/` and `docs/zh/` are mirrored pairs for most pages; update both in the same change. **Changelog is the exception** — English is the source, and Chinese is translated from English. + +Use this skill to update the corresponding documentation whenever the codebase has changes that affect product behavior or user experience. + +For a **full pre-release audit** of all pages (detecting hallucinations and coverage gaps), use the `audit-docs` skill instead. + +## Prerequisites + +This skill depends on the following being in place. If any are missing, stop and report to the user before continuing: + +- `docs/` directory with `docs/zh/`, `docs/en/`, and `docs/.vitepress/config.ts` set up (VitePress site). +- `docs/AGENTS.md` style guide — defines source-of-truth rules, terminology table, typography, and writing style. +- `docs/scripts/sync-changelog.mjs` — auto-syncs root `CHANGELOG.md` to `docs/en/release-notes/changelog.md`. +- `translate-docs` skill in `.agents/skills/` — handles bilingual synchronization. + +## Workflow + +1. **Inspect changes** + + - `git log main..HEAD --oneline` — commits on the current branch + - `git diff main..HEAD --stat` — file-level scope + - `ls .changeset/*.md` (excluding `README.md`) — pending changeset entries + - Read `CHANGELOG.md` and any subpackage `packages/*/CHANGELOG.md` for already-recorded entries. + +2. **Understand user-facing impact** + + For each change, read the actual implementation when needed; **do not infer behavior from commit messages or PR titles alone**. Skip: + + - Internal refactors with no externally visible behavior change + - Tests, CI, type-only changes + - Tooling / build-system changes that do not change how users invoke the CLI + + If after the scan you conclude there is no user-facing impact, say so and stop. + +3. **Sync English changelog** + + Run: + + ```bash + node docs/scripts/sync-changelog.mjs + ``` + + This updates `docs/en/release-notes/changelog.md` from the root `CHANGELOG.md`. Never edit the docs changelog by hand. + +4. **Update user docs** + + Following the rules in `docs/AGENTS.md`, edit the affected pages in whichever locale you are working in, then sync the mirror. Match terminology with the term table in `docs/AGENTS.md` and the existing wording in surrounding pages. + + Cover all relevant sections: + + - Guides (getting-started, use cases, interaction, sessions, IDE integration) + - Customization (skills, agents, MCP, hooks, plugins, etc.) + - Configuration (config files, env vars, providers, data locations) + - Reference (CLI subcommands, slash commands, keyboard shortcuts) + - Release notes (`docs/zh/release-notes/breaking-changes.md` if a breaking change is involved) + +5. **Sync bilingual content** + + Invoke the `translate-docs` skill. It will: + + - Sync updated non-changelog pages between `docs/en/` and `docs/zh/` + - Translate the English changelog → Chinese under `docs/zh/release-notes/changelog.md` + +## Rules and conventions + +- **Locale sync**: Non-changelog pages stay mirrored between `docs/en/` and `docs/zh/`. Changelog flows English → Chinese. +- **Terminology**: Use the term table in `docs/AGENTS.md` exactly. Do not invent new translations or use synonyms. +- **Scope discipline**: Only update sections affected by the recent changes. Do not opportunistically rewrite unrelated docs. +- **Breaking changes**: If any change is breaking, also update `docs/en/release-notes/breaking-changes.md` (under `## Unreleased`) with `**Affected**` + `**Migration**` subsections, and mirror it in `docs/zh/release-notes/breaking-changes.md`. +- **Do not edit auto-synced files**: `docs/en/release-notes/changelog.md` is regenerated by the sync script; any manual edit will be overwritten. + +## Common mistakes + +- Describing what code changed instead of what the user can now do (or can no longer do). +- Adding a new section heading per feature instead of weaving the change into existing prose. +- Updating only one locale and leaving its mirror stale. +- Editing only the mirror to fix wording that should be corrected in the locale you changed first. +- Inventing new terminology that drifts from the `docs/AGENTS.md` term table. diff --git a/.agents/skills/sync-changelog/SKILL.md b/.agents/skills/sync-changelog/SKILL.md new file mode 100644 index 000000000..13b734771 --- /dev/null +++ b/.agents/skills/sync-changelog/SKILL.md @@ -0,0 +1,247 @@ +--- +name: sync-changelog +description: Use after a release succeeds, when maintainers need to sync apps/kimi-code/CHANGELOG.md into docs/en/release-notes/changelog.md and docs/zh/release-notes/changelog.md. +--- + +# Sync Changelog + +## Overview + +`kimi-code` uses changesets for versioning. Each package gets its own `CHANGELOG.md`. The user-facing CLI package, `@moonshot-ai/kimi-code`, writes its changelog here: + +```text +apps/kimi-code/CHANGELOG.md +``` + +This file is the **only upstream source** for the documentation-site changelog. Internal package changelogs such as `packages/*/CHANGELOG.md` do not go into the documentation site. + +After the release flow finishes (Release PR merged → `Version Packages` completed → npm publish succeeded), maintainers manually run this skill to copy the new CLI changelog entries into the docs site and translate the English increment into Chinese. + +## When To Use + +- A new version has been published to npm. +- The top of `apps/kimi-code/CHANGELOG.md` contains version blocks that are not yet in `docs/en/release-notes/changelog.md`. +- The `gen-docs` flow does not run this automatically; maintainers must explicitly do it after release. + +Do **not** run this before the Release PR is merged. At that point, changesets has not yet written the new version into `apps/kimi-code/CHANGELOG.md`. + +## Source And Targets + +| File | Role | Edited by | +|---|---|---| +| `apps/kimi-code/CHANGELOG.md` | **Only upstream source**, generated by changesets | Never edit manually | +| `docs/en/release-notes/changelog.md` | English docs changelog; source of truth for docs | This skill | +| `docs/zh/release-notes/changelog.md` | Chinese docs changelog, translated from English | This skill, following `translate-docs` | + +Core rule: the English docs changelog is the source of truth, and Chinese is translated from English. This matches `translate-docs`. + +## Preconditions + +Before editing, confirm: + +- The released version exists on npm (`npm view @moonshot-ai/kimi-code versions --json`) or has a matching GitHub Release tag. +- The top of `apps/kimi-code/CHANGELOG.md` is that new version. +- The current branch is clean, or you are on a dedicated docs-sync branch. + +If any condition is not true, stop and confirm with the user. + +## Workflow + +### 1. Find The Version Range + +```bash +# Upstream versions +rg '^## ' apps/kimi-code/CHANGELOG.md | head -20 + +# Latest version already synced into the English docs page +rg '^## ' docs/en/release-notes/changelog.md | head -5 +``` + +- First sync: copy all upstream version blocks into the English page. +- Incremental sync: copy every upstream version block above the latest version already present in the English page. + +Use upstream order: newest version first. + +### 2. Strip Decorations And Extract Entry Text + +Upstream entries look like this: + +```markdown +- [#317](https://github.com/...) [`2f51db4`](https://github.com/...) - Clean up lint warnings ... +``` + +Keep: + +- Version headings such as `## 0.2.0`. +- Only the body text of each entry, after the PR/hash decoration. + +Remove: + +- The upstream H1 `# @moonshot-ai/kimi-code` because the docs page already has `# Changelog`. +- Changesets subheadings such as `### Patch Changes`, `### Minor Changes`, and `### Major Changes`. +- PR links such as `[#317](...)`. +- Commit hash links such as ``[`2f51db4`](...)``. + +After stripping, each entry should be only: + +```markdown +- +``` + +Upstream language rule: `gen-changesets` requires changelog entries to be English. If the upstream CLI changelog contains a non-English entry, stop and report it to the user. Do not silently rewrite it while syncing docs. + +### 3. Classify Entries + +The docs changelog uses four section types: + +| English section | Chinese section | Meaning | +|---|---|---| +| `### Features` | `### 新功能` | New user-facing functionality | +| `### Bug Fixes` | `### 修复` | Fixes for behavior that was broken | +| `### Refactors` | `### 重构` | Internal changes with no user-visible behavior change, including build, CI, tests, dependency cleanup, and internal renames | +| `### Other` | `### 其他` | Anything that does not fit above, such as user-visible adjustments that are not fixes or features, CDN/endpoint swaps, and docs-related artifacts | + +Classification process: + +1. Classify from the stripped entry text first. +2. If unclear, inspect the related commit or PR: + - Use the stripped commit hash with `git show `. + - Or use the PR number with `gh pr view `. +3. If it is still unclear, put it in `Other`. Do not guess or force entries into `Features`. + +Keyword hints: + +- **Features**: `Add`, `Introduce`, `Support`, `Allow`, `Enable`, `Implement`, `New ... command/flag/option` +- **Bug Fixes**: `Fix`, `Resolve`, `Correct`, `Address`, `Prevent ... from`, `Stop ... from`, `... no longer ...` +- **Refactors**: `Refactor`, `Rename`, `Clean up`, `Simplify`, `Remove unused`, `Migrate to`, `Unify`, `Restructure`, `Internal`, dependency bumps, pure CI/build/test changes +- **Other**: docs artifacts, CDN/endpoint switches, or small user-visible adjustments that are not fixes or new features + +Within each version, section order is: + +```text +Features → Bug Fixes → Refactors → Other +``` + +Omit empty sections. Preserve upstream entry order within each section. + +### 4. Write The English Page + +Never change the English page header: + +```markdown +# Changelog + +This page documents the changes in each Kimi Code CLI release. +``` + +Insert new version blocks immediately after the header paragraph and before the previous latest version. + +Example: + +```markdown +## 0.2.0 + +### Bug Fixes + +- Fix the TUI not restoring the current todo list after resuming a session. + +### Refactors + +- Clean up lint warnings across the CLI, SDK examples, and bundled runtime code without changing product behavior. +- Update the native release workflow to use current GitHub artifact actions. +``` + +### 5. Translate The Increment Into Chinese + +After updating the English page, translate only the newly added English content into `docs/zh/release-notes/changelog.md`. + +Follow `translate-docs`, direction `en → zh`. Changelog direction is English-to-Chinese even though many other docs flows use Chinese-to-English. + +Chinese page requirements: + +- Header: + + ```markdown + # 变更记录 + + 本页记录 Kimi Code CLI 每个版本的变更内容。 + ``` + +- Preserve version headings exactly, such as `## 0.2.0`. +- Translate section headings exactly: + - `### Features` → `### 新功能` + - `### Bug Fixes` → `### 修复` + - `### Refactors` → `### 重构` + - `### Other` → `### 其他` +- The Chinese page must mirror the English page 1:1 for versions, sections, section order, and entry counts. +- Keep the classification from the English page. Do not reclassify while translating. +- Translate only entry body text. Do not add entries that are not present in English. +- Follow `docs/AGENTS.md` for Chinese typography: full-width punctuation, spaces between Chinese and English, and the glossary. + +### 6. Verify + +Review: + +```bash +git diff docs/en/release-notes/changelog.md docs/zh/release-notes/changelog.md +``` + +Check: + +- Versions and version counts match between English and Chinese. +- Each version has the same section set and order on both pages. +- Each section has the same number of entries on both pages. +- PR links and commit hashes were stripped. +- There are no empty sections. +- Markdown indentation and blank lines are intact. + +Then run the docs build: + +```bash +pnpm --filter docs run build +``` + +### 7. Commit + +Use a neutral docs-sync commit message: + +```text +docs(changelog): sync from apps/kimi-code/CHANGELOG.md +``` + +Do **not** create a changeset for changelog docs sync. Docs sync does not enter the bundle. + +## Rules + +- The English docs changelog is the source of truth. +- Never edit upstream `apps/kimi-code/CHANGELOG.md`. +- Do not backfill unreleased `.changeset/*.md` drafts into the docs site. +- If upstream wording is wrong, leave upstream alone and fix it in a future changeset. + +## Common Mistakes + +| Mistake | Fix | +|---|---| +| Adding entries directly to the English docs page without reading upstream | Use `apps/kimi-code/CHANGELOG.md` as the source | +| Copying PR links or commit hashes into docs | Strip them; keep only body text | +| Rewording upstream English entries | Upstream is frozen; copy the body text unless the user explicitly asks otherwise | +| Leaving English text untranslated in the Chinese page | The Chinese page must be fully Chinese except preserved technical terms | +| Editing upstream changelog text | Do not edit upstream | +| Losing two-space indentation in multi-line list items | Restore indentation so Markdown lists stay valid | +| Copying `### Patch Changes` into docs | Remove changesets headings and classify under Features / Bug Fixes / Refactors / Other | +| Guessing unclear entries as Features | Inspect commit/PR; if still unclear, use Other | +| Reclassifying entries while translating | Chinese classification must mirror English | +| Leaving empty sections | Delete sections with no entries | +| Putting everything under Other for convenience | Classify what can be classified first | +| Translating tool names, command names, or config keys | Keep them as written | +| Creating a changeset for docs sync | Do not create one | +| Using curly quotes or half-width Chinese punctuation | Follow `docs/AGENTS.md` | + +## Stop Signals + +- The top version in `apps/kimi-code/CHANGELOG.md` is not published on npm or GitHub Releases. +- You are about to edit `apps/kimi-code/CHANGELOG.md`. +- You are about to add docs sync to a changeset. +- English and Chinese versions, entry counts, or section sets do not match. +- A section is empty. +- A Chinese term is uncertain and `docs/AGENTS.md` does not answer it. diff --git a/.agents/skills/translate-docs/SKILL.md b/.agents/skills/translate-docs/SKILL.md new file mode 100644 index 000000000..d34370225 --- /dev/null +++ b/.agents/skills/translate-docs/SKILL.md @@ -0,0 +1,65 @@ +--- +name: translate-docs +description: Translate and sync bilingual user documentation between docs/zh/ and docs/en/ following the source-of-truth rules in docs/AGENTS.md. +--- + +# Translate Docs + +## Overview + +This repository keeps bilingual user documentation under `docs/zh/` and `docs/en/`. This skill synchronizes the two locales, page by page, after either side has been updated. + +This skill is invoked by both `gen-docs` (incremental updates) and `audit-docs` (full pre-release audit) to keep locale mirrors in sync. + +## Prerequisites + +If any of the following are missing, stop and report to the user before continuing: + +- `docs/zh/` and `docs/en/` mirrored directory structure. +- `docs/AGENTS.md` — terminology table, typography rules, and source-of-truth rules. + +## Locale sync rules + +- **Changelog** (`release-notes/changelog.md`): English is the source. Translate to Chinese. +- **Breaking changes** (`release-notes/breaking-changes.md`): English is the source. Translate to Chinese. +- **All other pages**: `docs/en/` and `docs/zh/` are mirrored pairs. After either side changes, update the other locale in the same change. + +When non-changelog pages change in either locale, sync the mirror before release. When the English changelog changes, sync the Chinese changelog. + +## Workflow + +1. **Detect what needs syncing** + + - `git diff main..HEAD --stat docs/` — see which files changed + - For each changed file under `docs/en/` or `docs/zh/`, locate its mirror in the other locale (same relative path). + +2. **Translate page by page, section by section** + + - Keep heading hierarchy, list structure, code blocks, callout blocks, and link targets identical between the two versions. + - When in doubt about a technical term, **read the actual code** to confirm behavior rather than guessing. + +3. **Apply terminology and typography rules from `docs/AGENTS.md`** + + - Use the term table exactly. Do not invent translations or use synonyms. + - English H2+ uses sentence case (proper nouns excepted, per the term table). + - Chinese typography: full-width punctuation (`,。;:?!()`), space between Chinese and ASCII (letters / numbers / inline code / links). + - Callout titles (`::: tip` / `::: warning` / `::: info` / `::: danger`) use the short Chinese labels from `docs/AGENTS.md`. + +4. **Verify** + + - `git diff docs/` — scan for terminology drift or punctuation regressions. + - Run the docs build if available (`pnpm --filter docs run build` or equivalent) to catch broken links and Markdown errors. + +## Rules and conventions + +- **Do not one-sided fixes**: if the changed locale has an unclear or incorrect statement, fix it there first; do not patch only the mirror. +- **Match style, not just words**: Chinese docs use a narrative tone (see `docs/AGENTS.md` writing-style examples); preserve that tone in Chinese; preserve sentence-case headings and concise English style in English. +- **Code blocks and identifiers stay as-is**: do not translate code, command names, flag names, or file paths. + +## Common mistakes + +- Rewriting only the mirror because a phrase feels awkward in the target language — fix the changed locale first, then sync. +- Letting English headings slip into Title Case (only sentence case is allowed for H2+). +- Forgetting to add spaces between Chinese characters and inline code or English words. +- Translating proper nouns listed in the term table (`Wire`, `MCP`, `ACP`, `JSON`, `OAuth`, `macOS`, `uv`, etc.). +- Updating only one direction and leaving the other locale stale — always finish all pages flagged by the diff. diff --git a/.changeset/README.md b/.changeset/README.md new file mode 100644 index 000000000..aed0865b2 --- /dev/null +++ b/.changeset/README.md @@ -0,0 +1,152 @@ +# Changesets + +This repository uses [changesets](https://github.com/changesets/changesets) to manage npm package versions and releases. + +## Package Publishing Strategy + +This repository uses an **independent, manually-selected publishing** strategy. When generating a changeset, only select the publishable packages that this change actually affects. The repository's `.changeset/config.json` already filters out internal workspace packages via `ignore`, so only the publishable packages listed below should appear in the `pnpm changeset` prompt. + +Current publishable packages: + +| Package | Directory | Description | +| --- | --- | --- | +| `@moonshot-ai/kimi-code` | `apps/kimi-code` | CLI / TUI application — provides the `kimi` command after install | +| `@moonshot-ai/kimi-code-sdk` | `packages/node-sdk` | Public TypeScript SDK | + +All other workspace packages are private internal packages, are not published to npm, and are excluded via `ignore` in `.changeset/config.json`: + +- `@moonshot-ai/agent-core` +- `@moonshot-ai/kimi-code-oauth` +- `@moonshot-ai/kimi-telemetry` +- `@moonshot-ai/kaos` +- `@moonshot-ai/kosong` +- `@moonshot-ai/vis` +- `@moonshot-ai/vis-server` +- `@moonshot-ai/vis-web` + +Version impact from internal dependencies must be judged manually. The published artifacts for CLI and SDK bundle internal workspace packages into the artifact itself; runtime `dependencies` of published packages must not include any `@moonshot-ai/*` internal workspace packages. + +The repository's `.changeset/config.json` sets `updateInternalDependencies: "patch"`. Because internal packages are not published, you still need to manually select all affected publishable packages in the changeset — do not rely solely on automatic dependency bumps to express user-visible changes. + +Example scenarios: + +| Change | Changeset selection | +| --- | --- | +| Only modifies TUI behavior in `@moonshot-ai/kimi-code` | Add `patch` / `minor` / `major` to `@moonshot-ai/kimi-code` | +| Only modifies internal packages, no user-visible change in SDK / CLI | Usually no changeset needed | +| Internal package fix changes the CLI user experience | Add a changeset to `@moonshot-ai/kimi-code` describing the user-visible fix | +| Internal package adds a new capability exposed by the SDK | Add a changeset to `@moonshot-ai/kimi-code-sdk` | +| SDK behavior change affects CLI user experience | Add changesets to both `@moonshot-ai/kimi-code-sdk` and `@moonshot-ai/kimi-code` | +| Provider abstraction change affects SDK / CLI | Add changesets to the affected `@moonshot-ai/kimi-code-sdk` and/or `@moonshot-ai/kimi-code` | +| Test-only, internal refactor, docs, or private debug tooling changes | Usually no changeset needed | + +## Prerequisite: NPM Trusted Publishing (OIDC) + +This repository uses npm's **Trusted Publishing** (OIDC-based) for publishing — no `NPM_TOKEN` is required. + +### Configuration steps + +1. Open each publishable package's page on the npm website, e.g. `https://www.npmjs.com/package/@moonshot-ai/kimi-code`. +2. Go to **Settings** -> **Publishing access**. +3. Find **Automate publishing with GitHub Actions** or **Add trusted publisher**. +4. Click **Add a new trusted publisher**. + +Fill in the following: + +| Field | Value | +| --- | --- | +| GitHub Organization | `MoonshotAI` | +| GitHub Repository | `kimi-code` | +| GitHub Workflow | `release.yml` | +| Environment | leave empty | + +Each publishable package needs its Trusted Publisher configured once. The current GitHub Actions workflow lives at `.github/workflows/release.yml` and already has `id-token: write` configured. + +## Development Workflow + +### 1. Implement the feature or fix + +Complete code, tests, and documentation changes as usual. A changeset is required when the change affects user-visible behavior, public API, dependency ranges, or release artifacts of a publishable package. + +### 2. Generate a changeset + +From the repository root: + +```sh +pnpm changeset +``` + +Follow the prompts to choose: + +- Which publishable packages this change affects; +- The version bump level: + - `patch`: bug fixes, small changes, follow-up dependency updates; + - `minor`: backward-compatible new features; + - `major`: breaking changes; +- A user-facing description of the change. + +The command creates a `.changeset/*.md` file that must be committed alongside the code. + +### 3. Commit the changeset + +```sh +git add .changeset/ +git commit -m "chore: add changeset for package release" +git push +``` + +Commit messages must follow Conventional Commit style. Do not include any author/agent identity in the commit message. + +### 4. CI generates the release PR + +Once the changeset file is merged into `main`, `.github/workflows/release.yml` uses `changesets/action@v1` to create or update a release PR. + +The release PR runs: + +- `pnpm changeset version`: bumps publishable package versions and updates changelogs; +- Deletes the consumed `.changeset/*.md` files; +- Uses the title `[CI]: Release packages`. + +### 5. Merge the release PR + +Once the release PR is merged into `main`, the same workflow runs: + +- `pnpm install --frozen-lockfile` +- `pnpm build` +- `pnpm changeset publish` + +The packages are then published via npm Trusted Publishing, and a GitHub Release is created. + +## Manual Publishing (Not Recommended) + +Only publish manually when CI is unavailable. Before publishing manually, make sure you are logged into npm locally and using the Node.js and pnpm versions required by the repository. + +```sh +pnpm run version +pnpm run publish +``` + +The underlying changesets commands are: + +```sh +pnpm changeset version +pnpm changeset publish +``` + +The root-level `pnpm run publish` first runs typecheck, lint, sherif, test, build, and package lint, then runs `changeset publish`. + +## Notes + +- Every PR that affects publishable-package behavior or public API should include a corresponding changeset. +- Changeset files must be committed to the repository — release PRs are only triggered after they're merged. +- Release PRs require human review and merge; they will not publish automatically. +- Do not add release changesets for private internal packages; only select `@moonshot-ai/kimi-code` and `@moonshot-ai/kimi-code-sdk`. +- If a change in an underlying internal package alters user-visible behavior or public API of a publishable package, add a changeset to the affected publishable package. For example, when a bug fixed in `@moonshot-ai/agent-core` resolves an issue CLI users encounter, add a changeset to `@moonshot-ai/kimi-code` describing the user-visible fix. +- `@moonshot-ai/kimi-code` is the official CLI package name; after a global install it provides the `kimi` command. +- Make sure each publishable package on npm has a Trusted Publisher configured. + +## References + +- [Changesets documentation](https://github.com/changesets/changesets) +- [Changesets GitHub Action](https://github.com/changesets/action) +- [npm Trusted Publishing documentation](https://docs.npmjs.com/trusted-publishers) diff --git a/.changeset/config.json b/.changeset/config.json new file mode 100644 index 000000000..af6143de9 --- /dev/null +++ b/.changeset/config.json @@ -0,0 +1,18 @@ +{ + "changelog": ["@changesets/changelog-github", { "repo": "MoonshotAI/kimi-code", "disableThanks": true }], + "commit": false, + "fixed": [], + "linked": [], + "access": "public", + "baseBranch": "main", + "updateInternalDependencies": "patch", + "ignore": [ + "@moonshot-ai/vis", + "@moonshot-ai/vis-server", + "@moonshot-ai/vis-web" + ], + "snapshot": { + "useCalculatedVersion": true, + "prereleaseTemplate": "{tag}-{commit}" + } +} diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 000000000..4039ff111 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,12 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +indent_size = 2 +indent_style = space +insert_final_newline = true +trim_trailing_whitespace = true + +[*.md] +trim_trailing_whitespace = false diff --git a/.github/ISSUE_TEMPLATE/1-bug-report.yml b/.github/ISSUE_TEMPLATE/1-bug-report.yml new file mode 100644 index 000000000..d38aab205 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/1-bug-report.yml @@ -0,0 +1,65 @@ +name: Bug Report +description: Report an issue that should be fixed +labels: + - bug + - needs triage +body: + - type: markdown + attributes: + value: | + Thank you for submitting a bug report! It helps make Kimi Code better for everyone. + + Make sure you are running the latest version of Kimi Code CLI. The bug you are experiencing may already have been fixed. + + Please try to include as much information as possible. + + - type: input + id: version + attributes: + label: What version of Kimi Code is running? + description: Copy the output of `kimi --version` or `/version` + validations: + required: true + - type: input + id: plan + attributes: + label: Which open platform/subscription were you using? + description: The one you selected when running `/login` + validations: + required: true + - type: input + id: model + attributes: + label: Which model were you using? + description: The one you can see on the bottom status line, like `kimi-k2.6`, `kimi-for-coding`, etc. + - type: input + id: platform + attributes: + label: What platform is your computer? + description: | + For MacOS and Linux: copy the output of `uname -mprs` + For Windows: copy the output of `"$([Environment]::OSVersion | ForEach-Object VersionString) $(if ([Environment]::Is64BitOperatingSystem) { "x64" } else { "x86" })"` in the PowerShell console + - type: textarea + id: actual + attributes: + label: What issue are you seeing? + description: Please include the full error messages and prompts with any private information redacted. If possible, please provide text instead of a screenshot. + validations: + required: true + - type: textarea + id: steps + attributes: + label: What steps can reproduce the bug? + description: Explain the bug and provide a code snippet that can reproduce it. Please include session id and context usage if applicable. + validations: + required: true + - type: textarea + id: expected + attributes: + label: What is the expected behavior? + description: If possible, please provide text instead of a screenshot. + - type: textarea + id: notes + attributes: + label: Additional information + description: Is there anything else you think we should know? diff --git a/.github/ISSUE_TEMPLATE/2-feature-request.yml b/.github/ISSUE_TEMPLATE/2-feature-request.yml new file mode 100644 index 000000000..cbc055334 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/2-feature-request.yml @@ -0,0 +1,25 @@ +name: Feature Request +description: Propose a new feature for Kimi Code +labels: + - enhancement +body: + - type: markdown + attributes: + value: | + Is Kimi Code missing a feature that you'd like to see? Feel free to propose it here. + + Before you submit a feature: + 1. Search existing issues for similar features. If you find one, 👍 it rather than opening a new one. + 2. The Kimi Code team will try to balance the varying needs of the community when prioritizing or rejecting new features. Please understand that not all features will be accepted. + + - type: textarea + id: feature + attributes: + label: What feature would you like to see? + validations: + required: true + - type: textarea + id: notes + attributes: + label: Additional information + description: Is there anything else you think we should know? diff --git a/.github/actions/macos-keychain-cleanup/action.yml b/.github/actions/macos-keychain-cleanup/action.yml new file mode 100644 index 000000000..1cc377a1c --- /dev/null +++ b/.github/actions/macos-keychain-cleanup/action.yml @@ -0,0 +1,12 @@ +name: macOS keychain cleanup +description: Delete the temporary keychain created by macos-keychain-setup. + +runs: + using: composite + steps: + - name: Cleanup keychain + shell: bash + run: | + if [[ -n "${APPLE_KEYCHAIN_PATH:-}" && -f "${APPLE_KEYCHAIN_PATH}" ]]; then + security delete-keychain "$APPLE_KEYCHAIN_PATH" || true + fi diff --git a/.github/actions/macos-keychain-setup/action.yml b/.github/actions/macos-keychain-setup/action.yml new file mode 100644 index 000000000..199f8ed7f --- /dev/null +++ b/.github/actions/macos-keychain-setup/action.yml @@ -0,0 +1,82 @@ +name: macOS keychain setup +description: | + Create a temporary keychain, import a Developer ID Application certificate, + discover the signing identity, and export APPLE_SIGNING_IDENTITY + + APPLE_KEYCHAIN_PATH to GITHUB_ENV for subsequent steps. Must be paired + with macos-keychain-cleanup in an if: always() step at job end. + +inputs: + certificate-p12: + description: Base64-encoded P12 certificate + required: true + certificate-password: + description: P12 password + required: true + +runs: + using: composite + steps: + - name: Import signing certificate + shell: bash + env: + APPLE_CERTIFICATE_P12: ${{ inputs.certificate-p12 }} + APPLE_CERTIFICATE_PASSWORD: ${{ inputs.certificate-password }} + KEYCHAIN_PASSWORD: actions + run: | + set -euo pipefail + + # 0. Validate inputs early — base64 decoding "" gives 0-byte .p12 → security import reports + # "SecKeychainItemImport: One or more parameters passed to a function were not valid" + # which is cryptic. Fail loudly here instead. + if [ -z "$APPLE_CERTIFICATE_P12" ]; then + echo "::error::APPLE_CERTIFICATE_P12 secret is empty. Configure it in repo settings, or pass sign-macos=false." + exit 1 + fi + if [ -z "$APPLE_CERTIFICATE_PASSWORD" ]; then + echo "::error::APPLE_CERTIFICATE_PASSWORD secret is empty." + exit 1 + fi + + # 1. Decode certificate + cert_path="${RUNNER_TEMP}/certificate.p12" + printf '%s' "$APPLE_CERTIFICATE_P12" | base64 -d > "$cert_path" + if [ ! -s "$cert_path" ]; then + echo "::error::Decoded certificate is empty. APPLE_CERTIFICATE_P12 secret value may not be valid base64." + exit 1 + fi + + # 2. Create temporary keychain (don't pollute runner default keychain) + keychain_path="${RUNNER_TEMP}/signing.keychain-db" + security create-keychain -p "$KEYCHAIN_PASSWORD" "$keychain_path" + security set-keychain-settings -lut 21600 "$keychain_path" + security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$keychain_path" + + # 3. Add to user keychain search list and set default + security list-keychains -d user -s "$keychain_path" $(security list-keychains -d user | tr -d '"') + security default-keychain -s "$keychain_path" + + # 4. Import cert, authorize codesign + security tools + security import "$cert_path" -k "$keychain_path" \ + -P "$APPLE_CERTIFICATE_PASSWORD" \ + -T /usr/bin/codesign -T /usr/bin/security + + # 5. Non-interactive private key access + security set-key-partition-list -S apple-tool:,apple: \ + -s -k "$KEYCHAIN_PASSWORD" "$keychain_path" > /dev/null + + # 6. Discover identity (don't hardcode team ID) + IDENTITY=$(security find-identity -v -p codesigning "$keychain_path" \ + | grep "Developer ID Application" | head -1 \ + | sed -n 's/.*"\(Developer ID Application[^"]*\)".*/\1/p') + + if [[ -z "$IDENTITY" ]]; then + echo "::error::No Developer ID Application identity found in keychain" + security find-identity -v -p codesigning "$keychain_path" + exit 1 + fi + + echo "Found signing identity: $IDENTITY" + echo "APPLE_SIGNING_IDENTITY=$IDENTITY" >> "$GITHUB_ENV" + echo "APPLE_KEYCHAIN_PATH=$keychain_path" >> "$GITHUB_ENV" + + rm -f "$cert_path" diff --git a/.github/actions/macos-notarize/action.yml b/.github/actions/macos-notarize/action.yml new file mode 100644 index 000000000..9a919d810 --- /dev/null +++ b/.github/actions/macos-notarize/action.yml @@ -0,0 +1,95 @@ +name: macOS notarize +description: | + Submit a signed binary to Apple notary service via notarytool, wait for + result, and run Gatekeeper online check (spctl). Fails the job with the + notarytool log on rejection. + +inputs: + binary-path: + description: Absolute path to the signed binary + required: true + notarization-key-p8: + description: Base64-encoded App Store Connect API Key (.p8) + required: true + notarization-key-id: + description: API Key ID + required: true + notarization-issuer-id: + description: Issuer ID + required: true + timeout: + description: notarytool wait timeout (e.g. 15m) + required: false + default: '15m' + +runs: + using: composite + steps: + - name: Submit to notary service + shell: bash + env: + BINARY_PATH: ${{ inputs.binary-path }} + APPLE_NOTARIZATION_KEY_P8: ${{ inputs.notarization-key-p8 }} + APPLE_NOTARIZATION_KEY_ID: ${{ inputs.notarization-key-id }} + APPLE_NOTARIZATION_ISSUER_ID: ${{ inputs.notarization-issuer-id }} + NOTARY_TIMEOUT: ${{ inputs.timeout }} + run: | + set -euo pipefail + + if [[ ! -f "$BINARY_PATH" ]]; then + echo "::error::Binary not found at $BINARY_PATH" + exit 1 + fi + + # 1. Decode API key + key_path="${RUNNER_TEMP}/AuthKey.p8" + printf '%s' "$APPLE_NOTARIZATION_KEY_P8" | base64 -d > "$key_path" + + # 2. Pack with ditto (--norsrc avoids ._AppleDouble files) + binary_name=$(basename "$BINARY_PATH") + zip_path="${RUNNER_TEMP}/${binary_name}.notarize.zip" + ditto -c -k --norsrc --keepParent "$BINARY_PATH" "$zip_path" + + echo "==> Submitting $binary_name for notarization..." + + # 3. Submit and capture log + log_path="${RUNNER_TEMP}/notarize-${binary_name}.log" + set +e + xcrun notarytool submit "$zip_path" \ + --key "$key_path" \ + --key-id "$APPLE_NOTARIZATION_KEY_ID" \ + --issuer "$APPLE_NOTARIZATION_ISSUER_ID" \ + --wait --timeout "$NOTARY_TIMEOUT" 2>&1 | tee "$log_path" + notarize_rc=${PIPESTATUS[0]} + set -e + + # 4. Inspect status + if ! grep -q "status: Accepted" "$log_path"; then + echo "::error::Notarization failed (exit=$notarize_rc)" + submission_id=$(grep -m1 "id:" "$log_path" | awk '{print $2}' || true) + if [[ -n "$submission_id" ]]; then + echo "Fetching detailed log for submission $submission_id..." + xcrun notarytool log "$submission_id" \ + --key "$key_path" \ + --key-id "$APPLE_NOTARIZATION_KEY_ID" \ + --issuer "$APPLE_NOTARIZATION_ISSUER_ID" || true + fi + rm -f "$key_path" "$zip_path" + exit 1 + fi + + echo "==> Notarization accepted" + + # 5. Cleanup + rm -f "$key_path" "$zip_path" + + - name: Verify with Gatekeeper online check + shell: bash + env: + BINARY_PATH: ${{ inputs.binary-path }} + run: | + set -euo pipefail + echo "==> codesign -dv $BINARY_PATH" + codesign -dv --verbose=2 "$BINARY_PATH" + echo "==> spctl -a -vvv -t install $BINARY_PATH" + spctl -a -vvv -t install "$BINARY_PATH" diff --git a/.github/pr-title-checker-config.json b/.github/pr-title-checker-config.json new file mode 100644 index 000000000..c1000ebb4 --- /dev/null +++ b/.github/pr-title-checker-config.json @@ -0,0 +1,12 @@ +{ + "LABEL": { + "name": "Invalid PR Title", + "color": "B60205" + }, + "CHECKS": { + "regexp": "^(feat|fix|test|refactor|chore|style|docs|perf|build|ci|revert)(\\(.*\\))?:.*" + }, + "MESSAGES": { + "failure": "The PR title is invalid. Please refer to https://www.conventionalcommits.org/en/v1.0.0/ for the convention." + } +} diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 000000000..020e01f04 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,25 @@ + + +## Related Issue + + + +Resolve #(issue_number) + +## Description + + + +## Checklist + +- [ ] I have read the [CONTRIBUTING](https://github.com/MoonshotAI/kimi-code/blob/main/CONTRIBUTING.md) document. +- [ ] I have linked the related issue, if any. +- [ ] I have added tests that prove my fix is effective or that my feature works. +- [ ] Ran `gen-changesets` skill, or this PR needs no changeset. +- [ ] Ran `gen-docs` skill, or this PR needs no doc update. diff --git a/.github/workflows/_native-build.yml b/.github/workflows/_native-build.yml new file mode 100644 index 000000000..439d23544 --- /dev/null +++ b/.github/workflows/_native-build.yml @@ -0,0 +1,115 @@ +name: _native-build + +on: + workflow_call: + inputs: + upload-artifact-prefix: + description: 'Prefix for uploaded artifact name' + required: false + type: string + default: 'kimi-code-native' + retention-days: + description: 'Artifact retention in days' + required: false + type: number + default: 3 + sign-macos: + description: 'Whether to sign + notarize macOS builds (requires Apple secrets)' + required: false + type: boolean + default: false + 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: + native-bundle: + name: Native bundle (${{ matrix.target }}) + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + include: + - os: ubuntu-24.04 + target: linux-x64 + - os: ubuntu-24.04-arm + target: linux-arm64 + - os: macos-15-intel + target: darwin-x64 + - os: macos-15 + target: darwin-arm64 + - os: windows-2025-vs2026 + target: win32-x64 + - os: windows-11-arm + target: win32-arm64 + + 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: Setup macOS keychain (release only) + 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: Build native executable (release profile, macOS signed) + if: runner.os == 'macOS' && inputs.sign-macos + run: pnpm --filter @moonshot-ai/kimi-code run build:native:release + + - name: Build native executable (local profile) + if: '!(runner.os == ''macOS'' && inputs.sign-macos)' + run: pnpm --filter @moonshot-ai/kimi-code run build:native:sea + + - name: Notarize macOS binary + if: runner.os == 'macOS' && inputs.sign-macos + uses: ./.github/actions/macos-notarize + with: + binary-path: ${{ github.workspace }}/apps/kimi-code/dist-native/bin/${{ matrix.target }}/kimi + notarization-key-p8: ${{ secrets.APPLE_NOTARIZATION_KEY_P8 }} + notarization-key-id: ${{ secrets.APPLE_NOTARIZATION_KEY_ID }} + notarization-issuer-id: ${{ secrets.APPLE_NOTARIZATION_ISSUER_ID }} + + - name: Cleanup macOS keychain + if: always() && runner.os == 'macOS' && inputs.sign-macos + uses: ./.github/actions/macos-keychain-cleanup + + - name: Smoke test native executable + run: pnpm --filter @moonshot-ai/kimi-code run test:native:smoke + + - name: Package native artifact + run: pnpm --filter @moonshot-ai/kimi-code run package:native + + - name: Upload native artifact + uses: actions/upload-artifact@v7 + with: + name: ${{ inputs.upload-artifact-prefix }}-${{ matrix.target }} + retention-days: ${{ inputs.retention-days }} + path: | + apps/kimi-code/dist-native/artifacts/kimi-code-${{ matrix.target }}.zip + apps/kimi-code/dist-native/artifacts/kimi-code-${{ matrix.target }}.zip.sha256 + if-no-files-found: ignore diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 000000000..c7ceac2ad --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,84 @@ +name: CI + +on: + pull_request: + push: + branches: + - main + +permissions: + contents: read + +jobs: + build: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - uses: pnpm/action-setup@v6 + + - uses: actions/setup-node@v6 + with: + node-version-file: .nvmrc + cache: pnpm + + - run: pnpm install --frozen-lockfile + - run: pnpm run build + - name: Smoke test CLI bundle + run: pnpm -C apps/kimi-code run smoke + + test: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - uses: pnpm/action-setup@v6 + + - uses: actions/setup-node@v6 + with: + node-version-file: .nvmrc + cache: pnpm + + - run: pnpm install --frozen-lockfile + - run: pnpm run test + + lint: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - uses: pnpm/action-setup@v6 + + - uses: actions/setup-node@v6 + with: + node-version-file: .nvmrc + cache: pnpm + + - run: pnpm install --frozen-lockfile + - run: pnpm run lint + - run: pnpm run sherif + + typecheck: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - uses: pnpm/action-setup@v6 + + - uses: actions/setup-node@v6 + with: + node-version-file: .nvmrc + cache: pnpm + + - run: pnpm install --frozen-lockfile + - name: Typecheck + run: | + pnpm dlx --package @typescript/native-preview@beta tsgo --version + for config in packages/*/tsconfig.json apps/kimi-code/tsconfig.json; do + echo "Typechecking ${config}" + pnpm dlx --package @typescript/native-preview@beta tsgo -p "${config}" --noEmit + done diff --git a/.github/workflows/docs-deploy.yml b/.github/workflows/docs-deploy.yml new file mode 100644 index 000000000..1d54f305b --- /dev/null +++ b/.github/workflows/docs-deploy.yml @@ -0,0 +1,60 @@ +name: Deploy Docs to GitHub Pages + +on: + push: + branches: + - main + paths: + - 'docs/**' + - '.github/workflows/docs-deploy.yml' + + workflow_dispatch: + +permissions: + contents: read + pages: write + id-token: write + +concurrency: + group: pages + cancel-in-progress: false + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + + - uses: pnpm/action-setup@v6 + + - uses: actions/setup-node@v6 + with: + node-version-file: .nvmrc + cache: pnpm + + - run: pnpm install --frozen-lockfile + + - name: Install docs dependencies + run: pnpm --ignore-workspace -C docs install + + - name: Build docs + run: pnpm -C docs run build + env: + VITEPRESS_BASE: /${{ github.event.repository.name }}/ + + - name: Upload artifact + uses: actions/upload-pages-artifact@v5 + with: + path: docs/.vitepress/dist + + deploy: + if: github.ref == 'refs/heads/main' + needs: build + runs-on: ubuntu-latest + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + steps: + - name: Deploy to GitHub Pages + id: deployment + uses: actions/deploy-pages@v5 diff --git a/.github/workflows/native-bundle.yml b/.github/workflows/native-bundle.yml new file mode 100644 index 000000000..c9aa010c5 --- /dev/null +++ b/.github/workflows/native-bundle.yml @@ -0,0 +1,21 @@ +name: Native Bundle + +on: + workflow_dispatch: + +permissions: + contents: read + +jobs: + build: + uses: ./.github/workflows/_native-build.yml + with: + upload-artifact-prefix: kimi-code-native + retention-days: 3 + 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 }} diff --git a/.github/workflows/pr-title-checker.yml b/.github/workflows/pr-title-checker.yml new file mode 100644 index 000000000..00bfdf20f --- /dev/null +++ b/.github/workflows/pr-title-checker.yml @@ -0,0 +1,15 @@ +name: PR Title Checker + +on: + pull_request: + types: [opened, edited, labeled] + +jobs: + check: + runs-on: ubuntu-latest + name: pr-title-checker + steps: + - uses: thehanimo/pr-title-checker@v1.4.3 + with: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + configuration_path: ".github/pr-title-checker-config.json" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 000000000..fc0b9a8bb --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,112 @@ +name: Release + +on: + push: + branches: + - main + +concurrency: ${{ github.workflow }}-${{ github.ref }} + +jobs: + release: + name: Release + runs-on: ubuntu-latest + if: github.repository_owner == 'MoonshotAI' + outputs: + kimi_native_release: ${{ steps.kimi-release.outputs.should_publish }} + kimi_release_tag: ${{ steps.kimi-release.outputs.tag }} + permissions: + contents: write + pull-requests: write + id-token: write # Required for NPM Trusted Publishing (OIDC) + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Setup pnpm + uses: pnpm/action-setup@v6 + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version-file: .nvmrc + cache: 'pnpm' + registry-url: 'https://registry.npmjs.org' + + - name: Upgrade npm for Trusted Publishing + run: npm install -g npm@latest + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Build packages + run: pnpm build + + - name: Create Release Pull Request or Publish to npm + id: changesets + uses: changesets/action@v1 + with: + publish: pnpm changeset publish + version: pnpm changeset version + commit: 'ci: release packages' + title: 'ci: release packages' + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + # NPM_TOKEN is not needed with Trusted Publishing (OIDC) + + - name: Resolve Kimi Code native release + if: steps.changesets.outputs.published == 'true' + id: kimi-release + run: node apps/kimi-code/scripts/native/resolve-release.mjs + env: + CHANGESETS_PUBLISHED_PACKAGES: ${{ steps.changesets.outputs.publishedPackages }} + + native-artifacts: + name: Native release artifact + needs: release + if: needs.release.outputs.kimi_native_release == 'true' + uses: ./.github/workflows/_native-build.yml + with: + upload-artifact-prefix: kimi-code-native + 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: + - release + - native-artifacts + if: needs.release.outputs.kimi_native_release == 'true' + runs-on: ubuntu-latest + permissions: + contents: write + + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Download native artifacts + uses: actions/download-artifact@v8 + with: + pattern: kimi-code-native-* + path: dist-native-release + merge-multiple: true + + - name: Produce manifest.json + env: + RELEASE_TAG: ${{ needs.release.outputs.kimi_release_tag }} + run: node apps/kimi-code/scripts/native/produce-manifest.mjs dist-native-release "$RELEASE_TAG" + + - name: Upload assets to GitHub Release + env: + GH_TOKEN: ${{ github.token }} + RELEASE_TAG: ${{ needs.release.outputs.kimi_release_tag }} + run: gh release upload "$RELEASE_TAG" dist-native-release/* --clobber diff --git a/.gitignore b/.gitignore new file mode 100644 index 000000000..13b316428 --- /dev/null +++ b/.gitignore @@ -0,0 +1,13 @@ +node_modules/ +dist/ +dist-native/ +.tmp-api-extractor/ +coverage/ +*.tsbuildinfo +.vitest-results/ +.DS_Store +.playwright-mcp/ +.claude +.conductor +.kimi-stash-dir +superpowers/ diff --git a/.npmrc b/.npmrc new file mode 100644 index 000000000..fd6158e56 --- /dev/null +++ b/.npmrc @@ -0,0 +1,3 @@ +auto-install-peers=true +engine-strict=true +strict-peer-dependencies=false diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 000000000..5bf4400f2 --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +24.15.0 diff --git a/.oxfmtrc.json b/.oxfmtrc.json new file mode 100644 index 000000000..493d050e2 --- /dev/null +++ b/.oxfmtrc.json @@ -0,0 +1,19 @@ +{ + "$schema": "./node_modules/oxfmt/configuration_schema.json", + "printWidth": 100, + "tabWidth": 2, + "useTabs": false, + "semi": true, + "singleQuote": true, + "trailingComma": "all", + "bracketSpacing": true, + "arrowParens": "always", + "endOfLine": "lf", + "sortImports": { + "groups": ["builtin", "external", "internal", ["parent", "sibling", "index"], "unknown"], + "newlinesBetween": true, + "order": "asc" + }, + "sortPackageJson": true, + "ignorePatterns": ["dist/", "coverage/", "pnpm-lock.yaml", "*.generated.ts"] +} diff --git a/.oxlintrc.json b/.oxlintrc.json new file mode 100644 index 000000000..877553f49 --- /dev/null +++ b/.oxlintrc.json @@ -0,0 +1,155 @@ +{ + "$schema": "./node_modules/oxlint/configuration_schema.json", + "categories": { + "correctness": "error", + "suspicious": "warn", + "pedantic": "warn", + "perf": "warn" + }, + "plugins": ["typescript", "import", "unicorn", "promise", "node"], + "rules": { + "eslint/no-useless-return": "off", + "eslint/no-shadow": "off", + "eslint/eqeqeq": "error", + "eslint/no-throw-literal": "off", + "eslint/no-control-regex": "off", + "typescript/no-misused-promises": "error", + "typescript/return-await": "error", + "typescript/only-throw-error": "error", + "import/no-cycle": "error", + "import/no-self-import": "error", + "import/no-unassigned-import": "off", + "unicorn/prefer-node-protocol": "error", + "unicorn/no-useless-undefined": "off", + "unicorn/no-lonely-if": "off", + "unicorn/no-array-callback-reference": "off", + "typescript/prefer-promise-reject-errors": "off", + "typescript/no-unsafe-function-type": "off", + "typescript/no-unsafe-argument": "off", + "typescript/no-unsafe-assignment": "off", + "typescript/no-unsafe-call": "off", + "typescript/no-unsafe-member-access": "off", + "typescript/no-unsafe-return": "off", + + "eslint/no-console": "warn", + "typescript/no-explicit-any": "off", + "typescript/no-non-null-assertion": "off", + "typescript/use-unknown-in-catch-callback-variable": "off", + "typescript/consistent-type-imports": "off", + "typescript/ban-types": "off", + "import/first": "warn", + "import/extensions": [ + "error", + "ignorePackages", + { + "js": "never", + "ts": "never", + "tsx": "never" + } + ], + "import/no-duplicates": "warn", + "import/no-mutable-exports": "warn", + "unicorn/error-message": "warn", + "unicorn/throw-new-error": "warn", + "unicorn/catch-error-name": "warn", + "unicorn/prefer-add-event-listener": "off", + "node/no-new-require": "warn", + "node/no-path-concat": "warn", + + "promise/no-callback-in-promise": "warn", + "promise/valid-params": "warn", + "promise/no-return-in-finally": "warn", + "promise/always-return": "off", + + "eslint/max-classes-per-file": "off", + "eslint/max-depth": "off", + "eslint/max-lines": "off", + "eslint/max-lines-per-function": "off", + "eslint/max-nested-callbacks": "off", + "eslint/no-warning-comments": "off", + "eslint/no-inline-comments": "off", + "eslint/no-negated-condition": "off", + "eslint/no-await-in-loop": "off", + "eslint/require-await": "off", + "typescript/require-await": "off", + "eslint/sort-vars": "off", + "eslint/radix": "off", + "eslint/symbol-description": "off", + "typescript/consistent-return": "off", + "unicorn/consistent-function-scoping": "off", + "typescript/no-deprecated": "off", + "typescript/no-unsafe-type-assertion": "off", + "typescript/strict-boolean-expressions": "off", + "typescript/no-duplicate-type-constituents": "off", + "import/max-dependencies": "off" + }, + "overrides": [ + { + "files": ["**/examples/**/*.ts", "**/examples/**/*.tsx"], + "rules": { + "eslint/no-console": "off" + } + }, + { + "files": ["packages/kosong/src/providers/**/*.ts"], + "rules": { + "typescript/no-unsafe-argument": "off", + "typescript/no-unsafe-assignment": "off", + "typescript/no-unsafe-call": "off", + "typescript/no-unsafe-member-access": "off", + "typescript/no-unsafe-return": "off" + } + }, + { + "files": [ + "**/*.test.ts", + "**/*.test.tsx", + "**/*.spec.ts", + "**/*.spec.tsx", + "**/test/**/*.ts", + "**/test/**/*.tsx" + ], + "plugins": ["vitest"], + "rules": { + "typescript/no-explicit-any": "off", + "typescript/no-unsafe-assignment": "off", + "typescript/no-unsafe-argument": "off", + "typescript/no-non-null-assertion": "off", + "typescript/unbound-method": "off", + "typescript/no-redundant-type-constituents": "off", + "eslint/no-console": "off", + "eslint/no-promise-executor-return": "off", + "typescript/no-unsafe-member-access": "off", + "vitest/require-mock-type-parameters": "off", + "eslint/no-shadow": "off", + "unicorn/prefer-event-target": "off", + "jest/no-disabled-tests": "off", + "vitest/no-disabled-tests": "off", + "eslint/no-control-regex": "off", + "eslint/no-unused-vars": "off", + "eslint/no-unsafe-optional-chaining": "off", + "jest/valid-expect": "off", + "unicorn/no-useless-spread": "off", + "import/no-cycle": "off", + "typescript/no-meaningless-void-operator": "off", + "typescript/require-array-sort-compare": "off", + "vitest/expect-expect": "error", + "vitest/no-conditional-tests": "error", + "vitest/no-focused-tests": "error", + "vitest/no-identical-title": "error", + "vitest/valid-expect": "off", + "vitest/valid-describe-callback": "error", + "vitest/no-standalone-expect": "error", + "vitest/warn-todo": "off" + } + } + ], + "ignorePatterns": [ + "dist/", + "coverage/", + "node_modules/", + "apps/*/scripts/", + "docs/smoke-archive/", + "*.generated.ts" + ] +} diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 000000000..ddb2cea34 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,60 @@ +# Repository-level Agent Guide + +Reply in the same language as the user. + +This is a TypeScript monorepo built for agent-assisted development. Keep the root `AGENTS.md` limited to hot-path rules: the project map, hard constraints, and workflow requirements — things every task needs to know. + +## Working Principles + +- Think from first principles. Start from real requirements, code facts, and verification results; if the goal is unclear, discuss it with the user first. +- Treat code, not documentation, as the source of truth. Unless the user explicitly says otherwise, do not read ordinary Markdown just to understand the implementation. +- Before making code changes, read the relevant code and the most recent constraints, and follow the nearest `AGENTS.md` in the directory tree. +- Keep changes focused. Do not slip in unrelated refactors along the way. +- When committing, do not add any co-author attribution, and do not reveal the identity of the agent in commit messages, PR descriptions, or any explanatory text. + +## Project Map + +- `apps/kimi-code`: the CLI / TUI application. It consumes core capabilities through `@moonshot-ai/kimi-code-sdk` and must not depend directly on `@moonshot-ai/agent-core`. +- `apps/vis`, `apps/vis/server`, `apps/vis/web`: visual debugging tools for sessions and replays. +- `packages/agent-core`: the unified agent engine, including Agent, Session, profile, skills, tools, plan, permission, background, records, 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. + +## Environment Requirements + +- **Node.js**: `>=24.15.0` (from the root `package.json` `engines`; `.nvmrc` is `24.15.0`, used by nvm / fnm / mise to pick the minimum recommended version). +- **pnpm**: `10.33.0` (from the root `package.json` `packageManager`). +- `pnpm install` will fail when the Node version is not satisfied, because `.npmrc` sets `engine-strict=true`. + +## General Coding Rules + +- For optional object properties, pass `undefined` directly instead of using conditional spread. + - YES: `{ user }` + - NO: `{ ...(user ? { user } : undefined) }` +- Optional object properties do not need to additionally allow `undefined` in the type. + - YES: `interface Options { user?: User }` + - NO: `interface Options { user?: User | undefined }` +- Internal methods with only a single parameter should not be turned into options objects just for stylistic uniformity. +- Except for a package's `index.ts`, other `index.ts` files should prefer `export * from './module';`. +- The `Agent` class in `packages/agent-core/src/agent` must be usable on its own. The constructor must not force the caller to create a `Session` instance, nor require an `agentId` or `session`. It may accept an optional `sessionId` as a request-config hint — for example mapped to the provider's `prompt_cache_key` — but the instance must not hold `sessionId`, and must not depend on the Session lifecycle, metadata, or parent/child relationship logic. +- Do not add too many new test files. Prefer adding tests to the existing test file of the corresponding component or module. +- When a test fails because of a user modification, default to fixing the test first; do not change the implementation to satisfy an old test unless the implementation truly has a bug. +- Do not sacrifice code quality for external compatibility unless the user explicitly asks for it. Breaking changes go through changesets and a `major` bump, gated by the rule below. + +## Where to Update Instructions + +- Hard rules that affect almost every task: update the root `AGENTS.md`. +- Rules that only affect a specific directory: update the nearest sub-directory `AGENTS.md`. +- Keep instruction updates focused and supported by code facts. + +## Workflow Requirements + +- Prefer `rg` / `rg --files` when reading code. +- When designing changes, follow existing boundaries and local patterns first. +- When creating a PR, the PR title must follow Conventional Commit style, e.g. `chore: remove legacy format commands`. +- After finishing a task and before submitting a PR, you must run the `gen-changesets` skill (see `.agents/skills/gen-changesets/SKILL.md`) and generate a changeset under `.changeset/` according to its rules. +- When generating a changeset, **never** decide on a `major` bump on your own. When you judge a change to meet the major criteria (breaking changes, incompatible user configuration, renamed or removed commands/arguments, changed behavior semantics, etc.), you must stop and explain it to the user and ask for confirmation. **Only write `major` after the user has explicitly agreed.** Otherwise default to `minor` (and fall back to `patch` if `minor` is unclear). See the "Hard rule: confirm with the user before writing `major`" section in `.agents/skills/gen-changesets/SKILL.md` for details. +- Prefer importing via `import ... from '#/...'`, which serves the same purpose as `import ... from '@/...'`. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 000000000..ac49526de --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,96 @@ +# Contributing to kimi-code + +Thanks for taking the time to contribute! This project moves quickly, and thoughtful contributions from the community are what keep it sharp. The guide below walks you through how we work so your PR has the best chance of landing smoothly. + +## Before You Start + +- Open an issue or discussion before making changes larger than ~100 lines so we can align on direction early. +- We only merge PRs that are aligned with the roadmap — drive-by refactors without context are unlikely to land. +- Code quality bar: as good as code written by a strong human engineer or a competent coding agent. We hold AI-assisted contributions to the same standard as hand-written ones. + +## Project Layout + +This is a pnpm monorepo. The most relevant entry points are: + +- `apps/kimi-code` — CLI / TUI +- `apps/vis` — session replay & debugging visualizer +- `packages/node-sdk` — public TypeScript SDK (`@moonshot-ai/kimi-code-sdk`) +- `packages/agent-core`, `kosong`, `kaos`, `oauth`, `telemetry` — internal engine packages +- `docs/` — VitePress bilingual docs site + +For the full project map, see [AGENTS.md](AGENTS.md). + +## Development Setup + +Prerequisites: Node.js >= 24.15.0, pnpm 10.33.0, Git. + +```sh +git clone https://github.com/MoonshotAI/kimi-code.git +cd kimi-code +pnpm install +``` + +Useful scripts: + +- `pnpm dev:cli` — run the CLI in dev mode +- `pnpm test` — run tests (vitest) +- `pnpm typecheck` — TypeScript check (note: builds packages first) +- `pnpm lint` — oxlint +- `pnpm lint:fix` — oxlint with auto-fix +- `pnpm build` — build all packages + +## Commit Convention + +All commits and PR titles must follow [Conventional Commits](https://www.conventionalcommits.org/). + +| Type | Use for | Example | +|----------|---------------------------------------------|-------------------------------------------| +| feat | A new feature | feat(agent-core): add tool dedup | +| fix | A bug fix | fix(tui): correct status bar alignment | +| docs | Documentation only | docs: clarify install instructions | +| chore | Tooling / housekeeping | chore: bump dependencies | +| refactor | Internal refactor without behavior change | refactor(kosong): extract retry helper | +| test | Adding or improving tests | test(agent-core): cover skill resolver | +| ci | CI / build pipeline changes | ci: cache pnpm store | +| build | Build system / artifact changes | build(native): add win32-arm64 target | +| perf | Performance improvement | perf(session): batch event flushes | +| style | Formatting only (no logic) | style: apply oxlint --fix | + +PR titles are enforced by the `pr-title-checker` workflow — a non-conforming title will block merge. + +## Changesets + +This repo uses [changesets](https://github.com/changesets/changesets) to manage versioning and releases. + +- Every PR that affects release artifacts (code, behavior, public API) **must** include a changeset. +- Docs-only, test-only, or CI-only PRs may skip changesets. +- Generate one with `pnpm changeset` and follow the prompts (which packages are touched, which bump level). +- For repo-specific conventions, see `.changeset/README.md`. + +## Pull Request Checklist + +Before requesting review, make sure your PR ticks the following: + +- [ ] Linked an issue (for non-trivial changes) +- [ ] PR title follows Conventional Commits +- [ ] Added or updated tests +- [ ] Added a changeset if the PR affects release artifacts +- [ ] Ran `pnpm lint && pnpm typecheck && pnpm test` locally +- [ ] Updated user-facing docs in `docs/` if behavior changed + +The `.github/pull_request_template.md` checklist is a shorter subset of this — both must pass. + +## Code Style + +- TypeScript across the codebase. +- Linting via `oxlint` (config in `.oxlintrc.json`). +- Auto-formatting via `pnpm lint:fix`. +- Follow existing local patterns when the lint rules do not cover a style choice. + +## Reporting Security Issues + +Found a security issue? Please see [SECURITY.md](SECURITY.md) instead of opening a public issue. + +## License + +By contributing to this repository, you agree that your contributions will be licensed under the [MIT License](LICENSE). diff --git a/LICENSE b/LICENSE new file mode 100644 index 000000000..6102a1bc7 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Moonshot AI + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/Makefile b/Makefile new file mode 100644 index 000000000..d5889efa2 --- /dev/null +++ b/Makefile @@ -0,0 +1,67 @@ +.PHONY: prepare build typecheck lint lint-fix lint-pkg sherif test test-watch test-coverage clean changeset version publish release dev vis + +## Setup + +prepare: + pnpm install + +## Build + +build: + pnpm run build + +## Quality + +typecheck: + pnpm run typecheck + +lint: + pnpm run lint + +lint-fix: + pnpm run lint:fix + +sherif: + pnpm run sherif + +lint-pkg: + pnpm run lint:pkg + +## Test + +test: + pnpm run test + +test-watch: + pnpm run test:watch + +test-coverage: + pnpm run test:coverage + +## Clean + +clean: + pnpm run clean + +## Release + +changeset: + pnpm run changeset + +version: + pnpm run version + +publish: + pnpm run publish + +release: version publish + +## Development + +dev: + pnpm run dev:cli + +## vis + +vis: + pnpm run vis diff --git a/README.md b/README.md new file mode 100644 index 000000000..501bf8199 --- /dev/null +++ b/README.md @@ -0,0 +1,100 @@ +# Kimi Code CLI + +[![License](https://img.shields.io/badge/license-MIT-blue)](LICENSE) [![Docs](https://img.shields.io/badge/docs-online-blue)](https://moonshotai.github.io/kimi-code/en/)
+[Documentation](https://moonshotai.github.io/kimi-code/en/) · [Issues](https://github.com/MoonshotAI/kimi-code/issues) · [中文](README.zh-CN.md) + +![Demo of using Kimi Code](./docs/media/intro.gif) + +## What is Kimi Code CLI + +Kimi Code CLI is an AI coding agent that runs in your terminal — it can read and edit code, run shell commands, search files, fetch web pages, and choose the next step based on the feedback it receives. It works out of the box with Moonshot AI’s Kimi models and can also be configured to use other compatible providers. + +## Install + +Install with the official script. No Node.js required. + +- **macOS or Linux**: + +```sh +curl -fsSL https://code.kimi.com/kimi-code/install.sh | bash +``` + +- **Windows (PowerShell)**: + +```powershell +irm https://code.kimi.com/kimi-code/install.ps1 | iex +``` + +Then, run it with a new shell session: + +```sh +kimi --version +``` + +For npm install, upgrade, uninstall, see [Getting Started](https://moonshotai.github.io/kimi-code/en/guides/getting-started). + +## Quick Start + +Open a project and start the interactive UI: + +```sh +cd your-project +kimi +``` + +On first launch, run `/login` inside Kimi Code CLI and choose either Kimi Code OAuth or a Moonshot AI Open Platform API key. After login, try your first task: + +``` +Take a look at this project and explain its main directories. +``` + +## Key Features + +- **Single-binary distribution.** Install with one command: no Node.js setup, PATH gymnastics, or global module conflicts. +- **Blazing-fast startup.** The TUI is ready in milliseconds, so starting a session never feels heavy. +- **Purpose-built TUI.** A carefully tuned interface for long, focused agent sessions. +- **Video input.** Drop a screen recording or demo clip into the chat, and let the agent watch what is hard to describe in words. +- **AI-native MCP configuration.** Add, edit, and authenticate Model Context Protocol servers conversationally with `/mcp-config`, without hand-editing JSON. +- **Subagents for focused, parallel work.** Dispatch built-in `coder`, `explore`, and `plan` subagents in isolated contexts while keeping the main conversation clean. +- **Lifecycle hooks.** Run local commands at key points to gate risky tool calls, audit decisions, trigger desktop notifications, or connect to your own automation. + +## Docs + +- [Getting Started](https://moonshotai.github.io/kimi-code/en/guides/getting-started) +- [Interaction and approvals](https://moonshotai.github.io/kimi-code/en/guides/interaction) +- [Sessions](https://moonshotai.github.io/kimi-code/en/guides/sessions) +- [Configuration](https://moonshotai.github.io/kimi-code/en/configuration/config-files) +- [Command reference](https://moonshotai.github.io/kimi-code/en/reference/kimi-command) + +## Develop + +Requirements: Node.js ≥ 24.15.0, pnpm 10.33.0. + +```sh +git clone https://github.com/MoonshotAI/kimi-code.git +cd kimi-code +pnpm install +``` + +```sh +pnpm dev:cli # run the CLI in dev mode +pnpm test # run tests +pnpm typecheck # TypeScript check +pnpm lint # oxlint +pnpm build # build all packages +``` + +See [CONTRIBUTING.md](CONTRIBUTING.md) for the full contribution guide. + +## Community + +- [Issues](https://github.com/MoonshotAI/kimi-code/issues) +- For security vulnerabilities, see [SECURITY.md](SECURITY.md). + +## Acknowledgements + +Our TUI is built on top of [`pi-tui`](https://github.com/earendil-works/pi-mono/tree/main/packages/tui). We thank the authors of `pi-tui` for their valuable work. + +## License + +Released under the [MIT License](LICENSE). diff --git a/README.zh-CN.md b/README.zh-CN.md new file mode 100644 index 000000000..ace6cb425 --- /dev/null +++ b/README.zh-CN.md @@ -0,0 +1,104 @@ +# Kimi Code CLI + +[![License](https://img.shields.io/badge/license-MIT-blue)](LICENSE) [![Docs](https://img.shields.io/badge/docs-online-blue)](https://moonshotai.github.io/kimi-code/zh/) + +[Documentation](https://moonshotai.github.io/kimi-code/zh/) · [Issues](https://github.com/MoonshotAI/kimi-code/issues) · [English](README.md) + + +![Kimi Code 的使用演示](./docs/media/intro.gif) + + +## 什么是 Kimi Code CLI + +Kimi Code CLI 是一个运行在终端里的 AI 编程 agent,可以帮你读写代码、执行 shell 命令、检索文件、抓取网页,并根据反馈自主决定下一步动作。开箱即用对接 Moonshot AI 的 Kimi 模型,也可指向其他兼容厂商。 + +## 安装 + +推荐使用官方安装脚本,不需要提前安装 Node.js。 + +- **macOS / Linux**: + +```sh +curl -fsSL https://code.kimi.com/kimi-code/install.sh | bash +``` + +- **Windows(PowerShell)**: + +```powershell +irm https://code.kimi.com/kimi-code/install.ps1 | iex +``` + +随后在新的终端会话中运行: + +```sh +kimi --version +``` + +npm 安装、升级、卸载方式,见[快速上手](https://moonshotai.github.io/kimi-code/zh/guides/getting-started)。 + +## 快速开始 + +进入项目目录并启动交互界面: + +```sh +cd your-project +kimi +``` + +首次启动时,在 Kimi Code CLI 里输入 `/login`,选择 Kimi Code OAuth 或 Moonshot AI Open Platform API 密钥登录。登录完成后,可以先让它熟悉项目: + +``` +帮我看一下这个项目的目录结构,简单介绍一下每个目录是做什么的 +``` + +## 核心特性 + +- **二进制发行,零环境依赖** 一行命令安装,不需要预装 Node.js,不用折腾 PATH,也不会和全局模块冲突。 +- **极速启动** TUI 在毫秒级就绪,开一个新会话没有任何心智负担。 +- **精致的 TUI 体验** 为长时间、专注的 Agent 会话精心打磨的交互界面。 +- **视频也能输入** 屏幕录像、演示视频也能拖进对话。 +- **AI-native 的 MCP 配置** 通过 `/mcp-config` 对话式添加、编辑、认证 MCP 服务器,无需手写 JSON。 +- **子 Agent 聚焦并行工作** 内置 `coder`、`explore`、`plan` 子 Agent 在隔离上下文中处理子任务,主对话保持清爽。 +- **生命周期 hooks** 在关键节点执行本地命令:拦截高风险工具调用、审计决策、发送桌面通知,或对接你自己的自动化脚本。 + + +## 文档 + +- [快速上手](https://moonshotai.github.io/kimi-code/zh/guides/getting-started) +- [交互与审批](https://moonshotai.github.io/kimi-code/zh/guides/interaction) +- [会话](https://moonshotai.github.io/kimi-code/zh/guides/sessions) +- [配置](https://moonshotai.github.io/kimi-code/zh/configuration/config-files) +- [命令参考](https://moonshotai.github.io/kimi-code/zh/reference/kimi-command) + +## 本地开发 + +环境要求:Node.js ≥ 24.15.0,pnpm 10.33.0。 + +```sh +git clone https://github.com/MoonshotAI/kimi-code.git +cd kimi-code +pnpm install +``` + +```sh +pnpm dev:cli # 以开发模式运行 CLI +pnpm test # 运行测试 +pnpm typecheck # TypeScript 检查 +pnpm lint # 运行 oxlint +pnpm build # 构建所有包 +``` + +完整贡献流程见 [CONTRIBUTING.md](CONTRIBUTING.md)。 + +## 社区 + +- [Issues](https://github.com/MoonshotAI/kimi-code/issues) +- 安全漏洞反馈,请见 [SECURITY.md](SECURITY.md)。 + +## 致谢 + +我们的 TUI 构建在 [`pi-tui`](https://github.com/earendil-works/pi-mono/tree/main/packages/tui) 之上。我们忠心感谢 `pi-tui` 作者的工作。 + +## 许可证 + +基于 [MIT](LICENSE) 协议发布。 diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 000000000..982c6df63 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,33 @@ +# Security Policy + +## Supported Versions + +Currently, Kimi Code only provides security support for the latest released version. + +## Reporting a Vulnerability + +We take security seriously. **Please do not open a public issue for security vulnerabilities.** + +Preferred channel: + +- GitHub Security Advisories — https://github.com/MoonshotAI/kimi-code/security/advisories/new + (private disclosure, tracked with the codebase) + +Alternative channel: + +- Email: code@moonshot.ai (please include "[security]" in the subject) + +## What to Include + +- Affected version (output of `kimi --version`) +- Reproduction steps +- Impact assessment +- Any suggested mitigation + +## Our Response + +We will acknowledge your report and provide an initial assessment as soon as we can. + +## Public Disclosure + +We will coordinate with you on disclosure timing once a fix is ready. diff --git a/apps/kimi-code/.gitignore b/apps/kimi-code/.gitignore new file mode 100644 index 000000000..66d6a21a6 --- /dev/null +++ b/apps/kimi-code/.gitignore @@ -0,0 +1,2 @@ +# Copied from packages/kimi-core at build time +agents/ diff --git a/apps/kimi-code/AGENTS.md b/apps/kimi-code/AGENTS.md new file mode 100644 index 000000000..9c8f422cf --- /dev/null +++ b/apps/kimi-code/AGENTS.md @@ -0,0 +1,128 @@ +# apps/kimi-code Development Guide + +This file only contains rules local to `apps/kimi-code`. For cross-repo rules, see the root `AGENTS.md`. + +## TUI File Layout + +`apps/kimi-code` is the terminal UI / CLI app. The entry chain is: + +`src/main.ts` -> `src/cli/commands.ts` -> `src/cli/run-shell.ts` -> SDK `KimiHarness` -> `src/tui/kimi-tui.ts` + +Main directories: + +- `src/constant/`: non-copy constants shared by CLI/TUI — product, protocol, paths, terminal control, updates, and so on. +- `src/cli/`: command-line arguments, subcommands, and CLI startup. +- `src/tui/`: the interactive terminal UI. +- `src/tui/kimi-tui.ts`: the TUI master assembler, responsible for wiring state, layout, editor, session, SDK events, and dialogs together. +- `src/tui/actions/`: reusable TUI state/replay/projection logic. Pure logic that can be split out of `KimiTUI` should land here first. +- `src/tui/commands/`: slash command definitions, parsing, ordering, and dynamic skill command generation. +- `src/tui/components/`: pi-tui components, organized by UI type. +- `src/tui/constant/`: non-copy constants reused across TUI modules — symbols, terminal sequences, render sizing, streaming-arg match rules, and so on. +- `src/tui/components/chrome/`: persistent UI chrome — footer, todo panel, welcome, loader, device code. +- `src/tui/components/dialogs/`: selectors, approval panels, question popups, and settings popups that temporarily replace the editor. +- `src/tui/components/editor/`: the custom input box and the file mention provider. +- `src/tui/components/media/`: image, diff, code highlight, and other media displays. +- `src/tui/components/messages/`: message blocks in the transcript — assistant, user, tool call, thinking, usage, subagent, and so on. +- `src/tui/components/panes/`: right-side / activity-area panes such as the activity pane and queue pane. +- `src/tui/reverse-rpc/`: the adapter layer that bridges SDK approval/question callbacks to the UI. +- `src/tui/theme/`: themes, color tokens, style helpers, and the pi-tui markdown theme. +- `src/tui/utils/`: TUI-only utility functions. +- `src/utils/`: app-wide utilities — clipboard, git, history, image, process, usage, and so on. + +## Module Responsibilities + +- `cli` only interprets command-line input, assembles startup arguments, and invokes the TUI. Do not put TUI interaction logic into the CLI. +- `KimiTUI` coordinates; it does not accumulate complex business rules. New logic that can be tested independently should be split into `actions`, `commands`, `components`, `reverse-rpc`, or `utils` first. +- `commands` only owns slash-command declaration, parsing, and the parsed-result types. The actual execution can be dispatched from `KimiTUI`, but complex logic should continue to sink downward. +- `components` only handle presentation and local interaction; they must not call the SDK directly, and must not read or write session state directly. +- `reverse-rpc` converts SDK approval/question requests into the data shape a UI panel/dialog needs, and converts the user's choice back into an SDK response. +- `theme` is the single source of truth for colors and styles. Components must not bypass the theme system and use chalk named colors directly. +- `utils` holds utility functions with no UI-state dependency. Logic that needs `TUIState` or a component instance must not live under app-level `src/utils`. +- `apps/kimi-code` may only use core capabilities through `@moonshot-ai/kimi-code-sdk`. Do not import `@moonshot-ai/agent-core` directly in app code. + +## KimiTUI Internal Sections + +`src/tui/kimi-tui.ts` is large. When you modify it, place code into the existing responsibility section — do not just drop it where it happens to be convenient. + +- Types and state creation: `KimiTUIStartupInput`, `TUIState`, `createInitialAppState`, `createTUIState`. Before adding new global UI state, decide whether it really belongs in `TUIState`. +- Startup helpers: slash commands, autocomplete, skill commands, input history. +- Lifecycle: `start`, `init`, `stop`. They only handle startup/shutdown order — do not stuff feature implementations into them. +- Layout and editor: `buildLayout`, `setupEditorHandlers`, external editor, clipboard image, exit shortcuts. +- User input: `handleUserInput`, `executeSlashCommand`, `handleBuiltInSlashCommand`, `sendNormalUserInput`. +- Sending and queueing: `enqueueMessage`, `sendMessageInternal`, `sendMessage`, `steerMessage`, `finalizeTurn`. +- Session management: create, restore, switch, close, sync runtime state, subscribe to session events. +- Event routing: `handleEvent` only dispatches; concrete events go into the corresponding `handleXxx`. +- Streaming rendering: assistant delta, thinking, tool call, tool result, compaction, subagent, background agent. +- Transcript: `createTranscriptComponent`, `appendTranscriptEntry`, read/tool/agent group aggregation. +- Activity / queue / footer: `updateActivityPane`, `resolveActivityPaneMode`, `updateQueueDisplay`, terminal progress. +- Dialogs / selectors: help, session picker, editor/model/thinking/theme/permission/settings selectors, approval / question panels. +- Slash command handlers: `handleThemeCommand`, `handleModelCommand`, `handlePlanCommand`, `handleCompactCommand`, `handleLoginCommand`, and so on. + +If a section keeps growing, split pure functions, state projections, presentation components, and handler logic into the corresponding directories rather than continuing to expand `KimiTUI`. + +## Where New Features Go + +The feature type decides where it lands: + +- New CLI arguments: change `src/cli/commands.ts` / `src/cli/options.ts`, then pass them into the TUI via `src/cli/run-shell.ts`. Do not let the CLI operate on the session directly. +- New CLI subcommands: put them under `src/cli/sub/`, with non-interactive command logic only; when SDK access is needed, go through `@moonshot-ai/kimi-code-sdk`. +- New slash commands: first change definition, parsing, and types under `src/tui/commands/`; put the execution entry into the slash-command handler section of `KimiTUI`; split complex execution logic into `actions` or `utils`. +- New skill-derived commands: hook into `buildSkillSlashCommands` / the skill command map — do not hard-code a single skill. +- New transcript message types: define the data shape in `src/tui/types.ts`, add or extend a component under `components/messages/`, and register the renderer in `createTranscriptComponent`. +- New tool-result display: prefer extending `components/messages/tool-renderers/registry.ts` and the corresponding renderer; do not stack branches inside `ToolCallComponent`. +- New popup / selector: put it under `components/dialogs/` and mount it via `mountEditorReplacement`; if the trigger comes from an SDK callback, also check whether `reverse-rpc/` needs an adapter/controller/handler. +- New SDK event handling: add the dispatch in `handleEvent`, then add the corresponding `handleXxx`. If the event simply maps to a transcript entry. +- New session start / resume behavior: put it in the session management section, keeping `init` focused only on startup orchestration. +- New status bar, activity area, or queue display: change `chrome/footer`, `panes/activity`, `panes/queue`, and the corresponding `updateXxx` method. +- New configuration option: first change the read/write and schema in `src/tui/config.ts`, then wire the settings UI; when persistence is needed, go through `saveTuiConfig`. +- New constants: constants shared by CLI/TUI and not copy belong in `src/constant/`; non-copy constants reused only within the TUI belong in `src/tui/constant/`. Component-local copy, option labels, help descriptions, dialog title/footer text — keep these next to the corresponding component or command, do not centralize them into a global copy constants module. +- New general-purpose capability: if it does not depend on TUI state, put it under `src/utils/`; if it depends on TUI state or a component, put it under `src/tui/utils/`. + +Test placement rules: + +- Component behavior tests live next to the corresponding component's tests. +- Command parsing tests go under `test/tui/commands/`. +- reverse-rpc tests go under `test/tui/reverse-rpc/`. +- Pure utility tests go next to the corresponding utils tests. +- Do not create a generic `some-feature.test.ts` just to land a small feature. + +## TUI Coding Conventions + +- Do not over-encapsulate, especially for one- or two-line functions — do not introduce a two-layer wrapper, just inline. +- Functions with no state / UI side effects do not belong as private methods on the `KimiTUI` class; put them in external utils. +- Constants must live in the corresponding `constant` directory; they must not be scattered through component or logic code. +- Inside `handleInput(data)`, when comparing a printable character (letter, digit, space, punctuation), it is **forbidden** to write literal comparisons such as `data === 'q'`. With the Kitty keyboard protocol enabled in terminals like VSCode, these keys are sent as CSI-u sequences (e.g. `\x1b[113u`), and a bare comparison will never match. Decode with `printableChar(data)` from `src/tui/utils/printable-key.ts` first, then compare; function keys continue to use `matchesKey(data, Key.*)`; control characters (codepoint < 32) may still be compared against the raw `data`. `test/tui/printable-key-guard.test.ts` enforces this in CI. + +## How to Set Themes + +Themes are managed centrally under `src/tui/theme/`: + +- `colors.ts` defines semantic tokens: `ColorPalette`, `darkColors`, `lightColors`. +- `styles.ts` builds common chalk helpers on top of `ColorPalette`. +- `pi-tui-theme.ts` produces the theme configuration markdown / pi-tui requires. +- `bundle.ts` packs `colors`, `styles`, and `markdownTheme` into a `KimiTUIThemeBundle`. +- `index.ts` / `detect.ts` handle the theme type and auto/dark/light resolution. + +When setting or switching themes: + +- The UI entry goes through `ThemeSelectorComponent`, `handleThemeCommand`, and `applyThemeChoice`. +- The real apply step goes through `KimiTUI.applyTheme`, which should update `state.theme`, `state.appState.theme`, and notify the relevant components to refresh their palette. +- Persisting the user's choice goes through `saveTuiConfig`. Do not let a component write the config file itself. + +When writing color: + +- Do not use chalk named colors such as `chalk.red`, `chalk.cyan`, `chalk.white`, `chalk.gray`, `chalk.dim`, or `chalk.yellow` directly. +- If a component already has `colors`, use `chalk.hex(colors.)(text)`. +- If a component already has `state.theme.styles` or styles passed in, prefer helpers such as `styles.error(text)`, `styles.dim(text)`. +- When new visual semantics have no token, first add a semantic field to `ColorPalette`, and fill in both `darkColors` and `lightColors`. +- In light themes, text tokens against a white background must be at least 4.5:1; borders and large chrome must be at least 3:1. +- Do not cache styled chalk functions at module top level. Theme switching must take effect within a single render, so styles must be generated on the render path from the current palette. + +After a theme change, non-comment code must not contain chalk named colors such as `chalk.white`, `chalk.cyan`, `chalk.red`, `chalk.green`, `chalk.gray`, `chalk.yellow`, `chalk.blue`, `chalk.magenta`, `chalk.whiteBright`, or `chalk.blackBright`. + +## General Coding Requirements + +- For optional object properties, pass `undefined` directly — do not use conditional spread. +- Optional object properties do not need to additionally allow `undefined` in the type. +- Internal methods with only a single parameter should not be turned into options objects just for stylistic uniformity. +- Except for a package's own `index.ts`, other `index.ts` files should prefer `export * from './module'`. diff --git a/apps/kimi-code/README.md b/apps/kimi-code/README.md new file mode 100644 index 000000000..9c0bd4e27 --- /dev/null +++ b/apps/kimi-code/README.md @@ -0,0 +1,88 @@ +# @moonshot-ai/kimi-code + +> The Starting Point for Next-Gen Agents + +[![npm](https://img.shields.io/npm/v/@moonshot-ai/kimi-code)](https://www.npmjs.com/package/@moonshot-ai/kimi-code) [![License](https://img.shields.io/badge/license-MIT-blue)](LICENSE) [![Docs](https://img.shields.io/badge/docs-online-blue)](https://moonshotai.github.io/kimi-code/en/) + +## What is Kimi Code CLI + +Kimi Code CLI is an AI coding agent that runs in your terminal. It can read and edit code, run shell commands, search files, fetch web pages, and choose the next step based on the feedback it receives. It works out of the box with Moonshot AI's Kimi models and can also be configured to use other compatible providers. + +## Install + +The recommended install path is the official script. It does not require Node.js to be installed first. + +- **macOS / Linux**: + +```sh +curl -fsSL https://code.kimi.com/kimi-code/install.sh | bash +``` + +- **Windows (PowerShell)**: + +```powershell +irm https://code.kimi.com/kimi-code/install.ps1 | iex +``` + +Then run it with a new Terminal session: + +```sh +kimi --version +``` + +### Alternative: npm + +If you prefer npm, use Node.js 22.19.0 or later: + +```sh +npm install -g @moonshot-ai/kimi-code +``` + +Or with pnpm: + +```sh +pnpm add -g @moonshot-ai/kimi-code +``` + +For upgrade and uninstall instructions, see the [Getting Started guide](https://moonshotai.github.io/kimi-code/en/guides/getting-started). + +## Quick Start + +Open a project and start the interactive UI: + +```sh +cd your-project +kimi +``` + +On first launch, run `/login` inside Kimi Code CLI and choose either Kimi Code OAuth or a Moonshot AI Open Platform API key. After login, try a first task: + +``` +Take a look at this project and explain the main directories. +``` + +## Key Features + +- **Single-binary distribution.** Install with one command — no Node.js setup, no PATH gymnastics, no global module conflicts. +- **Blazing-fast startup.** The TUI is ready in milliseconds, so opening a session never feels heavy. +- **Polished TUI.** A carefully tuned interface designed for long, focused agent sessions. +- **Video input.** Drop a screen recording or demo clip into the chat — let the agent watch instead of typing out what's hard to describe in words. +- **AI-native MCP configuration.** Add, edit, and authenticate Model Context Protocol servers conversationally via `/mcp-config` — no hand-editing JSON. +- **Subagents for focused, parallel work.** Dispatch built-in `coder`, `explore`, and `plan` subagents in isolated context windows; the main conversation stays clean. +- **Lifecycle hooks.** Run local commands at key points — gate risky tool calls, audit decisions, fire desktop notifications, wire into your own automation. + +## Documentation + +- Full docs: https://moonshotai.github.io/kimi-code/en/ +- 中文文档: https://moonshotai.github.io/kimi-code/zh/ +- Getting Started: https://moonshotai.github.io/kimi-code/en/guides/getting-started + +## Repository & Issues + +- Source: https://github.com/MoonshotAI/kimi-code +- Issues: https://github.com/MoonshotAI/kimi-code/issues +- Security: see SECURITY.md in the main repository + +## License + +MIT diff --git a/apps/kimi-code/package.json b/apps/kimi-code/package.json new file mode 100644 index 000000000..01f448325 --- /dev/null +++ b/apps/kimi-code/package.json @@ -0,0 +1,88 @@ +{ + "name": "@moonshot-ai/kimi-code", + "version": "0.1.1", + "description": "The Starting Point for Next-Gen Agents", + "license": "MIT", + "author": "Moonshot AI", + "homepage": "https://github.com/MoonshotAI/kimi-code/tree/main/apps/kimi-code#readme", + "repository": { + "type": "git", + "url": "git+https://github.com/MoonshotAI/kimi-code.git", + "directory": "apps/kimi-code" + }, + "bugs": { + "url": "https://github.com/MoonshotAI/kimi-code/issues" + }, + "keywords": [ + "kimi", + "kimi-code", + "cli", + "agent", + "coding-agent", + "ai", + "tui" + ], + "bin": { + "kimi": "dist/main.mjs" + }, + "files": [ + "dist", + "scripts/postinstall.mjs", + "scripts/postinstall", + "README.md" + ], + "type": "module", + "imports": { + "#/*": [ + "./src/*.ts", + "./src/*/index.ts" + ] + }, + "publishConfig": { + "access": "public", + "provenance": true + }, + "scripts": { + "build": "tsdown", + "smoke": "node scripts/smoke.mjs", + "build:native:js": "node scripts/native/01-bundle.mjs", + "build:native:sea": "node scripts/native/build.mjs --profile=local", + "build:native:release": "node scripts/native/build.mjs --profile=release", + "package:native": "node scripts/native/package.mjs", + "produce:native:manifest": "node scripts/native/produce-manifest.mjs", + "release:native:resolve": "node scripts/native/resolve-release.mjs", + "test:native:smoke": "node scripts/native/smoke.mjs", + "dev": "tsx --import ../../build/register-raw-text-loader.mjs ./src/main.ts", + "dev:prod": "node dist/main.mjs", + "clean": "rm -rf dist", + "typecheck": "tsc -p tsconfig.json --noEmit", + "test": "pnpm -w run build:packages && vitest run", + "e2e": "pnpm -w run build:packages && KIMI_E2E=1 vitest run test/e2e", + "e2e:real": "pnpm -w run build:packages && KIMI_E2E_REAL=1 vitest run test/e2e/real-llm-smoke.e2e.test.ts", + "postinstall": "node scripts/postinstall.mjs" + }, + "dependencies": { + "@earendil-works/pi-tui": "^0.74.0", + "@mariozechner/clipboard": "^0.3.2", + "chalk": "^5.4.1", + "cli-highlight": "^2.1.11", + "commander": "^13.1.0", + "semver": "^7.7.4", + "smol-toml": "^1.6.1", + "zod": "^4.3.6" + }, + "devDependencies": { + "@moonshot-ai/kimi-code-oauth": "workspace:^", + "@moonshot-ai/kimi-code-sdk": "workspace:^", + "@moonshot-ai/kimi-telemetry": "workspace:^", + "@moonshot-ai/migration-legacy": "workspace:^", + "@types/semver": "^7.7.0", + "@types/yazl": "^2.4.6", + "postject": "1.0.0-alpha.6", + "tsx": "^4.21.0", + "yazl": "^3.3.1" + }, + "engines": { + "node": ">=22.19.0" + } +} diff --git a/apps/kimi-code/scripts/native/01-bundle.mjs b/apps/kimi-code/scripts/native/01-bundle.mjs new file mode 100644 index 000000000..46193d3e7 --- /dev/null +++ b/apps/kimi-code/scripts/native/01-bundle.mjs @@ -0,0 +1,17 @@ +import { createRequire } from 'node:module'; +import { resolve } from 'node:path'; + +import { run } from './exec.mjs'; + +const requireFromScript = createRequire(import.meta.url); +const tsdownCliPath = requireFromScript.resolve('tsdown/run'); +const checkBundlePath = resolve(import.meta.dirname, 'check-bundle.mjs'); + +export async function runBundleStep() { + await run(process.execPath, [tsdownCliPath, '--config', 'tsdown.native.config.ts']); + await run(process.execPath, [checkBundlePath]); +} + +if (import.meta.url === `file://${process.argv[1]}`) { + await runBundleStep(); +} diff --git a/apps/kimi-code/scripts/native/02-sea-blob.mjs b/apps/kimi-code/scripts/native/02-sea-blob.mjs new file mode 100644 index 000000000..8bac05a84 --- /dev/null +++ b/apps/kimi-code/scripts/native/02-sea-blob.mjs @@ -0,0 +1,69 @@ +import { mkdir, stat, writeFile } from 'node:fs/promises'; +import { dirname, resolve } from 'node:path'; + +import { + collectNativeAssets, + nativeAssetManifestKey, + nativeAssetSummary, +} from './assets.mjs'; +import { fail, run } from './exec.mjs'; +import { + appRoot, + nativeBlobPath, + nativeIntermediatesDir, + nativeJsBundlePath, + nativeManifestDir, + nativeSeaConfigPath, + targetTriple, +} from './paths.mjs'; + +async function ensureBundleExists() { + try { + await stat(nativeJsBundlePath()); + } catch { + fail(`Native JS bundle not found at ${nativeJsBundlePath()}. Run 01-bundle.mjs first.`); + } +} + +async function writeSeaConfig(target) { + await mkdir(nativeIntermediatesDir(), { recursive: true }); + const { manifest, manifestJson, assets } = await collectNativeAssets({ + appRoot, + target, + }); + const manifestPath = resolve(nativeManifestDir(target), 'manifest.json'); + await mkdir(dirname(manifestPath), { recursive: true }); + await writeFile(manifestPath, manifestJson); + + const seaAssets = { + [nativeAssetManifestKey(target)]: manifestPath, + ...assets, + }; + const config = { + main: nativeJsBundlePath(), + output: nativeBlobPath(), + assets: Object.fromEntries( + Object.entries(seaAssets).sort(([a], [b]) => a.localeCompare(b)), + ), + disableExperimentalSEAWarning: true, + useCodeCache: false, + useSnapshot: false, + }; + await writeFile(nativeSeaConfigPath(), `${JSON.stringify(config, null, 2)}\n`); + + console.log(`Collected native assets for ${manifest.target}:`); + for (const line of nativeAssetSummary(manifest)) { + console.log(`- ${line}`); + } +} + +export async function runSeaBlobStep() { + await ensureBundleExists(); + const target = targetTriple(); + await writeSeaConfig(target); + await run(process.execPath, ['--experimental-sea-config', nativeSeaConfigPath()]); +} + +if (import.meta.url === `file://${process.argv[1]}`) { + await runSeaBlobStep(); +} diff --git a/apps/kimi-code/scripts/native/03-inject.mjs b/apps/kimi-code/scripts/native/03-inject.mjs new file mode 100644 index 000000000..24cd0ad32 --- /dev/null +++ b/apps/kimi-code/scripts/native/03-inject.mjs @@ -0,0 +1,65 @@ +import { copyFile, mkdir, stat } from 'node:fs/promises'; +import { resolve } from 'node:path'; + +import { fail, run, tryRun } from './exec.mjs'; +import { + appRoot, + nativeBinDir, + nativeBinPath, + nativeBlobPath, + SEA_SENTINEL_FUSE, + targetTriple, +} from './paths.mjs'; + +function postjectPath() { + const command = process.platform === 'win32' ? 'postject.cmd' : 'postject'; + return resolve(appRoot, 'node_modules/.bin', command); +} + +async function ensureBlobExists() { + try { + await stat(nativeBlobPath()); + } catch { + fail(`SEA blob not found at ${nativeBlobPath()}. Run 02-sea-blob.mjs first.`); + } +} + +async function copyNodeExecutable(target) { + await mkdir(nativeBinDir(target), { recursive: true }); + const out = nativeBinPath(target); + await copyFile(process.execPath, out); + if (process.platform !== 'win32') { + await run('chmod', ['755', out]); + } +} + +async function removeSignatureIfNeeded(target) { + const out = nativeBinPath(target); + if (process.platform === 'darwin') { + await tryRun('codesign', ['--remove-signature', out]); + } + if (process.platform === 'win32') { + await tryRun('signtool', ['remove', '/s', out]); + } +} + +async function injectSeaBlob(target) { + const out = nativeBinPath(target); + const args = [out, 'NODE_SEA_BLOB', nativeBlobPath(), '--sentinel-fuse', SEA_SENTINEL_FUSE]; + if (process.platform === 'darwin') { + args.push('--macho-segment-name', 'NODE_SEA'); + } + await run(postjectPath(), args); +} + +export async function runInjectStep() { + const target = targetTriple(); + await ensureBlobExists(); + await copyNodeExecutable(target); + await removeSignatureIfNeeded(target); + await injectSeaBlob(target); +} + +if (import.meta.url === `file://${process.argv[1]}`) { + await runInjectStep(); +} diff --git a/apps/kimi-code/scripts/native/04-sign.mjs b/apps/kimi-code/scripts/native/04-sign.mjs new file mode 100644 index 000000000..2930b5e63 --- /dev/null +++ b/apps/kimi-code/scripts/native/04-sign.mjs @@ -0,0 +1,68 @@ +import { createHash } from 'node:crypto'; +import { createReadStream } from 'node:fs'; +import { writeFile } from 'node:fs/promises'; +import { basename, resolve } from 'node:path'; + +import { run } from './exec.mjs'; +import { nativeBinPath, targetTriple } from './paths.mjs'; + +const ENTITLEMENTS_PATH = resolve(import.meta.dirname, 'entitlements.plist'); + +export function buildCodesignArgs({ identity, executable, entitlementsPath, keychainPath }) { + if (identity === '-') { + return ['--sign', '-', executable]; + } + const args = [ + '--sign', + identity, + '--options', + 'runtime', + '--entitlements', + entitlementsPath, + '--timestamp', + ]; + if (keychainPath) { + args.push('--keychain', keychainPath); + } + args.push('--force', executable); + return args; +} + +async function sha256(path) { + return await new Promise((resolveHash, reject) => { + const hash = createHash('sha256'); + const stream = createReadStream(path); + stream.on('error', reject); + stream.on('data', (chunk) => hash.update(chunk)); + stream.on('end', () => resolveHash(hash.digest('hex'))); + }); +} + +async function writeChecksum(executable) { + const digest = await sha256(executable); + await writeFile(`${executable}.sha256`, `${digest} ${basename(executable)}\n`); +} + +export async function runSignStep({ identity = '-', keychainPath = null } = {}) { + const target = targetTriple(); + const executable = nativeBinPath(target); + + if (process.platform === 'darwin') { + const args = buildCodesignArgs({ + identity, + executable, + entitlementsPath: ENTITLEMENTS_PATH, + keychainPath, + }); + await run('codesign', args); + } + + await writeChecksum(executable); + console.log(`Signed and hashed: ${executable}`); +} + +if (import.meta.url === `file://${process.argv[1]}`) { + const identity = process.env.APPLE_SIGNING_IDENTITY ?? '-'; + const keychainPath = process.env.APPLE_KEYCHAIN_PATH ?? null; + await runSignStep({ identity, keychainPath }); +} diff --git a/apps/kimi-code/scripts/native/05-verify.mjs b/apps/kimi-code/scripts/native/05-verify.mjs new file mode 100644 index 000000000..5c47da6bc --- /dev/null +++ b/apps/kimi-code/scripts/native/05-verify.mjs @@ -0,0 +1,30 @@ +import { run } from './exec.mjs'; +import { nativeBinPath, targetTriple } from './paths.mjs'; + +export async function runVerifyStep({ requireGatekeeper = false } = {}) { + if (process.platform !== 'darwin') { + console.log('Verify step skipped (not macOS)'); + return; + } + + const target = targetTriple(); + const executable = nativeBinPath(target); + + console.log(`==> codesign -dv ${executable}`); + await run('codesign', ['-dv', '--verbose=2', executable]); + + if (requireGatekeeper) { + // spctl in 'install' mode simulates the Gatekeeper online check — only a + // fully notarized binary passes. Ad-hoc signed binaries fail, so this is + // only run under the release profile. + console.log(`==> spctl -a -vvv -t install ${executable}`); + await run('spctl', ['-a', '-vvv', '-t', 'install', executable]); + } else { + console.log('Skipping spctl check (requireGatekeeper=false)'); + } +} + +if (import.meta.url === `file://${process.argv[1]}`) { + const requireGatekeeper = process.env.KIMI_VERIFY_GATEKEEPER === '1'; + await runVerifyStep({ requireGatekeeper }); +} diff --git a/apps/kimi-code/scripts/native/assets.mjs b/apps/kimi-code/scripts/native/assets.mjs new file mode 100644 index 000000000..7b0560b69 --- /dev/null +++ b/apps/kimi-code/scripts/native/assets.mjs @@ -0,0 +1,276 @@ +import { createHash } from 'node:crypto'; +import { existsSync, realpathSync } from 'node:fs'; +import { readdir, readFile } from 'node:fs/promises'; +import { createRequire } from 'node:module'; +import { dirname, extname, isAbsolute, join, relative, resolve } from 'node:path'; +import { pathToFileURL } from 'node:url'; + +import { NATIVE_ASSET_MANIFEST_VERSION, buildManifestKey } from './manifest.mjs'; +import { resolveTargetDeps, SUPPORTED_TARGETS } from './native-deps.mjs'; + +export { NATIVE_ASSET_MANIFEST_VERSION }; + +// Re-export for any external consumer that still needs it. Internally we +// use resolveTargetDeps() exclusively — no more if/else against package names. +export const NATIVE_TARGETS = Object.freeze( + Object.fromEntries( + SUPPORTED_TARGETS.map((t) => { + const deps = resolveTargetDeps(t); + const clipboardTarget = deps.find((d) => d.id === 'clipboard-target')?.resolvedName; + const koffiNativeFile = deps.find((d) => d.id === 'koffi')?.nativeFileRelatives?.[0]; + const koffiTriplet = koffiNativeFile?.match(/koffi\/([^/]+)\/koffi\.node$/)?.[1] ?? null; + return [t, { clipboardPackage: clipboardTarget, koffiTriplet }]; + }), + ), +); + +const jsExtensions = ['.js', '.cjs', '.mjs', '.json', '.node']; +const runtimeEntryNames = ['index.js', 'index.cjs', 'index.mjs']; + +function fail(message) { + throw new Error(message); +} + +function toPosixPath(path) { + return path.split('\\').join('/'); +} + +function sha256(bytes) { + return createHash('sha256').update(bytes).digest('hex'); +} + +async function readJson(path) { + return JSON.parse(await readFile(path, 'utf-8')); +} + +async function listFiles(root) { + const files = []; + + async function walk(dir) { + const entries = await readdir(dir, { withFileTypes: true }); + for (const entry of entries) { + const path = join(dir, entry.name); + if (entry.isDirectory()) { + await walk(path); + continue; + } + if (entry.isFile()) { + files.push(path); + } + } + } + + await walk(root); + return files; +} + +function resolvePackageRootGeneric(requireFromApp, packageName, parentPackageName, appRoot, target) { + try { + return dirname(requireFromApp.resolve(`${packageName}/package.json`)); + } catch (rootError) { + if (parentPackageName !== null) { + try { + const parentPackageJsonPath = realpathSync( + requireFromApp.resolve(`${parentPackageName}/package.json`), + ); + const requireFromParent = createRequire(pathToFileURL(parentPackageJsonPath)); + return dirname(requireFromParent.resolve(`${packageName}/package.json`)); + } catch {} + } + fail( + [ + `Native asset package is not installed for target ${target}: ${packageName}`, + parentPackageName ? `Searched via parent: ${parentPackageName}` : '', + `Resolve root: ${appRoot}`, + 'Run pnpm install --frozen-lockfile before building native assets.', + rootError instanceof Error ? rootError.message : String(rootError), + ] + .filter(Boolean) + .join('\n'), + ); + } +} + +function resolveFileCandidate(path) { + if (existsSync(path)) return path; + for (const extension of jsExtensions) { + const candidate = `${path}${extension}`; + if (existsSync(candidate)) return candidate; + } + for (const entryName of runtimeEntryNames) { + const candidate = join(path, entryName); + if (existsSync(candidate)) return candidate; + } + return null; +} + +function resolvePackageEntry(packageRoot, packageJson) { + const rawMain = + typeof packageJson.main === 'string' + ? packageJson.main + : typeof packageJson.module === 'string' + ? packageJson.module + : 'index.js'; + return resolveFileCandidate(resolve(packageRoot, rawMain)); +} + +function relativeRuntimeSpecifiers(text) { + const specifiers = new Set(); + for (const match of text.matchAll(/\brequire\(\s*["'](\.[^"']+)["']\s*\)/g)) { + specifiers.add(match[1]); + } + for (const match of text.matchAll(/(? a.localeCompare(b)); + if (includeNativeFiles && !sorted.some((file) => file.endsWith('.node'))) { + fail(`Native package ${packageName} does not contain a .node file at ${packageRoot}`); + } + return sorted; +} + +async function packageManifestEntries({ packageName, packageRoot, files, target }) { + const root = `node_modules/${packageName}`; + const entries = []; + const assets = {}; + + for (const file of files) { + const sourceBytes = await readFile(file); + const packageRelativePath = toPosixPath(relative(packageRoot, file)); + const relativePath = `${root}/${packageRelativePath}`; + const assetKey = `native/${target}/${relativePath}`; + entries.push({ + assetKey, + relativePath, + sha256: sha256(sourceBytes), + }); + assets[assetKey] = file; + } + + return { + packageManifest: { + name: packageName, + root, + files: entries, + }, + assets, + }; +} + +export const nativeAssetManifestKey = buildManifestKey; + +export function nativeAssetSummary(manifest) { + return manifest.packages.map((pkg) => `${pkg.name}: ${pkg.files.length} files`); +} + +export async function collectNativeAssets({ appRoot, target }) { + const requireFromApp = createRequire(pathToFileURL(resolve(appRoot, 'package.json'))); + const targetDeps = resolveTargetDeps(target); // throws on unsupported target + + const manifestPackages = []; + const assets = {}; + + for (const dep of targetDeps) { + const packageRoot = resolvePackageRootGeneric( + requireFromApp, + dep.resolvedName, + dep.parentName, + appRoot, + target, + ); + const files = await collectPackageFiles({ + packageName: dep.resolvedName, + packageRoot, + includeNativeFiles: dep.collect === 'native-files', + nativeFileRelatives: dep.nativeFileRelatives, + }); + const result = await packageManifestEntries({ + packageName: dep.resolvedName, + packageRoot, + files, + target, + }); + manifestPackages.push(result.packageManifest); + Object.assign(assets, result.assets); + } + + const manifest = { + version: NATIVE_ASSET_MANIFEST_VERSION, + target, + packages: manifestPackages, + }; + + return { + manifest, + manifestJson: `${JSON.stringify(manifest, null, 2)}\n`, + assets, + }; +} diff --git a/apps/kimi-code/scripts/native/build.mjs b/apps/kimi-code/scripts/native/build.mjs new file mode 100644 index 000000000..ec51d2368 --- /dev/null +++ b/apps/kimi-code/scripts/native/build.mjs @@ -0,0 +1,47 @@ +import { parseArgs } from 'node:util'; + +import { runBundleStep } from './01-bundle.mjs'; +import { runInjectStep } from './03-inject.mjs'; +import { runSeaBlobStep } from './02-sea-blob.mjs'; +import { runSignStep } from './04-sign.mjs'; +import { runVerifyStep } from './05-verify.mjs'; + +const { values } = parseArgs({ + options: { + profile: { type: 'string', default: 'local' }, + }, +}); + +const profile = values.profile; +if (!['local', 'release'].includes(profile)) { + console.error(`Unknown profile: ${profile}. Expected 'local' or 'release'.`); + process.exit(1); +} + +function ensureNodeVersion() { + const [major, minor] = process.versions.node.split('.').map(Number); + if (major < 24 || (major === 24 && minor < 15)) { + console.error( + `Kimi Code native SEA build requires Node.js >=24.15.0, current ${process.versions.node}.`, + ); + process.exit(1); + } +} + +ensureNodeVersion(); +console.log(`==> Native build (profile=${profile})`); + +await runBundleStep(); +await runSeaBlobStep(); +await runInjectStep(); + +const identity = + profile === 'release' ? (process.env.APPLE_SIGNING_IDENTITY ?? '-') : '-'; +const keychainPath = profile === 'release' ? (process.env.APPLE_KEYCHAIN_PATH ?? null) : null; +await runSignStep({ identity, keychainPath }); + +// Verify always runs (codesign -dv); spctl gatekeeper gate only after notarization +// (CI macos-notarize composite action) — orchestrator just self-checks signing here. +await runVerifyStep({ requireGatekeeper: false }); + +console.log('==> Native build complete'); diff --git a/apps/kimi-code/scripts/native/check-bundle.mjs b/apps/kimi-code/scripts/native/check-bundle.mjs new file mode 100644 index 000000000..a0479f209 --- /dev/null +++ b/apps/kimi-code/scripts/native/check-bundle.mjs @@ -0,0 +1,90 @@ +import { builtinModules } from 'node:module'; +import { readFileSync } from 'node:fs'; + +import { nativeJsBundlePath } from './paths.mjs'; + +const bundlePath = nativeJsBundlePath(); +const text = readFileSync(bundlePath, 'utf-8'); + +const builtins = new Set([ + ...builtinModules, + ...builtinModules.map((name) => `node:${name}`), +]); + +const optionalRuntimeRequires = new Set([ + 'ajv-formats/dist/formats', + 'ajv/dist/runtime/validation_error', + 'bufferutil', + 'canvas', + 'chokidar', + 'cpu-features', + 'utf-8-validate', +]); +const optionalRelativeRuntimeRequires = new Set(['./crypto/build/Release/sshcrypto.node']); +const handledNativeRuntimeRequires = new Set(['koffi']); + +function isAllowedSpecifier(specifier) { + if (builtins.has(specifier) || specifier.startsWith('node:')) return true; + if (optionalRuntimeRequires.has(specifier)) return true; + if (handledNativeRuntimeRequires.has(specifier)) return true; + return false; +} + +const errors = []; + +function executableLines() { + return text + .split('\n') + .map((line) => line.trim()) + .filter((line) => { + if (line.length === 0) return false; + if (line.startsWith('*') || line.startsWith('//') || line.startsWith('/*')) return false; + return true; + }); +} + +for (const line of executableLines()) { + for (const match of line.matchAll(/\brequire\(\s*["']([^"']+)["']\s*\)/g)) { + const specifier = match[1]; + if (specifier.startsWith('.') || specifier.startsWith('/')) { + if (optionalRelativeRuntimeRequires.has(specifier)) continue; + errors.push(`relative require remains: ${specifier}`); + continue; + } + if (!isAllowedSpecifier(specifier)) { + errors.push(`external require remains: ${specifier}`); + } + } + + for (const match of line.matchAll(/(? 0) { + console.error(`Native JS bundle check failed for ${bundlePath}:`); + for (const error of errors) { + console.error(`- ${error}`); + } + process.exit(1); +} diff --git a/apps/kimi-code/scripts/native/entitlements.plist b/apps/kimi-code/scripts/native/entitlements.plist new file mode 100644 index 000000000..d7e0e9d6b --- /dev/null +++ b/apps/kimi-code/scripts/native/entitlements.plist @@ -0,0 +1,24 @@ + + + + + + com.apple.security.cs.allow-jit + + + + com.apple.security.cs.allow-unsigned-executable-memory + + + + com.apple.security.cs.disable-library-validation + + + + com.apple.security.cs.allow-dyld-environment-variables + + + diff --git a/apps/kimi-code/scripts/native/exec.mjs b/apps/kimi-code/scripts/native/exec.mjs new file mode 100644 index 000000000..03ebe5b0c --- /dev/null +++ b/apps/kimi-code/scripts/native/exec.mjs @@ -0,0 +1,61 @@ +import { execFile } from 'node:child_process'; +import { basename } from 'node:path'; +import { promisify } from 'node:util'; + +import { appRoot } from './paths.mjs'; + +const execFileAsync = promisify(execFile); + +export function commandForExecFile(command, args, platform = process.platform, env = process.env) { + if (platform !== 'win32' || !/\.(?:bat|cmd)$/i.test(command)) { + return { command, args }; + } + const shellCommand = [command, ...args] + .map((arg) => `"${String(arg).replaceAll('"', '""')}"`) + .join(' '); + return { + command: env.ComSpec ?? 'cmd.exe', + args: ['/d', '/s', '/c', `"${shellCommand}"`], + options: { windowsVerbatimArguments: true }, + }; +} + +export function fail(message) { + console.error(message); + process.exit(1); +} + +export async function run(command, args, options = {}) { + const exec = commandForExecFile(command, args); + try { + const { stdout, stderr } = await execFileAsync(exec.command, exec.args, { + cwd: appRoot, + maxBuffer: 1024 * 1024 * 16, + ...exec.options, + ...options, + }); + if (stdout.trim()) console.log(stdout.trim()); + if (stderr.trim()) console.error(stderr.trim()); + } catch (error) { + const details = [error.stdout?.trim(), error.stderr?.trim(), error.message] + .filter(Boolean) + .join('\n'); + fail(`Command failed: ${basename(command)} ${args.join(' ')}\n${details}`); + } +} + +export async function tryRun(command, args, options = {}) { + const exec = commandForExecFile(command, args); + try { + await execFileAsync(exec.command, exec.args, { + cwd: appRoot, + maxBuffer: 1024 * 1024 * 16, + ...exec.options, + }); + } catch (error) { + const details = [error.stdout?.trim(), error.stderr?.trim(), error.message] + .filter(Boolean) + .join('\n'); + console.warn(`Warning: ${basename(command)} ${args.join(' ')} failed.\n${details}`); + } +} diff --git a/apps/kimi-code/scripts/native/manifest.mjs b/apps/kimi-code/scripts/native/manifest.mjs new file mode 100644 index 000000000..910738943 --- /dev/null +++ b/apps/kimi-code/scripts/native/manifest.mjs @@ -0,0 +1,13 @@ +export const NATIVE_ASSET_MANIFEST_VERSION = 1; + +export function buildManifestKey(target) { + return `native/${target}/manifest.json`; +} + +export function isManifestVersionSupported(version) { + return version === NATIVE_ASSET_MANIFEST_VERSION; +} + +export function buildAssetKey(target, packageRoot, relativePath) { + return `native/${target}/${packageRoot}/${relativePath}`; +} diff --git a/apps/kimi-code/scripts/native/native-deps.mjs b/apps/kimi-code/scripts/native/native-deps.mjs new file mode 100644 index 000000000..f195a3cb1 --- /dev/null +++ b/apps/kimi-code/scripts/native/native-deps.mjs @@ -0,0 +1,103 @@ +/** + * Native dependency registry. + * + * Each entry describes one native-bearing npm package: how to resolve its + * install root, what to collect (JS only / native binary only / both), and + * which other registered dep it nests under (for `pnpm`-style nested resolves). + * + * Adding a new native package = appending one object here. No edits to + * NATIVE_TARGETS table or resolvePackageRoot if/else chain. + */ + +export const SUPPORTED_TARGETS = Object.freeze([ + 'darwin-arm64', + 'darwin-x64', + 'linux-arm64', + 'linux-x64', + 'win32-arm64', + 'win32-x64', +]); + +const clipboardSubpackageByTarget = Object.freeze({ + 'darwin-arm64': '@mariozechner/clipboard-darwin-arm64', + 'darwin-x64': '@mariozechner/clipboard-darwin-x64', + 'linux-arm64': '@mariozechner/clipboard-linux-arm64-gnu', + 'linux-x64': '@mariozechner/clipboard-linux-x64-gnu', + 'win32-arm64': '@mariozechner/clipboard-win32-arm64-msvc', + 'win32-x64': '@mariozechner/clipboard-win32-x64-msvc', +}); + +const koffiTripletByTarget = Object.freeze({ + 'darwin-arm64': 'darwin_arm64', + 'darwin-x64': 'darwin_x64', + 'linux-arm64': 'linux_arm64', + 'linux-x64': 'linux_x64', + 'win32-arm64': 'win32_arm64', + 'win32-x64': 'win32_x64', +}); + +export function isSupportedTarget(target) { + return SUPPORTED_TARGETS.includes(target); +} + +/** + * @typedef {Object} NativeDepDescriptor + * @property {string} id — stable internal id used for parent refs + * @property {(target: string) => string} name + * — npm package name (may depend on target) + * @property {'js-only'|'native-files'|'js-and-native-file'|'virtual'} collect + * @property {string|null} parent + * — id of another registered dep this nests under (for pnpm), + * or null for top-level (resolvable from app root) + * @property {(target: string) => string[]} [nativeFileRelatives] + * — explicit list of .node files relative to package root + * (used by 'js-and-native-file'; native-files mode auto-scans *.node) + */ + +/** @type {readonly NativeDepDescriptor[]} */ +export const nativeDeps = Object.freeze([ + { + id: 'clipboard-host', + name: () => '@mariozechner/clipboard', + collect: 'js-only', + parent: null, + }, + { + id: 'clipboard-target', + name: (target) => clipboardSubpackageByTarget[target], + collect: 'native-files', + parent: 'clipboard-host', + }, + { + id: 'pi-tui', + name: () => '@earendil-works/pi-tui', + // pi-tui is bundled into main.cjs at build time — we don't collect it as + // a native dep, only register it so koffi can declare it as parent. + collect: 'virtual', + parent: null, + }, + { + id: 'koffi', + name: () => 'koffi', + collect: 'js-and-native-file', + parent: 'pi-tui', + nativeFileRelatives: (target) => [`build/koffi/${koffiTripletByTarget[target]}/koffi.node`], + }, +]); + +/** + * Resolve which deps need collecting for a given build target, with concrete names. + */ +export function resolveTargetDeps(target) { + if (!isSupportedTarget(target)) { + throw new Error(`Unsupported native asset target: ${target}`); + } + return nativeDeps + .filter((d) => d.collect !== 'virtual') + .map((d) => ({ + ...d, + resolvedName: d.name(target), + nativeFileRelatives: d.nativeFileRelatives?.(target) ?? [], + parentName: d.parent ? nativeDeps.find((p) => p.id === d.parent)?.name(target) ?? null : null, + })); +} diff --git a/apps/kimi-code/scripts/native/package.mjs b/apps/kimi-code/scripts/native/package.mjs new file mode 100644 index 000000000..e146af0ab --- /dev/null +++ b/apps/kimi-code/scripts/native/package.mjs @@ -0,0 +1,53 @@ +import { createHash } from 'node:crypto'; +import { createReadStream, createWriteStream } from 'node:fs'; +import { mkdir, stat, writeFile } from 'node:fs/promises'; +import { basename, resolve } from 'node:path'; +import { pipeline } from 'node:stream/promises'; + +import { ZipFile } from 'yazl'; + +import { executableName, nativeArtifactsDir, nativeBinPath, targetTriple } from './paths.mjs'; + +const target = targetTriple(); +const execName = executableName(); +const sourceBinary = nativeBinPath(target); +const artifactsDir = nativeArtifactsDir(); + +// Flat-name archive for GH Release (GitHub Release assets do not support subdirectories). +const artifactName = `kimi-code-${target}.zip`; +const artifactPath = resolve(artifactsDir, artifactName); +const checksumPath = `${artifactPath}.sha256`; + +function fail(message) { + console.error(message); + process.exit(1); +} + +async function sha256(path) { + return await new Promise((resolveHash, reject) => { + const hash = createHash('sha256'); + const stream = createReadStream(path); + stream.on('error', reject); + stream.on('data', (chunk) => hash.update(chunk)); + stream.on('end', () => resolveHash(hash.digest('hex'))); + }); +} + +try { + await stat(sourceBinary); +} catch { + fail(`Native executable not found at ${sourceBinary}. Run build:native:sea first.`); +} + +await mkdir(artifactsDir, { recursive: true }); + +const zip = new ZipFile(); +zip.addFile(sourceBinary, execName, { mode: 0o100755 }); +zip.end(); +await pipeline(zip.outputStream, createWriteStream(artifactPath)); + +const digest = await sha256(artifactPath); +await writeFile(checksumPath, `${digest} ${basename(artifactPath)}\n`); + +console.log(`Wrote native artifact: ${artifactPath}`); +console.log(`Wrote artifact checksum: ${checksumPath}`); diff --git a/apps/kimi-code/scripts/native/paths.mjs b/apps/kimi-code/scripts/native/paths.mjs new file mode 100644 index 000000000..ca0462072 --- /dev/null +++ b/apps/kimi-code/scripts/native/paths.mjs @@ -0,0 +1,57 @@ +import { resolve } from 'node:path'; + +export const appRoot = resolve(import.meta.dirname, '..', '..'); + +export function targetTriple({ platform = process.platform, arch = process.arch, env = process.env } = {}) { + return env.KIMI_CODE_BUILD_TARGET ?? `${platform}-${arch}`; +} + +export function executableName(platform = process.platform) { + return platform === 'win32' ? 'kimi.exe' : 'kimi'; +} + +export function nativeDistRoot() { + return resolve(appRoot, 'dist-native'); +} + +export function nativeIntermediatesDir() { + return resolve(nativeDistRoot(), 'intermediates'); +} + +export function nativeBinDir(target = targetTriple()) { + return resolve(nativeDistRoot(), 'bin', target); +} + +export function nativeBinPath(target = targetTriple(), platform = process.platform) { + return resolve(nativeBinDir(target), executableName(platform)); +} + +export function nativeJsBundlePath() { + return resolve(nativeIntermediatesDir(), 'main.cjs'); +} + +export function nativeBlobPath() { + return resolve(nativeIntermediatesDir(), 'kimi.blob'); +} + +export function nativeSeaConfigPath() { + return resolve(nativeIntermediatesDir(), 'sea-config.json'); +} + +export function nativeManifestDir(target = targetTriple()) { + return resolve(nativeIntermediatesDir(), 'native-assets', target); +} + +export function nativeArtifactsDir() { + return resolve(nativeDistRoot(), 'artifacts'); +} + +export function nativeSmokeHome() { + return resolve(nativeDistRoot(), 'smoke-home'); +} + +export function nativeManifestKey(target = targetTriple()) { + return `native/${target}/manifest.json`; +} + +export const SEA_SENTINEL_FUSE = 'NODE_SEA_FUSE_fce680ab2cc467b6e072b8b5df1996b2'; diff --git a/apps/kimi-code/scripts/native/produce-manifest.mjs b/apps/kimi-code/scripts/native/produce-manifest.mjs new file mode 100644 index 000000000..7718f64b1 --- /dev/null +++ b/apps/kimi-code/scripts/native/produce-manifest.mjs @@ -0,0 +1,55 @@ +/** + * Aggregate per-platform zip archive `.sha256` files into a single + * `manifest.json` written into the same input directory. + * + * Usage: + * node produce-manifest.mjs + * + * Input dir must contain files matching: kimi-code-.zip.sha256 + * (produced by package.mjs across the 6 native-build matrix runners). + * + * Output: + * /manifest.json ← consumed by install.sh / install.ps1 + * + */ + +import { readFile, readdir, writeFile } from 'node:fs/promises'; +import { basename, resolve } from 'node:path'; + +const [, , inputDir, tag] = process.argv; +if (!inputDir || !tag) { + console.error('Usage: produce-manifest.mjs '); + process.exit(1); +} + +// Tag 格式 `@moonshot-ai/kimi-code@x.y.z` 或 `vx.y.z` 或 `x.y.z`,都归一化到 x.y.z +const version = tag.replace(/^@moonshot-ai\/kimi-code@/, '').replace(/^v/, ''); + +const entries = await readdir(inputDir); +const sumFiles = entries.filter((f) => /^kimi-code-[a-z0-9-]+\.zip\.sha256$/.test(f)); + +if (sumFiles.length === 0) { + console.error(`No kimi-code-.zip.sha256 files found in ${inputDir}`); + process.exit(1); +} + +const platforms = {}; +for (const sumFile of sumFiles.sort()) { + const text = await readFile(resolve(inputDir, sumFile), 'utf-8'); + const [checksum] = text.trim().split(/\s+/, 1); + if (!checksum || !/^[a-f0-9]{64}$/.test(checksum)) { + console.error(`Invalid checksum in ${sumFile}: ${checksum}`); + process.exit(1); + } + const filename = basename(sumFile, '.sha256'); + // kimi-code-darwin-arm64.zip → darwin-arm64 + const target = filename.replace(/^kimi-code-/, '').replace(/\.zip$/, ''); + platforms[target] = { filename, checksum }; +} + +const manifest = { version, tag, platforms }; +const manifestPath = resolve(inputDir, 'manifest.json'); + +await writeFile(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`); + +console.log(`Wrote ${manifestPath} (${Object.keys(platforms).length} platforms)`); diff --git a/apps/kimi-code/scripts/native/resolve-release.mjs b/apps/kimi-code/scripts/native/resolve-release.mjs new file mode 100644 index 000000000..10b4f9724 --- /dev/null +++ b/apps/kimi-code/scripts/native/resolve-release.mjs @@ -0,0 +1,55 @@ +import { appendFile, readFile } from 'node:fs/promises'; +import { resolve } from 'node:path'; + +import { appRoot } from './paths.mjs'; + +const packageName = '@moonshot-ai/kimi-code'; +const packageJson = JSON.parse( + await readFile(resolve(appRoot, 'package.json'), 'utf-8'), +); + +function parsePublishedPackages() { + const raw = process.env['CHANGESETS_PUBLISHED_PACKAGES']; + if (raw === undefined || raw.trim().length === 0) { + return []; + } + + try { + const parsed = JSON.parse(raw); + return Array.isArray(parsed) ? parsed : []; + } catch { + return []; + } +} + +function outputLine(name, value) { + return `${name}=${value}\n`; +} + +const publishedPackage = parsePublishedPackages().find( + (entry) => + typeof entry === 'object' && + entry !== null && + entry.name === packageName && + typeof entry.version === 'string', +); + +const version = publishedPackage?.version ?? packageJson.version; +const shouldPublish = publishedPackage !== undefined; +const tag = `${packageName}@${version}`; +const githubOutput = process.env['GITHUB_OUTPUT']; + +if (githubOutput !== undefined) { + await appendFile( + githubOutput, + [ + outputLine('should_publish', String(shouldPublish)), + outputLine('version', version), + outputLine('tag', tag), + ].join(''), + ); +} + +console.log(`should_publish=${String(shouldPublish)}`); +console.log(`version=${version}`); +console.log(`tag=${tag}`); diff --git a/apps/kimi-code/scripts/native/smoke.mjs b/apps/kimi-code/scripts/native/smoke.mjs new file mode 100644 index 000000000..ed3a8624f --- /dev/null +++ b/apps/kimi-code/scripts/native/smoke.mjs @@ -0,0 +1,82 @@ +import { execFile } from 'node:child_process'; +import { readFile, stat } from 'node:fs/promises'; +import { resolve } from 'node:path'; +import { promisify } from 'node:util'; + +import { appRoot, nativeBinPath, nativeSmokeHome, targetTriple } from './paths.mjs'; + +const execFileAsync = promisify(execFile); +const target = targetTriple(); +const executablePath = nativeBinPath(target); +const smokeHome = nativeSmokeHome(); +const packageJson = JSON.parse(await readFile(resolve(appRoot, 'package.json'), 'utf-8')); +const expectedVersion = packageJson.version; + +function fail(message) { + console.error(message); + process.exit(1); +} + +async function ensureExecutableExists() { + try { + await stat(executablePath); + } catch { + fail(`Native executable not found at ${executablePath}. Run build:native:sea first.`); + } +} + +async function runKimi(args) { + try { + const { stdout, stderr } = await execFileAsync(executablePath, args, { + cwd: appRoot, + maxBuffer: 1024 * 1024 * 16, + }); + return `${stdout}${stderr}`; + } catch (error) { + const detail = [error.stdout?.trim(), error.stderr?.trim(), error.message] + .filter(Boolean) + .join('\n'); + fail(`Native smoke failed: ${executablePath} ${args.join(' ')}\n${detail}`); + } +} + +async function runKimiWithEnv(args, env) { + try { + const { stdout, stderr } = await execFileAsync(executablePath, args, { + cwd: appRoot, + env: { ...process.env, ...env }, + maxBuffer: 1024 * 1024 * 16, + }); + return `${stdout}${stderr}`; + } catch (error) { + const detail = [error.stdout?.trim(), error.stderr?.trim(), error.message] + .filter(Boolean) + .join('\n'); + fail(`Native smoke failed: ${executablePath} ${args.join(' ')}\n${detail}`); + } +} + +function assertIncludes(output, expected, command) { + if (!output.includes(expected)) { + fail(`Native smoke output for "${command}" did not include "${expected}".\n${output}`); + } +} + +await ensureExecutableExists(); + +const versionOutput = await runKimi(['--version']); +assertIncludes(versionOutput, expectedVersion, '--version'); + +const helpOutput = await runKimi(['--help']); +assertIncludes(helpOutput, 'Usage: kimi', '--help'); + +const exportHelpOutput = await runKimi(['export', '--help']); +assertIncludes(exportHelpOutput, 'Usage: kimi export', 'export --help'); + +const nativeAssetOutput = await runKimiWithEnv(['--version'], { + KIMI_CODE_HOME: smokeHome, + KIMI_CODE_NATIVE_ASSET_SMOKE: '1', +}); +assertIncludes(nativeAssetOutput, `Native asset smoke passed: ${target}`, 'native asset smoke'); + +console.log(`Native smoke passed: ${executablePath}`); diff --git a/apps/kimi-code/scripts/postinstall.mjs b/apps/kimi-code/scripts/postinstall.mjs new file mode 100644 index 000000000..4662c43cf --- /dev/null +++ b/apps/kimi-code/scripts/postinstall.mjs @@ -0,0 +1,269 @@ +#!/usr/bin/env node +/** + * Postinstall hook for @moonshot-ai/kimi-code. + * + * Goal: when this package is installed globally, ensure typing `kimi` + * invokes the new TypeScript CLI. The npm `package.json` bin field + * installs a fresh `kimi` shim into the global bin dir; this script + * removes any pre-existing `kimi` shim left behind by the previous + * Python CLI (installed via `uv tool install`, `pipx install`, + * `pip install`, etc.) that would otherwise shadow ours via PATH + * ordering. The renamed shim is kept as `kimi-legacy` so users can + * still invoke the old CLI if they want to fall back. + * + * ## Hard rules + * + * - Only runs for global installs across npm, yarn (classic), and + * pnpm. Non-global installs (npx, local project deps, workspace + * bootstraps, `pnpm dlx`) are silent no-ops. + * - Never fails the install. Any error here is caught and reported, + * but the script always exits 0. + * - Does not touch a `kimi` we don't recognize as the previous + * Python CLI (matched by realpath-resolved shim head containing + * `kimi_cli`). + * - Cross-platform: POSIX and Windows. Windows-specific bits live + * in the helpers (PATHEXT-aware PATH walking, whole-file marker + * sniff for uv's Rust launcher .exe, extension-preserving + * rename target like `kimi.exe` → `kimi-legacy.exe`). + * + * ## Code layout + * + * This file is the orchestrator; the actual logic lives in + * sibling modules to keep each file under a manageable size: + * + * - `./postinstall/reach.mjs` — package-manager detection, + * global-install gate, own-package-root resolution, user-shell + * PATH lookup, reachability check. + * - `./postinstall/migrate.mjs` — legacy detection, + * `kimi`-vs-`kimi-legacy` classification, the rename / unlink + * primitives. + * - `./postinstall/ui.mjs` — `notify()` (with `/dev/tty` fallback), + * ANSI styling, the fixed-width box, and the five outcome + * renderers. + * + * ## Workflow + * + * What runs when a user types `npm install -g @moonshot-ai/kimi-code` + * (or the yarn / pnpm equivalent): + * + * 1. The manager extracts the package and runs lifecycle scripts. + * The `bin.kimi` mapping in `package.json` tells the manager to + * install a `kimi` shim under its global bin directory. + * 2. The manager invokes this script via the `scripts.postinstall` + * entry — orchestrated by `main` below. + * 3. Install-context gate: only proceed when this is a global + * install (`isGlobalInstall` checks `npm_config_global` / + * `pnpm_config_global` / `npm_config_location`). + * 4. Probe PATH once via `postinstallPaths()`: detection uses the + * union of shell PATH + process PATH; reachability uses the + * shell PATH alone (with a fallback to process PATH if the + * shell can't be probed). Sharing one probe keeps detection + * and reachability symmetric and avoids running `$SHELL -l` + * twice. + * 5. Detect EVERY previous Python `kimi-cli` shim on the detection + * PATH (`detectLegacyShims`). Returns `[]` for fresh-install / + * no-op. Multiple results happen when the user has installed + * `kimi-cli` through more than one Python tool (uv + pipx, or + * sudo-pip + pip-user). PATH order is preserved. + * 6. Pre-flight classify each shim (`classifyShim`) — pure + * filesystem inspection, no writes. Each shim ends up + * `renameable`, `consolidate`, `delete-only`, or `blocked`. + * 7. Decide abort vs proceed against the WHOLE set: + * `findFirstResolvableKimi` walks PATH treating the actionable + * shims as gone and reports what wins: + * - `own` → proceed to execute. + * - `blocked-legacy` → a legacy we can't remove still wins. + * Surface `logMigrationBlocked` with sudo / admin + * instructions; touch nothing. + * - `foreign` → some `kimi` we don't recognize (a user's own + * file) wins. Surface `logForeignKimiInTheWay` asking the + * user to delete or rename their own file; touch nothing. + * - `none` → no `kimi` on PATH at all (our shim's bin dir + * isn't in the shell's PATH). Surface + * `logNewCliNotOnPath`; touch nothing. + * 8. Execute. The FIRST classification in PATH order that we can + * touch becomes `kimi-legacy` (preserves what `kimi` referred + * to before this install). Each subsequent shim is `unlink`ed — + * keeping it as a dormant duplicate adds no value. If the + * first shim's `kimi-legacy` target is already user-managed, + * we delete `kimi` anyway (still achieves takeover) and tell + * the user we couldn't preserve a fallback. Extension is + * preserved on Windows (`kimi.exe` → `kimi-legacy.exe`). + * 9. One end-of-orchestration notice (`logMigrationDone`) + * summarizes every action — renames, consolidates, + * delete-only, deletes, and harmless blocked leftovers. The + * takeover-success line only fires on this path because Step 7 + * already certified it. + * 10. The manager completes the install with its usual summary. + * This script always exits 0; any uncaught error is swallowed + * by the top-level `catch` so the install never fails because + * of the migration. + */ + +import { + detectPackageManager, + findFirstResolvableKimi, + isGlobalInstall, + ownPackageRoot, + postinstallPaths, +} from './postinstall/reach.mjs'; +import { + classifyShim, + deleteShim, + detectLegacyShims, + renameInPlace, +} from './postinstall/migrate.mjs'; +import { + logForeignKimiInTheWay, + logMigrationBlocked, + logMigrationDone, + logNewCliNotOnPath, + notify, +} from './postinstall/ui.mjs'; + +async function main() { + // Step 1: skip non-global installs (npx, local project deps, + // workspace bootstraps). Windows is supported natively; the + // platform-specific bits (PATHEXT-aware PATH walk, whole-file + // marker sniff for uv's launcher .exe, extension-preserving + // rename) live in the helpers. + if (!isGlobalInstall()) return; + + // Step 2: locate our own installed package root once and share it + // with both detection (skip files inside our package) and + // reachability (only count our shim as "found"). + const ownRoot = await ownPackageRoot(import.meta.dirname); + const pm = detectPackageManager(); + + // Step 3: probe the user's shell PATH once so detection and + // reachability share a single consistent view. Detection uses the + // union of shell PATH + process PATH (so we catch a legacy shim + // visible to either); reachability uses the shell PATH alone (so + // we don't claim "kimi works now" when the shim only sits in the + // installer's env). + const paths = await postinstallPaths(); + + // Step 4: detect EVERY previous Python `kimi-cli` shim on the + // detection PATH. A user with both `uv tool install` and `pipx + // install` would have two; we must address all of them or the + // survivor still shadows the new CLI. + const detections = await detectLegacyShims(ownRoot, paths.detection); + if (detections.length === 0) return; + + // Step 5: pre-flight classify every shim WITHOUT touching the + // filesystem yet. The orchestrator decides abort-or-proceed against + // the whole set rather than discovering mid-loop that we got partway + // and have to backtrack. + const classifications = await Promise.all( + detections.map(async (detection) => { + const c = await classifyShim(detection.shimPath); + return { ...c, detection }; + }), + ); + + // Step 6: figure out what wins PATH resolution once every shim we + // CAN touch is treated as gone. Three possible blockers: + // - a legacy shim we couldn't classify as actionable (sudo/admin + // needed) + // - an unrelated `kimi` we don't recognize (a user's own wrapper + // script — they own the decision) + // - nothing resolves (our shim isn't on PATH at all) + // For each we render a different notice and touch NOTHING. The + // common-case fourth result is "our shim wins" — we proceed. + const actionable = classifications.filter((c) => c.kind !== 'blocked'); + const blocked = classifications.filter((c) => c.kind === 'blocked'); + const actionableShimPaths = actionable.map((c) => c.shimPath); + const allDetectedShimPaths = classifications.map((c) => c.shimPath); + + const blocker = await findFirstResolvableKimi( + ownRoot, + paths.reachability, + actionableShimPaths, + allDetectedShimPaths, + ); + if (blocker.kind !== 'own') { + if (blocker.kind === 'blocked-legacy') { + logMigrationBlocked(blocked, actionable, pm); + } else if (blocker.kind === 'foreign') { + logForeignKimiInTheWay(blocker.path, pm); + } else { + // 'none' — our shim isn't on PATH at all. + logNewCliNotOnPath(detections[0], pm); + } + return; + } + + // Step 7: execute. The FIRST classification in PATH order that + // we can touch becomes `kimi-legacy` (preserves what the user's + // `kimi` used to refer to). Every subsequent shim is just + // deleted — keeping it as a dormant duplicate adds no value. + const renames = []; + const consolidates = []; + const skippedForeignTarget = []; + const deletes = []; + const errors = []; + let preservedFirst = false; + + for (const c of classifications) { + if (c.kind === 'blocked') continue; // already established harmless + + if (!preservedFirst) { + preservedFirst = true; + if (c.kind === 'renameable') { + const r = await renameInPlace(c.shimPath, c.target); + if (r.success) { + renames.push(c); + } else { + errors.push({ ...c, ...r }); + } + continue; + } + if (c.kind === 'consolidate') { + const r = await deleteShim(c.shimPath); + if (r.success) { + consolidates.push(c); + } else { + errors.push({ ...c, ...r }); + } + continue; + } + if (c.kind === 'delete-only') { + const r = await deleteShim(c.shimPath); + if (r.success) { + skippedForeignTarget.push(c); + } else { + errors.push({ ...c, ...r }); + } + continue; + } + } else { + // Not the first actionable shim. Just delete it. + const r = await deleteShim(c.shimPath); + if (r.success) { + deletes.push(c); + } else { + errors.push({ ...c, ...r }); + } + } + } + + // Step 8: one notice summarizing everything that happened. The + // takeover-success language is only emitted when we know it's true + // (we already passed the reachability gate above). + logMigrationDone( + { + renames, + consolidates, + skippedForeignTarget, + deletes, + blockedHarmless: blocked, + errors, + }, + pm, + ); +} + +main().catch((err) => { + const message = err instanceof Error ? err.message : String(err); + notify(`[kimi-code] postinstall warning: ${message}`); +}); diff --git a/apps/kimi-code/scripts/postinstall/migrate.mjs b/apps/kimi-code/scripts/postinstall/migrate.mjs new file mode 100644 index 000000000..b1ae695a3 --- /dev/null +++ b/apps/kimi-code/scripts/postinstall/migrate.mjs @@ -0,0 +1,351 @@ +/** + * Detection of the previous Python `kimi-cli` shim and the actual + * filesystem operations that perform (or refuse) the rename. + * + * Detection: + * - {@link detectLegacyShims}: walks the caller-supplied + * `pathString` and returns every legacy `kimi` shim along the + * way (in PATH order). A shim qualifies when it realpath-resolves + * outside our own installed package root and its head 4 KiB + * contains the `kimi_cli` module marker — the setuptools + * entry-point format produced by `uv tool install`, + * `pipx install`, `pip install`, etc. Returning all hits (not + * just the first) matters because a user with both uv- and + * pipx-installed `kimi-cli` has two legacy shims in different + * dirs, and renaming only the earlier one leaves the later one + * shadowing our new CLI. Callers should pass the `detection` + * field from `postinstallPaths()` so detection sees the union of + * the shell PATH and the installer's PATH. + * - {@link isLegacyShim}: same criterion in standalone form, used to + * decide whether an existing `kimi-legacy` is itself a legacy CLI + * (safe to consolidate over) or a user-managed file (preserve). + * + * Classify + execute (two-phase): + * - {@link classifyShim}: pre-flight inspection that says what we + * COULD do to a given shim. Returns one of `renameable`, + * `consolidate`, `delete-only`, `blocked`. No filesystem writes. + * - {@link renameInPlace} / {@link deleteShim}: the primitive + * operations that actually mutate the filesystem. Run after the + * orchestrator has looked at the full set of classifications and + * decided to proceed. + * + * Splitting classification from execution lets the orchestrator make + * the abort-or-proceed decision once, against the whole detected set, + * rather than discovering mid-loop that something failed and ending up + * with a misleading "kimi now launches the new CLI" notice in front of + * a "permission denied" notice. Uses `fs.lstat` (not `fs.access`) to + * detect dangling symlinks at the target so we don't clobber them. + */ + +import { constants as fsConstants, promises as fs } from 'node:fs'; +import { delimiter, dirname, extname, join, sep } from 'node:path'; + +const LEGACY_BIN = 'kimi'; +const LEGACY_RENAME = 'kimi-legacy'; +const PYTHON_MARKER = 'kimi_cli'; +const IS_WINDOWS = process.platform === 'win32'; + +// Read window for the marker sniff. +// POSIX: setuptools entry-point scripts are a few hundred bytes — +// 4 KiB is generous. +// Windows: `uv tool install` produces a Rust-built launcher .exe +// (~45 KiB) and the `kimi_cli` module name sits embedded +// near the END of the file (verified offset ≈ 44103 on +// uv 0.11). Cap reads at 256 KiB so a hostile or +// unexpectedly large file can't make us hold a lot of +// memory. +const SHIM_SNIFF_BYTES_POSIX = 4096; +const SHIM_SNIFF_BYTES_WINDOWS_MAX = 256 * 1024; + +function pathEntries(pathString) { + if (!pathString) return []; + const seen = new Set(); + const out = []; + for (const entry of pathString.split(delimiter)) { + if (!entry || seen.has(entry)) continue; + seen.add(entry); + out.push(entry); + } + return out; +} + +/** + * Expand `kimi` into the set of filenames that resolve as executables + * on this platform. POSIX → just `['kimi']`. Windows → adds every + * `PATHEXT` extension (so we find `kimi.exe`, `kimi.cmd`, etc). + */ +function executableCandidates(basename) { + if (!IS_WINDOWS) return [basename]; + const pathext = (process.env['PATHEXT'] ?? '.EXE;.CMD;.BAT;.COM') + .toLowerCase() + .split(';') + .map((e) => e.trim()) + .filter(Boolean); + return [basename, ...pathext.map((ext) => basename + ext)]; +} + +async function isExecutableFile(filePath) { + try { + const info = await fs.stat(filePath); + if (!info.isFile()) return false; + // Windows: stat().mode doesn't reflect ACLs in any useful way. + // Callers already restrict to PATHEXT candidates, so existence + // suffices. + if (IS_WINDOWS) return true; + return (info.mode & 0o111) !== 0; + } catch { + return false; + } +} + +async function readShimHead(filePath) { + let handle; + try { + handle = await fs.open(filePath, 'r'); + const stat = await handle.stat(); + const limit = IS_WINDOWS ? SHIM_SNIFF_BYTES_WINDOWS_MAX : SHIM_SNIFF_BYTES_POSIX; + const target = Math.min(stat.size, limit); + const buffer = Buffer.alloc(target); + const { bytesRead } = await handle.read(buffer, 0, target, 0); + // `latin1` is a 1-to-1 byte→char mapping; we're searching for an + // ASCII substring, so we don't want UTF-8 decoding to mangle the + // bytes around the marker. + return buffer.subarray(0, bytesRead).toString('latin1'); + } catch { + return null; + } finally { + if (handle) await handle.close().catch(() => {}); + } +} + +/** + * Walk `pathString` and return every legacy `kimi` shim along the + * way, in PATH order. The orchestrator renames each in turn — a + * single rename is insufficient when the user has multiple legacy + * installs (e.g. one from `uv tool install` and another from `pipx + * install`) in different directories, because the survivor still + * shadows the new CLI. + * + * Each entry has the same shape as the previous single-return + * value: `{ shimPath, realPath }`. The empty array means + * "fresh-install / no-op". + */ +export async function detectLegacyShims(ownRoot, pathString) { + const ownRootPrefix = ownRoot ? ownRoot + sep : null; + const candidates = executableCandidates(LEGACY_BIN); + const results = []; + const seenShims = new Set(); + + for (const dir of pathEntries(pathString)) { + for (const name of candidates) { + const shimPath = join(dir, name); + if (seenShims.has(shimPath)) continue; + if (!(await isExecutableFile(shimPath))) continue; + + let realPath; + try { + realPath = await fs.realpath(shimPath); + } catch { + continue; + } + + // Defence-in-depth: never touch a `kimi` that resolves into our + // own installed package. The `kimi_cli` marker check below + // already excludes the manager's generated wrapper today, but + // this layer keeps us safe if anything in our bundle ever + // happens to contain the marker substring. + if ( + ownRootPrefix !== null && + (realPath === ownRoot || realPath.startsWith(ownRootPrefix)) + ) { + continue; + } + + const head = await readShimHead(realPath); + if (!head || !head.includes(PYTHON_MARKER)) continue; + + seenShims.add(shimPath); + results.push({ shimPath, realPath }); + } + } + return results; +} + +/** + * Does the file at `p` look like the legacy Python `kimi-cli`? + * + * Same criterion as {@link detectLegacyShim} uses to recognize the + * original shim: realpath-resolvable and the first 4 KiB of the + * resolved file contains the `kimi_cli` module name. Used to decide + * whether an existing `kimi-legacy` is itself a legacy shim (safe to + * drop the duplicate `kimi`) or a user-managed file we must not + * clobber. + */ +export async function isLegacyShim(p) { + let real; + try { + real = await fs.realpath(p); + } catch { + return false; + } + const head = await readShimHead(real); + return Boolean(head && head.includes(PYTHON_MARKER)); +} + +async function pathExists(p) { + try { + // lstat (not access/stat) so a dangling symlink at `p` still + // reports as existing — `fs.access` follows symlinks and would + // return ENOENT for a broken link, after which `fs.rename` would + // silently replace it. + await fs.lstat(p); + return true; + } catch { + return false; + } +} + +/** + * Compute where `shimPath` should be renamed to. Preserves the file + * extension so a Windows `C:\…\kimi.exe` ends up at `kimi-legacy.exe` + * rather than an extension-less `kimi-legacy` that `kimi.exe -- legacy` + * shells won't run. + */ +function renameTargetFor(shimPath) { + const ext = extname(shimPath); // "" on POSIX, ".exe" on Windows + return join(dirname(shimPath), LEGACY_RENAME + ext); +} + +/** + * Is the directory containing `shimPath` a system-managed location + * the current user can't write to? + * + * POSIX: dir owned by uid 0 (root) — captures + * `sudo pip install kimi-cli` → `/usr/local/bin/`. + * + * Windows: dir under one of the well-known system roots + * (`C:\Program Files`, `C:\ProgramData`, `C:\Windows`). uv tool + * installs land in user space (`%USERPROFILE%\.local\bin`) so this + * path almost never fires on Windows in practice, but the heuristic + * correctly classifies the rare admin-prefix install case. + * + * The dedicated permission-denied notice (`logPermissionDenied`) + * uses this to switch from a bare "rename it manually" message to a + * sudo-aware / admin-aware explanation. + */ +async function isSystemOwnedDir(shimPath) { + if (IS_WINDOWS) { + const dir = dirname(shimPath).toLowerCase(); + const systemRoots = [ + 'c:\\program files', + 'c:\\program files (x86)', + 'c:\\programdata', + 'c:\\windows', + ]; + return systemRoots.some( + (root) => dir === root || dir.startsWith(root + '\\'), + ); + } + try { + const info = await fs.stat(dirname(shimPath)); + return info.uid === 0; + } catch { + return false; + } +} + +async function canWriteDir(dir) { + try { + // For `fs.rename` and `fs.unlink` the parent dir needs to be both + // writable and executable (POSIX `wx`). `fs.access` follows the + // same semantics. On Windows ACL details are coarse but + // `fs.access(W_OK)` is a reasonable best-effort. + await fs.access(dir, fsConstants.W_OK | fsConstants.X_OK); + return true; + } catch { + return false; + } +} + +/** + * Pre-flight inspection of a single legacy shim. Returns what action + * we could take WITHOUT executing it. The orchestrator uses these + * classifications to decide the whole-set strategy (abort vs proceed, + * which shim becomes kimi-legacy) before any filesystem writes + * happen. + * + * Result shapes (all carry `shimPath` and `target` so the renderer + * has the paths): + * - `renameable` : can `fs.rename(shim → target)` cleanly; + * target slot is free. + * - `consolidate` : target already exists and is itself a legacy + * shim; we'd `fs.unlink(shim)` and leave the + * existing `kimi-legacy` as the canonical + * fallback (functionally equivalent — same + * upstream package). + * - `delete-only` : target exists but is a user-managed file we + * won't clobber. We can still `fs.unlink(shim)` + * to stop the shadowing; the + * "preserve original kimi" invariant fails for + * THIS dir (we tell the user). + * - `blocked` : we can't write to the parent dir. Carries + * `isSystemPath` so the renderer can suggest + * sudo (POSIX) or admin PowerShell (Windows). + */ +export async function classifyShim(shimPath) { + const target = renameTargetFor(shimPath); + const dir = dirname(shimPath); + + if (!(await canWriteDir(dir))) { + return { + kind: 'blocked', + shimPath, + target, + isSystemPath: await isSystemOwnedDir(shimPath), + }; + } + + if (await pathExists(target)) { + if (await isLegacyShim(target)) { + return { kind: 'consolidate', shimPath, target }; + } + return { kind: 'delete-only', shimPath, target }; + } + + return { kind: 'renameable', shimPath, target }; +} + +/** + * Execute an `fs.rename`. Pre-flight classification establishes + * whether this is expected to succeed; this primitive just runs the + * call and wraps the error. + */ +export async function renameInPlace(shimPath, target) { + try { + await fs.rename(shimPath, target); + return { success: true }; + } catch (err) { + const code = err && typeof err === 'object' ? err.code : undefined; + const message = err instanceof Error ? err.message : String(err); + return { success: false, code, message }; + } +} + +/** + * Execute an `fs.unlink`. Used when: + * - we're consolidating onto an existing legacy `kimi-legacy`, or + * - we couldn't rename (foreign target) but can still remove the + * shadow, or + * - this is a non-first shim and we just want to clear it out so + * PATH order resolves to our new CLI. + */ +export async function deleteShim(shimPath) { + try { + await fs.unlink(shimPath); + return { success: true }; + } catch (err) { + const code = err && typeof err === 'object' ? err.code : undefined; + const message = err instanceof Error ? err.message : String(err); + return { success: false, code, message }; + } +} diff --git a/apps/kimi-code/scripts/postinstall/reach.mjs b/apps/kimi-code/scripts/postinstall/reach.mjs new file mode 100644 index 000000000..e6a4c9385 --- /dev/null +++ b/apps/kimi-code/scripts/postinstall/reach.mjs @@ -0,0 +1,457 @@ +/** + * Where the user actually types things, and what `kimi` resolves to + * from there. + * + * Covers: + * + * - Package-manager detection ({@link detectPackageManager}) and + * manager-specific command hints ({@link pmGlobalBinCommand}, + * {@link pmGlobalInstallCommand}) used in user-facing notices. + * - Global-install gating ({@link isGlobalInstall}) — what counts as + * a global install across npm / yarn classic / pnpm. + * - Own-package-root location ({@link ownPackageRoot}) — walks up + * from `import.meta.dirname` looking for `package.json`. + * - User-shell PATH ({@link userShellPath}) — spawns `$SHELL -l` so + * we can check reachability from the shell the user will type + * `kimi` into, not just the installer's environment. + * - The combined PATH dispatcher ({@link postinstallPaths}) — + * called once by the orchestrator so detection and reachability + * stay symmetric and the shell probe doesn't run twice. + * - The reachability check ({@link findFirstResolvableKimi}) — + * walks PATH treating the to-be-renamed legacy shims as gone + * and reports what wins resolution. Distinguishes our own shim + * from a blocked legacy that's still in the way vs. an + * unknown/foreign `kimi` (e.g. a user-written wrapper). + * + * All functions are pure w.r.t. the filesystem (no mutations). + */ + +import { spawn } from 'node:child_process'; +import { promises as fs } from 'node:fs'; +import { delimiter, dirname, join, sep } from 'node:path'; + +const LEGACY_BIN = 'kimi'; +const IS_WINDOWS = process.platform === 'win32'; + +/** + * Expand a basename like `kimi` into the set of filenames the OS + * would actually match on PATH. + * + * On POSIX: just `['kimi']`. + * + * On Windows: `['kimi', 'kimi.exe', 'kimi.cmd', …]` — every + * extension in `PATHEXT`. Without this, our PATH walk would miss + * the typical `kimi.exe` shim produced by `uv tool install` on + * Windows. + */ +export function executableCandidates(basename) { + if (!IS_WINDOWS) return [basename]; + const pathext = (process.env['PATHEXT'] ?? '.EXE;.CMD;.BAT;.COM') + .toLowerCase() + .split(';') + .map((e) => e.trim()) + .filter(Boolean); + return [basename, ...pathext.map((ext) => basename + ext)]; +} + +/** + * Identify which package manager ran us. `npm_config_user_agent` is + * set by npm, yarn (classic + berry), and pnpm, and starts with the + * manager's name plus version. Defaults to `'npm'` for unknown / + * missing values. + */ +export function detectPackageManager() { + const ua = process.env['npm_config_user_agent'] ?? ''; + if (ua.startsWith('pnpm/')) return 'pnpm'; + if (ua.startsWith('yarn/')) return 'yarn'; + return 'npm'; +} + +/** + * Manager-specific shell command that prints (or expands to) the + * global bin directory. Used in the PATH-fix hint so the suggestion + * is valid regardless of which manager the user ran. + */ +export function pmGlobalBinCommand(pm) { + switch (pm) { + case 'pnpm': + return 'pnpm bin -g'; + case 'yarn': + return 'yarn global bin'; + case 'npm': + default: + return 'npm prefix -g'; + } +} + +/** Manager-specific reinstall command, used in the success-notice hint. */ +export function pmGlobalInstallCommand(pm, pkg) { + switch (pm) { + case 'pnpm': + return `pnpm add -g ${pkg}`; + case 'yarn': + return `yarn global add ${pkg}`; + case 'npm': + default: + return `npm install -g ${pkg}`; + } +} + +/** + * Did the user run a global install via some Node package manager? + * + * We accept four signals, any of which means "this install is + * landing in the manager's global bin directory": + * + * - `npm_config_global === 'true'` — set by `npm install -g`, and + * by pnpm on its global paths for back-compat. + * - `pnpm_config_global === 'true'` — set by pnpm in addition to + * the npm-compat flag above. + * - `npm_config_location === 'global'` — set by npm 7+ when the + * user passes `--location=global` (or runs `npm config set + * location global` persistently). npm intentionally does NOT + * also set `npm_config_global` in this case, so without this + * branch the migration silently no-ops for `npm install + * --location=global @moonshot-ai/kimi-code` — verified on npm + * 11.13.0. + * - {@link isYarnClassicGlobalAdd} — yarn classic does NOT set + * `npm_config_global` for `yarn global add`. The only reliable + * signal is parsing `npm_config_argv` and seeing the `global` + * subcommand. Verified on yarn 1.22.22. + * + * Local installs, `npx`, `pnpm dlx`, and workspace bootstraps leave + * all four signals false. + * + * Yarn berry (v2+) intentionally has no global-install concept, so + * it doesn't matter here. Postinstall on yarn berry runs in local + * context. + */ +export function isGlobalInstall() { + return ( + process.env['npm_config_global'] === 'true' || + process.env['pnpm_config_global'] === 'true' || + process.env['npm_config_location'] === 'global' || + isYarnClassicGlobalAdd() + ); +} + +/** + * `yarn global add` (yarn classic, v1.x) runs lifecycle scripts but + * leaves both `npm_config_global` and `npm_config_location` unset. + * The only reliable in-band signal is `npm_config_argv`, which yarn + * populates with the original command line as JSON: + * { original: ["global", "add", "", "--prefix=..."] } + * Parse it and require both: + * - `npm_config_user_agent` starts with `yarn/1.` (yarn classic; + * yarn berry has no global concept anyway). + * - Some token in argv is literally `"global"` AND the very next + * token is a known yarn-global subcommand (`add`, `remove`, + * etc). This handles the simple case (`yarn global add foo` → + * argv `["global","add",...]`) and the value-taking-flag case + * (`yarn --cwd /tmp global add foo` → argv `["--cwd","/tmp", + * "global","add",...]`) without having to maintain yarn's full + * flag table. It rejects `yarn add global` (the next token is + * undefined) and `yarn add @scope/global` (the literal string + * `"global"` doesn't appear). The remaining false positives + * (e.g., `yarn add global add` — installing two packages, one + * literally named `global`) are caught downstream by the + * reachability gate: a local install's bin dir isn't on PATH, + * so `isOwnCliResolvableFirst` will refuse to migrate. + */ +function isYarnClassicGlobalAdd() { + const ua = process.env['npm_config_user_agent'] ?? ''; + if (!ua.startsWith('yarn/1.')) return false; + const raw = process.env['npm_config_argv']; + if (!raw) return false; + let argv; + try { + argv = JSON.parse(raw); + } catch { + return false; + } + if (!Array.isArray(argv?.original)) return false; + const globalIdx = argv.original.indexOf('global'); + if (globalIdx === -1) return false; + const next = argv.original[globalIdx + 1]; + return typeof next === 'string' && YARN_GLOBAL_SUBCOMMANDS.has(next); +} + +// Yarn 1.x global subcommands. The install-class ones (`add`, +// `upgrade`, `upgrade-interactive`) are the ones that actually run +// our postinstall, but the read-only ones are included so the +// detection is consistent across all `yarn global ...` invocations. +const YARN_GLOBAL_SUBCOMMANDS = new Set([ + 'add', + 'remove', + 'upgrade', + 'upgrade-interactive', + 'list', + 'bin', + 'dir', +]); + +/** + * Locate the realpath of our own installed package root. + * + * Callers pass the starting directory (typically `import.meta.dirname` + * of the entry script, which lives at `/scripts/`). We + * walk up looking for the nearest `package.json`, then `realpath` the + * directory so symlinked install layouts (e.g. `/bin/kimi` + * symlinked into `lib/node_modules/.../dist/main.mjs`) compare equal + * in the caller's prefix check. + * + * Returns null if no `package.json` is found within a few levels — + * callers should treat that as "can't locate ourselves" and bail. + */ +export async function ownPackageRoot(startDir) { + let dir = startDir; + for (let i = 0; i < 6; i++) { + try { + await fs.access(join(dir, 'package.json')); + try { + return await fs.realpath(dir); + } catch { + return dir; + } + } catch { + // No `package.json` here; walk up. + } + const parent = dirname(dir); + if (parent === dir) break; + dir = parent; + } + return null; +} + +async function isExecutableFile(filePath) { + try { + const info = await fs.stat(filePath); + if (!info.isFile()) return false; + // Windows: ACLs aren't visible through stat().mode meaningfully — + // existence + a recognized extension is what PATHEXT-style lookup + // checks. Callers only pass us candidates that already match an + // extension in `executableCandidates()`, so "is a file" suffices. + if (IS_WINDOWS) return true; + return (info.mode & 0o111) !== 0; + } catch { + return false; + } +} + +// Package-manager shim generators embed the package's resolved path +// into the wrapper file. So any shim whose first KB contains our +// package name is one we own. Used as a fallback when realpath alone +// can't catch the shim — that happens for: +// - Windows cmd-shims (literal `.cmd` / `.ps1` files, not symlinks) +// - pnpm POSIX shims (literal `/bin/sh` scripts, not symlinks; pnpm +// does not symlink into the package root the way npm/yarn classic +// do on POSIX) +const PACKAGE_NAME_MARKERS = ['@moonshot-ai/kimi-code', '@moonshot-ai\\kimi-code']; + +async function shimReferencesOwnPackage(shimPath) { + try { + const handle = await fs.open(shimPath, 'r'); + try { + const buf = Buffer.alloc(4096); + const { bytesRead } = await handle.read(buf, 0, 4096, 0); + const text = buf.subarray(0, bytesRead).toString('latin1'); + return PACKAGE_NAME_MARKERS.some((m) => text.includes(m)); + } finally { + await handle.close().catch(() => {}); + } + } catch { + return false; + } +} + +async function classifyShim(shim, ownRoot, ownPrefix) { + let real; + try { + real = await fs.realpath(shim); + } catch { + return 'unreadable'; + } + if (real === ownRoot || real.startsWith(ownPrefix)) return 'own'; + if (await shimReferencesOwnPackage(shim)) return 'own'; + return 'other'; +} + +/** + * Walk `pathString` and report what the user's shell would resolve + * `kimi` to, AFTER the to-be-removed shims in `actionableShimPaths` + * are pretended gone. Returns the first match by PATH order: + * + * - { kind: 'own' } — our shim wins. + * - { kind: 'blocked-legacy', shim } — a legacy `kimi` we + * detected but couldn't touch (a "blocked" shim) wins. + * - { kind: 'foreign', path } — something else wins: a + * `kimi` we didn't recognize as a legacy CLI and didn't generate + * ourselves (e.g. a user-managed wrapper script in `~/bin`). + * - { kind: 'none' } — no `kimi` resolves at all. + * + * Used by the orchestrator to give an accurate "why the takeover + * can't proceed" notice: a blocked-legacy blocker needs different + * remediation (sudo / admin delete) than a foreign blocker (the + * user's own file, which only they can decide what to do with). + * + * `allDetectedShimPaths` is every legacy shim our detector found + * (both blocked and actionable). It lets us distinguish a blocked + * legacy that survived our hypothetical removal pass from a totally + * unknown `kimi`. + */ +export async function findFirstResolvableKimi( + ownRoot, + pathString, + actionableShimPaths, + allDetectedShimPaths, +) { + if (!ownRoot || !pathString) return { kind: 'none' }; + const ownPrefix = ownRoot + sep; + const candidates = executableCandidates(LEGACY_BIN); + const skipSet = new Set(actionableShimPaths ?? []); + const knownLegacySet = new Set(allDetectedShimPaths ?? []); + const seenDirs = new Set(); + for (const dir of pathString.split(delimiter)) { + if (!dir || seenDirs.has(dir)) continue; + seenDirs.add(dir); + for (const name of candidates) { + const shim = join(dir, name); + if (skipSet.has(shim)) continue; + if (!(await isExecutableFile(shim))) continue; + const kind = await classifyShim(shim, ownRoot, ownPrefix); + if (kind === 'unreadable') continue; + if (kind === 'own') return { kind: 'own' }; + if (knownLegacySet.has(shim)) { + return { kind: 'blocked-legacy', shim }; + } + return { kind: 'foreign', path: shim }; + } + } + return { kind: 'none' }; +} + +/** + * Read the user's default shell's view of `PATH`. + * + * Why: `process.env.PATH` reflects the environment of whichever + * shell the user happened to invoke the package manager from, which + * may or may not match the daily-driver shell they'll later type + * `kimi` into. A `~/.zshrc`-only PATH entry, a `~/.bash_profile` + * that doesn't source `~/.bashrc`, or a sudo'd install that scrubs + * HOME all break the installer's-PATH heuristic. + * + * We spawn `$SHELL -l -c 'printf %s "$PATH"'` so the shell reads + * its login-profile files (`.profile`, `.bash_profile`, `.zprofile`). + * `-i` would also pull in interactive rc files (`.bashrc`, `.zshrc`) + * but it requires a tty for some shells and is much more likely to + * have side effects (prompts, agent startup) — we accept the + * narrower view and let `'unknown'` fall through to a gentler + * check. + * + * Outcomes: + * { kind: 'ok', path: string } — shell printed its PATH. + * { kind: 'unknown', reason: string } — `$SHELL` missing, spawn + * failed, timed out, or + * shell printed no `PATH`. + */ +export async function userShellPath() { + // Windows has no `$SHELL`/login-shell concept worth probing — cmd.exe + // and PowerShell don't have profile files in the rc-chain sense, and + // their PATH already comes from the user's persistent registry env + // (which is what `process.env.PATH` reflects). Skip the spawn and + // let the caller fall back to `process.env.PATH`. + if (IS_WINDOWS) return { kind: 'unknown', reason: 'windows skip' }; + + const shell = process.env['SHELL']; + if (!shell) return { kind: 'unknown', reason: 'no SHELL env var' }; + + return new Promise((resolve) => { + let settled = false; + const settle = (value) => { + if (settled) return; + settled = true; + resolve(value); + }; + + let stdout = ''; + // Wrap PATH in delimiters we can parse out, defensively, in case + // the shell prints anything else (motd, prompt redraw, …). + const probe = + 'printf "<<>>%s<<>>\\n" "$PATH"'; + const child = spawn(shell, ['-l', '-c', probe], { + stdio: ['ignore', 'pipe', 'ignore'], + }); + child.stdout.on('data', (chunk) => { + stdout += chunk.toString('utf-8'); + }); + + // Bound the wait. Login rc files are usually quick, but a hostile + // `.profile` could hang indefinitely. + const timer = setTimeout(() => { + child.kill('SIGKILL'); + settle({ kind: 'unknown', reason: 'shell spawn timed out' }); + }, 5000); + + child.on('close', (code) => { + clearTimeout(timer); + const match = stdout.match( + /<<>>([\s\S]*?)<<>>/, + ); + if (match && match[1].length > 0) { + settle({ kind: 'ok', path: match[1] }); + return; + } + settle({ kind: 'unknown', reason: `no PATH printed (exit ${code})` }); + }); + child.on('error', (err) => { + clearTimeout(timer); + settle({ kind: 'unknown', reason: `spawn error: ${err.message}` }); + }); + }); +} + +/** + * Compute the two PATH strings the postinstall consults. + * + * - `detection`: a `delimiter`-joined union of the user's shell + * PATH and `process.env.PATH`, deduplicated. Used by + * {@link detectLegacyShim} to walk for legacy shims. We union + * because either source may contain a legacy `kimi` we want to + * rename — including one that lives in a directory the + * installer's environment can't see but the user's shell can + * (e.g. a sanitized lifecycle env from a packaged manager), and + * vice versa. + * + * - `reachability`: the user's shell PATH if we can probe it, else + * `process.env.PATH`. Used to verify our own shim is on the PATH + * the user will actually type `kimi` into. We do NOT union here: + * a shim that's only in the installer's PATH wouldn't help the + * user after the install, so unioning would mis-classify the + * install as reachable. + * + * Returns a single object so the (potentially slow) shell-probe runs + * just once per postinstall. + */ +export async function postinstallPaths() { + const shellResult = await userShellPath(); + const processPath = process.env['PATH'] ?? ''; + const shellPathStr = shellResult.kind === 'ok' ? shellResult.path : null; + const reachability = shellPathStr ?? processPath; + const detection = unionPaths(shellPathStr, processPath); + return { detection, reachability }; +} + +function unionPaths(...paths) { + const seen = new Set(); + const out = []; + for (const p of paths) { + if (!p) continue; + for (const entry of p.split(delimiter)) { + if (!entry || seen.has(entry)) continue; + seen.add(entry); + out.push(entry); + } + } + return out.join(delimiter); +} + diff --git a/apps/kimi-code/scripts/postinstall/ui.mjs b/apps/kimi-code/scripts/postinstall/ui.mjs new file mode 100644 index 000000000..84be992f5 --- /dev/null +++ b/apps/kimi-code/scripts/postinstall/ui.mjs @@ -0,0 +1,458 @@ +/** + * User-facing output for the postinstall: where lines go, ANSI styling, + * the fixed-width box layout, and the four outcome renderers: + * + * - `logMigrationDone` — the takeover succeeded (one or more legacy + * shims were processed). Lists every action taken: renames, + * consolidates, delete-only, plain deletes, and harmless blocked + * leftovers. Footer branches three ways: preserved-somewhere + * (standard "type kimi-legacy"), only skippedForeignTarget (we + * couldn't save the old CLI because a user file took the name), + * and only blockedHarmless (just notes the leftovers, no + * phantom-file talk). + * - `logMigrationBlocked` — a legacy `kimi` we can't remove sits + * on PATH ahead of our shim. Nothing was touched; user is told + * which paths need their manual attention with sudo / admin. + * - `logForeignKimiInTheWay` — a `kimi` we don't recognize (not + * ours, not a legacy CLI) sits ahead of our shim on PATH. User + * needs to delete or rename their own file. Different remediation + * from `logMigrationBlocked`. + * - `logNewCliNotOnPath` — we found a legacy but our own shim + * isn't on the user's shell PATH at all. Same "touch nothing" + * behavior, different prose. + * + * This module is intentionally self-contained: no PATH walking, no fs + * mutations, no shell spawning — just rendering. The orchestrator + * (`postinstall.mjs`) makes the abort-or-proceed decision once and + * calls exactly one renderer at the end. + */ + +import { writeFileSync } from 'node:fs'; + +import { pmGlobalInstallCommand, pmGlobalBinCommand } from './reach.mjs'; + +// Fixed-width box rendering. 80 cols is the de facto terminal default. +// We can't reliably read TTY width from a piped postinstall context, so +// we pin the width and truncate long content with an ellipsis if needed. +const BOX_WIDTH = 80; +const BOX_INNER = BOX_WIDTH - 2; // chars between the two vertical borders +const BOX_PAD_LEFT = 2; // leading spaces inside the box for breathing room + +// ANSI styling. Disabled when NO_COLOR is set (https://no-color.org/). +// We can't reliably tell whether `/dev/tty`'s far end supports color, +// but modern terminals all do; users who want plain output can set +// NO_COLOR. +const USE_COLOR = !process.env['NO_COLOR']; +const ANSI_ESCAPE = /\x1b\[[0-9;]*[a-zA-Z]/g; +const C_RESET = USE_COLOR ? '\x1b[0m' : ''; +const C_DIM = USE_COLOR ? '\x1b[2m' : ''; +const C_BOLD_GREEN = USE_COLOR ? '\x1b[1;32m' : ''; +const C_BOLD_YELLOW = USE_COLOR ? '\x1b[1;33m' : ''; +const C_CYAN = USE_COLOR ? '\x1b[36m' : ''; + +function color(c, text) { + return USE_COLOR ? c + text + C_RESET : text; +} + +function visibleLength(s) { + return s.replace(ANSI_ESCAPE, '').length; +} + +function stripAnsi(s) { + return s.replace(ANSI_ESCAPE, ''); +} + +// Platform-specific path to the controlling terminal device. Writing +// here bypasses the package manager's lifecycle stdout capture (npm 7+ +// hides script stdout/stderr by default). On POSIX it's `/dev/tty`; +// on Windows it's the special filename `CON`, which Node resolves to +// the console device. (The fully-qualified `\\.\CON` form looks +// equivalent but Node appends a trailing backslash that breaks the +// open call — confirmed empirically on Windows 11 / Node 22.) +const TERMINAL_DEVICE = process.platform === 'win32' ? 'CON' : '/dev/tty'; + +/** + * Print a user-facing line. npm 7+ captures lifecycle stdout/stderr by + * default, so messages here would be invisible to a user running + * `npm install -g`. Writing directly to the platform's terminal + * device bypasses the manager's capture when one is available + * (interactive terminals). In CI / non-TTY contexts the device isn't + * writable; fall back to stdout so the message is still preserved in + * npm's lifecycle log under `~/.npm/_logs/`, with ANSI stripped so + * the log file stays readable. + */ +export function notify(line) { + const text = line + '\n'; + try { + writeFileSync(TERMINAL_DEVICE, text); + return; + } catch { + // Terminal device not writable (CI, sandboxed environments). + } + process.stdout.write(stripAnsi(text)); +} + +// Single-quote `path` for safe interpolation in a POSIX `sh` command. +// Wraps in single quotes and escapes any embedded `'` as `'\''`. +function quotePosixPath(path) { + return "'" + path.replace(/'/g, "'\\''") + "'"; +} + +// Single-quote `path` for safe interpolation in a PowerShell command. +// PowerShell single-quoted strings disable expansion; embedded `'` is +// escaped by doubling. +function quotePowerShellPath(path) { + return "'" + path.replace(/'/g, "''") + "'"; +} + +function boxBorder(left, right, fill = '─') { + return color(C_DIM, left + fill.repeat(BOX_INNER) + right); +} + +function boxLine(content = '') { + const visible = visibleLength(content); + const padding = + visible < BOX_INNER ? ' '.repeat(BOX_INNER - visible) : ''; + return color(C_DIM, '│') + content + padding + color(C_DIM, '│'); +} + +function pad(content) { + return ' '.repeat(BOX_PAD_LEFT) + content; +} + +function renderBox(lines) { + const out = [boxBorder('╭', '╮'), boxLine('')]; + for (const line of lines) out.push(boxLine(line)); + out.push(boxLine(''), boxBorder('╰', '╯')); + return out; +} + +function emit(lines) { + notify(''); + for (const line of lines) notify(line); + notify(''); +} + +function pathInBox(path) { + // 7-space lead = box pad (2) + prose indent (3) + nesting under + // label (2). We intentionally do NOT truncate overflowing content: + // for command lines (`sudo rm `, `mv `), left-truncation + // would swallow the command verb and leave the user with + // un-copy-pasteable instructions. Long lines just overflow the box + // border, which is visually less pretty but keeps the content + // intact. + const lead = ' '.repeat(BOX_PAD_LEFT + 5); + return lead + color(C_CYAN, path); +} + +function successHeading(text) { + return pad(color(C_BOLD_GREEN, '✓ ' + text)); +} + +function warningHeading(text) { + return pad(color(C_BOLD_YELLOW, '! ' + text)); +} + +/** + * The takeover completed. Renders one box that lists every action + * taken, so the user sees a single coherent picture even when several + * shims were involved. + * + * Sections (each only shown when non-empty): + * - "Renamed" — the first PATH-order shim, preserved as + * `kimi-legacy`. The "`kimi` now launches the new CLI" claim is + * safe to make here because the orchestrator already verified + * reachability after this set of removals. + * - "Consolidated" — first shim's `kimi-legacy` already pointed at + * a legacy file (re-migration case); we deleted the duplicate + * source and kept the existing target. Same end state, different + * mechanism. + * - "Couldn't preserve as kimi-legacy" — first shim's `kimi-legacy` + * slot was a user-managed file; we deleted the source `kimi` to + * remove the shadow but left their file alone, so no fallback + * exists in that dir. + * - "Also removed" — non-first PATH-order shims that would have + * shadowed our new shim. Just `unlink`ed. + * - "Note: legacy left behind" — blocked shims that couldn't be + * removed but PATH order means they don't shadow us; the user + * can clean them up at leisure. + * - "Errors" — anything that failed during execution despite + * pre-flight saying it should work (race conditions, transient + * fs errors). Listed last so the user can see what to retry. + */ +export function logMigrationDone(outcomes, pm) { + const reinstallCmd = pmGlobalInstallCommand(pm, '@moonshot-ai/kimi-code'); + const { + renames, + consolidates, + skippedForeignTarget, + deletes, + blockedHarmless, + errors, + } = outcomes; + + const lines = [successHeading('kimi now runs the new version'), '']; + + if (renames.length > 0) { + lines.push(pad(' Renamed your old kimi so you can still run it as')); + lines.push(pad(' kimi-legacy:')); + for (const c of renames) { + lines.push(pathInBox(c.shimPath + ' -> ' + c.target)); + } + lines.push(''); + } + + if (consolidates.length > 0) { + lines.push(pad(' Removed an extra copy of your old kimi (kimi-legacy')); + lines.push(pad(' was already set up here from before):')); + for (const c of consolidates) { + lines.push(pathInBox(c.shimPath)); + lines.push(pathInBox(' (kimi-legacy is at ' + c.target + ')')); + } + lines.push(''); + } + + if (skippedForeignTarget.length > 0) { + lines.push(pad(' Removed your old kimi (a file you created was already')); + lines.push(pad(' using the name kimi-legacy, so we left it alone):')); + for (const c of skippedForeignTarget) { + lines.push(pathInBox(c.shimPath)); + lines.push(pathInBox(' (your file at ' + c.target + ' is untouched)')); + } + lines.push(''); + } + + if (deletes.length > 0) { + lines.push(pad(' Also removed (these would have run instead of the')); + lines.push(pad(' new kimi if we left them):')); + for (const c of deletes) { + lines.push(pathInBox(c.shimPath)); + } + lines.push(''); + } + + if (blockedHarmless.length > 0) { + lines.push(pad(' Note: we can\'t change these files, but it\'s OK —')); + lines.push(pad(' they won\'t run instead of the new kimi:')); + for (const c of blockedHarmless) { + lines.push(pathInBox(c.shimPath)); + } + lines.push(''); + } + + if (errors.length > 0) { + lines.push(pad(' Some changes didn\'t go through:')); + for (const e of errors) { + lines.push( + pathInBox(e.shimPath + ' (' + (e.message ?? e.code ?? 'error') + ')'), + ); + } + lines.push(''); + } + + // Footer has three branches based on what actually happened: + // + // 1. Preserved somewhere (rename or consolidate): the old CLI is + // available as `kimi-legacy`. Standard takeover footer. + // 2. Only skippedForeignTarget (no rename, no consolidate, no + // blockedHarmless): user has a file they made called + // `kimi-legacy`, so we couldn't save the old CLI under that + // name. Explain the situation honestly. + // 3. Only blockedHarmless (no rename, no consolidate, no + // skippedForeignTarget): we have nothing to celebrate or + // apologize for — the "Note: we can't change these files but + // it's OK" section above already covers it. Plain footer. + // + // If both skippedForeignTarget AND blockedHarmless are present + // (but no preservation), branch 2 wins — the foreign-target story + // is the more useful one for the user to know about. + const preservedSomewhere = renames.length > 0 || consolidates.length > 0; + if (preservedSomewhere) { + lines.push( + pad(' Now typing `kimi` runs the new version. To run the old'), + pad(' version, type `kimi-legacy` instead. Your settings from'), + pad(' the old version will be moved over the first time you'), + pad(' run `kimi`.'), + '', + pad(' Note: if you reinstall the old kimi later (e.g. with'), + pad(' `uv tool`, `pip`, or `pipx`), it will put `kimi` back.'), + pad(' Run this command again to switch to the new one:'), + pathInBox(reinstallCmd), + '', + pad(' If typing `kimi` still runs the old version, open a new'), + pad(' terminal window — your current one may have remembered'), + pad(' the old path.'), + ); + } else if (skippedForeignTarget.length > 0) { + lines.push( + pad(' Now typing `kimi` runs the new version. Your settings'), + pad(' from the old version will be moved over the first time'), + pad(' you run `kimi`.'), + '', + pad(' We couldn\'t save the old kimi as `kimi-legacy` because'), + pad(' that name was already taken by a file you\'d created.'), + pad(' If you need the old kimi back, install it again with'), + pad(' `uv tool install kimi-cli` (or pipx / pip).'), + '', + pad(' If typing `kimi` still runs the old version, open a new'), + pad(' terminal window — your current one may have remembered'), + pad(' the old path.'), + ); + } else { + lines.push( + pad(' Now typing `kimi` runs the new version. Your settings'), + pad(' from the old version will be moved over the first time'), + pad(' you run `kimi`.'), + '', + pad(' If typing `kimi` still runs the old version, open a new'), + pad(' terminal window — your current one may have remembered'), + pad(' the old path.'), + ); + } + + emit(renderBox(lines)); +} + +/** + * At least one blocked legacy shim still sits on PATH ahead of where + * our new shim would land. We refused to touch anything (pre-flight + * abort), so neither the user's existing setup nor the new install + * gets a half-migrated state. List each blocking path with the + * platform-appropriate manual fix. + */ +export function logMigrationBlocked(blocked, actionable, pm) { + const isWindows = process.platform === 'win32'; + const reinstallCmd = pmGlobalInstallCommand(pm, '@moonshot-ai/kimi-code'); + + const lines = [ + warningHeading('Can\'t switch to the new kimi yet'), + '', + pad(' There\'s an old kimi on your computer that we can\'t change.'), + pad(' As long as it\'s there, typing `kimi` will still run the old'), + pad(' version. Files we can\'t change:'), + ]; + + for (const c of blocked) { + lines.push(pathInBox(c.shimPath)); + } + + lines.push('', pad(' Please delete them yourself, then install again:')); + + for (const c of blocked) { + if (isWindows && c.isSystemPath) { + // Admin PowerShell needed. + lines.push(pathInBox('# in an elevated PowerShell:')); + lines.push(pathInBox('Remove-Item ' + quotePowerShellPath(c.shimPath))); + } else if (c.isSystemPath) { + lines.push(pathInBox('sudo rm ' + quotePosixPath(c.shimPath))); + } else if (isWindows) { + lines.push(pathInBox('Remove-Item ' + quotePowerShellPath(c.shimPath))); + } else { + lines.push(pathInBox('rm ' + quotePosixPath(c.shimPath))); + } + } + + if (actionable.length > 0) { + lines.push( + '', + pad(' We also found these old kimi files. We could remove them'), + pad(' ourselves, once the ones above are gone:'), + ); + for (const c of actionable) { + lines.push(pathInBox(c.shimPath)); + } + } + + lines.push( + '', + pad(' After deleting them, install again to finish:'), + pathInBox(reinstallCmd), + '', + pad(' Nothing on your computer was changed.'), + ); + + emit(renderBox(lines)); +} + +/** + * The reachability check found a `kimi` ahead of our shim on PATH + * that's neither ours nor a legacy Python CLI — almost certainly a + * wrapper the user wrote themselves (or installed from somewhere we + * don't recognize). Deleting blocked legacy shims wouldn't help + * here: the foreign file would still win resolution. So the + * remediation is "delete or rename your own file", which only the + * user can decide. + */ +export function logForeignKimiInTheWay(foreignPath, pm) { + const reinstallCmd = pmGlobalInstallCommand(pm, '@moonshot-ai/kimi-code'); + emit( + renderBox([ + warningHeading('Can\'t switch to the new kimi yet'), + '', + pad(' There\'s another file called `kimi` on your computer that\'s'), + pad(' not the new CLI and not the old one — it looks like'), + pad(' something you set up yourself. As long as it\'s there,'), + pad(' typing `kimi` will run it instead of the new version.'), + '', + pad(' We found it at:'), + pathInBox(foreignPath), + '', + pad(' To use the new kimi, delete or rename that file, then'), + pad(' install again:'), + pathInBox(reinstallCmd), + '', + pad(' Nothing on your computer was changed.'), + ]), + ); +} + +/** + * The legacy `kimi` was found, but the directory where the package + * manager placed the new `kimi` shim is not on the user's PATH. + * Renaming the legacy shim now would leave the user with NO reachable + * `kimi` command — the new one would still not be discoverable by + * their shell. Show them the PATH fix and leave the legacy CLI alone. + * + * The PATH-fix hint uses the manager-specific subshell command (one + * of `npm prefix -g`, `yarn global bin`, `pnpm bin -g`) so it works + * regardless of which manager the user ran, and renders in the + * syntax of the user's likely shell: + * - POSIX : `export PATH=...`. + * - Windows: `$env:Path = ...` (PowerShell). + * On Windows, npm places global shims directly under `` (no + * `bin` subdir), and pnpm/yarn already report the bin dir, so we + * skip the `/bin` suffix the POSIX branch needs for npm. + */ +export function logNewCliNotOnPath(detection, pm) { + const isWindows = process.platform === 'win32'; + const binCmd = pmGlobalBinCommand(pm); + const reinstallCmd = pmGlobalInstallCommand(pm, '@moonshot-ai/kimi-code'); + + const newPathHint = isWindows + ? `$env:Path = "$(${binCmd});$env:Path"` + : pm === 'npm' + ? `export PATH="$(${binCmd})/bin:$PATH"` + : `export PATH="$(${binCmd}):$PATH"`; + const rcLabel = isWindows ? 'PowerShell profile' : 'shell rc'; + + emit( + renderBox([ + warningHeading('New kimi is installed, but your terminal can\'t find it'), + '', + pad(' The old kimi is still here:'), + pathInBox(detection.shimPath), + '', + pad(' The new kimi was installed by ' + pm + ', but it landed in a'), + pad(' folder your terminal doesn\'t search. (Your terminal looks'), + pad(' for commands in folders listed in your PATH.) If we removed'), + pad(' the old kimi now, typing `kimi` wouldn\'t find anything.'), + '', + pad(' Add the new kimi\'s folder to your PATH (and save the change'), + pad(' in your ' + rcLabel + ' so it sticks), then install again:'), + pathInBox(newPathHint), + pathInBox(reinstallCmd), + '', + pad(' The old kimi is still where it was.'), + ]), + ); +} diff --git a/apps/kimi-code/scripts/smoke.mjs b/apps/kimi-code/scripts/smoke.mjs new file mode 100644 index 000000000..9c64dd80d --- /dev/null +++ b/apps/kimi-code/scripts/smoke.mjs @@ -0,0 +1,58 @@ +import { execFile } from 'node:child_process'; +import { readFile, stat } from 'node:fs/promises'; +import { fileURLToPath } from 'node:url'; +import { dirname, resolve } from 'node:path'; +import { promisify } from 'node:util'; + +const execFileAsync = promisify(execFile); +const appRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..'); +const bundlePath = resolve(appRoot, 'dist', 'main.mjs'); +const packageJson = JSON.parse(await readFile(resolve(appRoot, 'package.json'), 'utf-8')); +const expectedVersion = packageJson.version; + +function fail(message) { + console.error(message); + process.exit(1); +} + +async function ensureBundleExists() { + try { + await stat(bundlePath); + } catch { + fail(`Bundle not found at ${bundlePath}. Run \`pnpm build\` first.`); + } +} + +async function runBundle(args) { + try { + const { stdout, stderr } = await execFileAsync(process.execPath, [bundlePath, ...args], { + cwd: appRoot, + maxBuffer: 1024 * 1024 * 16, + }); + return `${stdout}${stderr}`; + } catch (error) { + const detail = [error.stdout?.trim(), error.stderr?.trim(), error.message] + .filter(Boolean) + .join('\n'); + fail(`Bundle smoke failed: node ${bundlePath} ${args.join(' ')}\n${detail}`); + } +} + +function assertIncludes(output, expected, command) { + if (!output.includes(expected)) { + fail(`Bundle smoke output for "${command}" did not include "${expected}".\n${output}`); + } +} + +await ensureBundleExists(); + +const versionOutput = await runBundle(['--version']); +assertIncludes(versionOutput, expectedVersion, '--version'); + +const helpOutput = await runBundle(['--help']); +assertIncludes(helpOutput, 'Usage: kimi', '--help'); + +const exportHelpOutput = await runBundle(['export', '--help']); +assertIncludes(exportHelpOutput, 'Usage: kimi export', 'export --help'); + +console.log(`Bundle smoke passed: ${bundlePath}`); diff --git a/apps/kimi-code/src/cli/build-info.ts b/apps/kimi-code/src/cli/build-info.ts new file mode 100644 index 000000000..56c89a5b8 --- /dev/null +++ b/apps/kimi-code/src/cli/build-info.ts @@ -0,0 +1,34 @@ +declare const __KIMI_CODE_VERSION__: string | undefined; +declare const __KIMI_CODE_CHANNEL__: string | undefined; +declare const __KIMI_CODE_COMMIT__: string | undefined; +declare const __KIMI_CODE_BUILD_TARGET__: string | undefined; + +export interface KimiBuildInfo { + readonly version?: string; + readonly channel?: string; + readonly commit?: string; + readonly buildTarget?: string; +} + +function optionalBuildString(value: unknown): string | undefined { + return typeof value === 'string' && value.length > 0 ? value : undefined; +} + +export const KIMI_BUILD_INFO: KimiBuildInfo = { + version: + typeof __KIMI_CODE_VERSION__ === 'string' + ? optionalBuildString(__KIMI_CODE_VERSION__) + : undefined, + channel: + typeof __KIMI_CODE_CHANNEL__ === 'string' + ? optionalBuildString(__KIMI_CODE_CHANNEL__) + : undefined, + commit: + typeof __KIMI_CODE_COMMIT__ === 'string' + ? optionalBuildString(__KIMI_CODE_COMMIT__) + : undefined, + buildTarget: + typeof __KIMI_CODE_BUILD_TARGET__ === 'string' + ? optionalBuildString(__KIMI_CODE_BUILD_TARGET__) + : undefined, +}; diff --git a/apps/kimi-code/src/cli/commands.ts b/apps/kimi-code/src/cli/commands.ts new file mode 100644 index 000000000..b0a65f257 --- /dev/null +++ b/apps/kimi-code/src/cli/commands.ts @@ -0,0 +1,98 @@ +import { Command, Option } from 'commander'; + +import { CLI_COMMAND_NAME } from '#/constant/app'; + +import { registerMigrateCommand } from '#/migration/index'; + +import type { CLIOptions } from './options'; +import { registerExportCommand } from './sub/export'; + +export type MainCommandHandler = (opts: CLIOptions) => void; +export type MigrateCommandHandler = () => void; + +export function createProgram( + version: string, + onMain: MainCommandHandler, + onMigrate: MigrateCommandHandler, +): Command { + const program = new Command(CLI_COMMAND_NAME) + .description('The Starting Point for Next-Gen Agents') + .version(version, '-V, --version') + .allowUnknownOption(false) + .configureHelp({ helpWidth: 100 }) + .helpOption('-h, --help', 'Show help.') + .addHelpText( + 'after', + '\nDocumentation: https://moonshotai.github.io/kimi-code/\n' + ); + + program + .addOption( + new Option( + '-S, --session [id]', + 'Resume a session. With ID: resume that session. Without ID: interactively pick.', + ).argParser((val: string | boolean) => (val === true ? '' : (val as string))), + ) + .addOption( + new Option('-r, --resume [id]') + .hideHelp() + .argParser((val: string | boolean) => (val === true ? '' : (val as string))), + ) + .option('-C, --continue', 'Continue the previous session for the working directory.', false) + .option('-y, --yolo', 'Automatically approve all actions.', false) + .addOption( + new Option( + '-m, --model ', + 'LLM model alias to use for this invocation. Defaults to default_model in config.toml.', + ), + ) + .addOption( + new Option( + '-p, --prompt ', + 'Run one prompt non-interactively and print the response.', + ), + ) + .addOption( + new Option( + '--output-format ', + 'Output format for prompt mode. Defaults to text.', + ).choices(['text', 'stream-json']), + ) + .addOption( + new Option( + '--skills-dir ', + 'Load skills from this directory instead of auto-discovered user and project directories. Can be repeated.', + ) + .argParser((value: string, previous: string[] | undefined) => [...(previous ?? []), value]) + .default([]), + ) + .addOption(new Option('--yes').hideHelp().default(false)) + .addOption(new Option('--auto-approve').hideHelp().default(false)) + .option('--plan', 'Start in plan mode.', false); + + registerExportCommand(program); + registerMigrateCommand(program, onMigrate); + + program.action(() => { + const raw = program.opts>(); + + const rawSession = raw['session'] ?? raw['resume']; + const sessionValue = rawSession === true ? '' : (rawSession as string | undefined); + const yoloValue = raw['yolo'] === true || raw['yes'] === true || raw['autoApprove'] === true; + + const opts: CLIOptions = { + session: sessionValue, + continue: raw['continue'] as boolean, + yolo: yoloValue, + plan: raw['plan'] as boolean, + model: raw['model'] as string | undefined, + outputFormat: raw['outputFormat'] as CLIOptions['outputFormat'], + prompt: raw['prompt'] as string | undefined, + skillsDirs: raw['skillsDir'] as string[], + }; + + onMain(opts); + }); + + return program; +} diff --git a/apps/kimi-code/src/cli/options.ts b/apps/kimi-code/src/cli/options.ts new file mode 100644 index 000000000..67bc86d4f --- /dev/null +++ b/apps/kimi-code/src/cli/options.ts @@ -0,0 +1,58 @@ +export type UIMode = 'shell' | 'print'; +export type PromptOutputFormat = 'text' | 'stream-json'; + +export interface CLIOptions { + session: string | undefined; + continue: boolean; + yolo: boolean; + plan: boolean; + model: string | undefined; + outputFormat: PromptOutputFormat | undefined; + prompt: string | undefined; + skillsDirs: string[]; +} + +export interface ValidatedOptions { + options: CLIOptions; + uiMode: UIMode; +} + +export class OptionConflictError extends Error { + constructor(message: string) { + super(message); + this.name = 'OptionConflictError'; + } +} + +export function validateOptions(opts: CLIOptions): ValidatedOptions { + const prompt = opts.prompt; + const promptMode = prompt !== undefined; + if (promptMode && prompt.trim().length === 0) { + throw new OptionConflictError('Prompt cannot be empty.'); + } + if (opts.model !== undefined && opts.model.trim().length === 0) { + throw new OptionConflictError('Model cannot be empty.'); + } + if (!promptMode && opts.outputFormat !== undefined) { + throw new OptionConflictError('Output format is only supported in prompt mode.'); + } + if (promptMode && opts.yolo) { + throw new OptionConflictError('Cannot combine --prompt with --yolo.'); + } + if (promptMode && opts.plan) { + throw new OptionConflictError('Cannot combine --prompt with --plan.'); + } + if (promptMode && opts.session === '') { + throw new OptionConflictError('Cannot use --session without an id in prompt mode.'); + } + if (opts.continue && opts.session !== undefined) { + throw new OptionConflictError('Cannot combine --continue, --session.'); + } + if (!promptMode && (opts.continue || opts.session !== undefined) && opts.yolo) { + throw new OptionConflictError('Cannot combine --yolo with --continue or --session.'); + } + if (!promptMode && (opts.continue || opts.session !== undefined) && opts.plan) { + throw new OptionConflictError('Cannot combine --plan with --continue or --session.'); + } + return { options: opts, uiMode: promptMode ? 'print' : 'shell' }; +} diff --git a/apps/kimi-code/src/cli/run-prompt.ts b/apps/kimi-code/src/cli/run-prompt.ts new file mode 100644 index 000000000..806d60a6d --- /dev/null +++ b/apps/kimi-code/src/cli/run-prompt.ts @@ -0,0 +1,673 @@ +import { createKimiDeviceId, KIMI_CODE_PROVIDER_NAME } from '@moonshot-ai/kimi-code-oauth'; +import { + initializeTelemetry, + setCrashPhase, + setTelemetryContext, + shutdownTelemetry, + track, + withTelemetryContext, +} from '@moonshot-ai/kimi-telemetry'; +import { + KimiHarness, + log, + type Event, + type HookResultEvent, + type Session, + type SessionStatus, + type TelemetryClient, +} from '@moonshot-ai/kimi-code-sdk'; + +import { CLI_SHUTDOWN_TIMEOUT_MS, CLI_USER_AGENT_PRODUCT } from '#/constant/app'; + +import type { CLIOptions, PromptOutputFormat } from './options'; +import { createKimiCodeHostIdentity } from './version'; + +interface PromptOutput { + readonly columns?: number | undefined; + write(chunk: string): boolean; +} + +interface PromptRunIO { + readonly stdout?: PromptOutput; + readonly stderr?: PromptOutput; + readonly process?: PromptProcess; +} + +interface PromptProcess { + once(signal: NodeJS.Signals, listener: () => Promise): unknown; + off(signal: NodeJS.Signals, listener: () => Promise): unknown; + exit(code?: number): never | void; +} + +const PROMPT_UI_MODE = 'print'; +const PROMPT_MAIN_AGENT_ID = 'main'; +const PROMPT_BLOCK_BULLET = '• '; +const PROMPT_BLOCK_INDENT = ' '; + +export async function runPrompt( + opts: CLIOptions, + version: string, + io: PromptRunIO = {}, +): Promise { + const startedAt = Date.now(); + const stdout = io.stdout ?? process.stdout; + const stderr = io.stderr ?? process.stderr; + const promptProcess = io.process ?? process; + const workDir = process.cwd(); + const telemetryClient: TelemetryClient = { + track, + withContext: withTelemetryContext, + setContext: setTelemetryContext, + }; + const harness = new KimiHarness({ + identity: createKimiCodeHostIdentity(version), + uiMode: PROMPT_UI_MODE, + skillDirs: opts.skillsDirs, + telemetry: telemetryClient, + onOAuthRefresh: (outcome) => { + if (outcome.success) { + track('oauth_refresh', { success: true }); + return; + } + track('oauth_refresh', { success: false, reason: outcome.reason }); + }, + }); + log.info('kimi-code starting', { + version, + uiMode: PROMPT_UI_MODE, + nodeVersion: process.version, + platform: `${process.platform}/${process.arch}`, + workDir, + }); + let restorePromptSessionPermission = async (): Promise => {}; + let removeTerminationCleanup: (() => void) | undefined; + let cleanupPromise: Promise | undefined; + const cleanupPromptRun = async (): Promise => { + cleanupPromise ??= (async () => { + removeTerminationCleanup?.(); + setCrashPhase('shutdown'); + try { + await restorePromptSessionPermission(); + } finally { + await shutdownTelemetry({ timeoutMs: CLI_SHUTDOWN_TIMEOUT_MS }); + await harness.close(); + } + })(); + await cleanupPromise; + }; + removeTerminationCleanup = installPromptTerminationCleanup(promptProcess, cleanupPromptRun); + + try { + await harness.ensureConfigFile(); + const config = await harness.getConfig(); + const { session, resumed, restorePermission, telemetryModel } = await resolvePromptSession( + harness, + opts, + workDir, + config.defaultModel, + stderr, + (restorePermission) => { + restorePromptSessionPermission = restorePermission; + }, + ); + restorePromptSessionPermission = restorePermission; + + const deviceId = createKimiDeviceId(harness.homeDir); + initializeTelemetry({ + homeDir: harness.homeDir, + deviceId, + enabled: config.telemetry !== false, + appName: CLI_USER_AGENT_PRODUCT, + version, + uiMode: PROMPT_UI_MODE, + model: telemetryModel, + getAccessToken: async () => + (await harness.auth.getCachedAccessToken(KIMI_CODE_PROVIDER_NAME)) ?? null, + }); + setCrashPhase('runtime'); + + withTelemetryContext({ sessionId: session.id }).track('started', { + resumed, + yolo: false, + plan: false, + afk: true, + }); + + await runPromptTurn(session, opts.prompt!, opts.outputFormat ?? 'text', stdout, stderr); + stderr.write(`To resume this session: kimi -r ${session.id}\n`); + + withTelemetryContext({ sessionId: session.id }).track('exit', { + duration_s: (Date.now() - startedAt) / 1000, + }); + } finally { + await cleanupPromptRun(); + } +} + +interface ResolvedPromptSession { + readonly session: Session; + readonly resumed: boolean; + readonly restorePermission: () => Promise; + readonly telemetryModel?: string; +} + +async function resolvePromptSession( + harness: KimiHarness, + opts: CLIOptions, + workDir: string, + defaultModel: string | undefined, + stderr: PromptOutput, + setRestorePermission: (restorePermission: () => Promise) => void, +): Promise { + if (opts.session !== undefined) { + const session = await harness.resumeSession({ id: opts.session }); + const status = await session.getStatus(); + const restorePermission = await forcePromptPermission( + session, + status.permission, + setRestorePermission, + ); + if (opts.model !== undefined) { + await session.setModel(opts.model); + } + installHeadlessHandlers(session); + return { + session, + resumed: true, + restorePermission, + telemetryModel: configuredModel(opts.model, status.model, defaultModel), + }; + } + + if (opts.continue) { + const sessions = await harness.listSessions({ workDir }); + const previous = sessions[0]; + if (previous !== undefined) { + const session = await harness.resumeSession({ id: previous.id }); + const status = await session.getStatus(); + const restorePermission = await forcePromptPermission( + session, + status.permission, + setRestorePermission, + ); + if (opts.model !== undefined) { + await session.setModel(opts.model); + } + installHeadlessHandlers(session); + return { + session, + resumed: true, + restorePermission, + telemetryModel: configuredModel(opts.model, status.model, defaultModel), + }; + } + stderr.write(`No sessions to continue under "${workDir}"; starting a fresh session.\n`); + } + + const model = requireConfiguredModel(opts.model, defaultModel); + const session = await harness.createSession({ workDir, model, permission: 'auto' }); + installHeadlessHandlers(session); + return { session, resumed: false, restorePermission: async () => {}, telemetryModel: model }; +} + +async function forcePromptPermission( + session: Session, + previousPermission: SessionStatus['permission'], + setRestorePermission: (restorePermission: () => Promise) => void, +): Promise<() => Promise> { + let overridePermission: Promise | undefined; + const restorePermission = async () => { + await overridePermission?.catch(() => {}); + if (previousPermission !== 'auto') { + await session.setPermission(previousPermission); + } + }; + setRestorePermission(restorePermission); + if (previousPermission !== 'auto') { + overridePermission = session.setPermission('auto'); + await overridePermission; + } + return restorePermission; +} + +function requireConfiguredModel(...models: readonly (string | undefined)[]): string { + const model = configuredModel(...models); + if (model === undefined) { + throw new Error('No model configured. Set default_model in config.toml.'); + } + return model; +} + +function configuredModel(...models: readonly (string | undefined)[]): string | undefined { + return models.find((model) => model !== undefined && model.trim().length > 0); +} + +function installHeadlessHandlers(session: Session): void { + session.setApprovalHandler(() => ({ decision: 'approved' })); + session.setQuestionHandler(() => null); +} + +function installPromptTerminationCleanup( + promptProcess: PromptProcess, + cleanup: () => Promise, +): () => void { + let terminating = false; + const exitAfterCleanup = async (signal: NodeJS.Signals): Promise => { + if (terminating) return; + terminating = true; + try { + await cleanup(); + } finally { + promptProcess.exit(signalExitCode(signal)); + } + }; + const onSigint = () => exitAfterCleanup('SIGINT'); + const onSigterm = () => exitAfterCleanup('SIGTERM'); + promptProcess.once('SIGINT', onSigint); + promptProcess.once('SIGTERM', onSigterm); + return () => { + promptProcess.off('SIGINT', onSigint); + promptProcess.off('SIGTERM', onSigterm); + }; +} + +function signalExitCode(signal: NodeJS.Signals): number { + return signal === 'SIGINT' ? 130 : 143; +} + +function runPromptTurn( + session: Session, + prompt: string, + outputFormat: PromptOutputFormat, + stdout: PromptOutput, + stderr: PromptOutput, +): Promise { + let activeTurnId: number | undefined; + let activeAgentId: string | undefined; + const outputWriter = + outputFormat === 'stream-json' + ? new PromptJsonWriter(stdout) + : new PromptTranscriptWriter(stdout, stderr); + let settled = false; + let unsubscribe: (() => void) | undefined; + + return new Promise((resolve, reject) => { + const finish = (error?: Error): void => { + if (settled) return; + settled = true; + unsubscribe?.(); + outputWriter.finish(); + if (error !== undefined) { + reject(error); + return; + } + resolve(); + }; + + unsubscribe = session.onEvent((event) => { + if (event.type === 'error') { + if (event.agentId !== PROMPT_MAIN_AGENT_ID) { + return; + } + finish(new Error(`${event.code}: ${event.message}`)); + return; + } + if (event.type === 'turn.started' && activeTurnId === undefined) { + if (event.agentId !== PROMPT_MAIN_AGENT_ID) { + return; + } + activeTurnId = event.turnId; + activeAgentId = event.agentId; + return; + } + if ( + activeTurnId === undefined || + activeAgentId === undefined || + !hasTurnId(event) || + event.turnId !== activeTurnId || + event.agentId !== activeAgentId + ) { + return; + } + switch (event.type) { + case 'turn.step.started': + case 'turn.step.interrupted': + outputWriter.flushAssistant(); + return; + case 'turn.step.retrying': + outputWriter.discardAssistant(); + return; + case 'assistant.delta': + outputWriter.writeAssistantDelta(event.delta); + return; + case 'hook.result': + outputWriter.writeHookResult(event); + return; + case 'thinking.delta': + outputWriter.writeThinkingDelta(event.delta); + return; + case 'tool.call.started': + outputWriter.writeToolCall(event.toolCallId, event.name, event.args); + return; + case 'tool.call.delta': + outputWriter.writeToolCallDelta(event.toolCallId, event.name, event.argumentsPart); + return; + case 'tool.result': + outputWriter.writeToolResult(event.toolCallId, event.output); + return; + case 'tool.progress': + if (event.update.text !== undefined && event.update.text.length > 0) { + stderr.write( + event.update.text.endsWith('\n') ? event.update.text : `${event.update.text}\n`, + ); + } + return; + case 'turn.ended': + if (event.reason === 'completed') { + finish(); + return; + } + finish(new Error(formatTurnEndedFailure(event))); + return; + case 'agent.status.updated': + case 'background.task.started': + case 'background.task.terminated': + case 'background.task.updated': + case 'compaction.blocked': + case 'compaction.cancelled': + case 'compaction.completed': + case 'compaction.started': + case 'mcp.server.status': + case 'session.meta.updated': + case 'skill.activated': + case 'subagent.completed': + case 'subagent.failed': + case 'subagent.spawned': + case 'tool.list.updated': + case 'turn.started': + case 'turn.step.completed': + return; + } + }); + + session.prompt(prompt).catch((error: unknown) => { + finish(error instanceof Error ? error : new Error(String(error))); + }); + }); +} + +interface PromptTurnWriter { + writeAssistantDelta(delta: string): void; + writeHookResult(event: HookResultEvent): void; + writeThinkingDelta(delta: string): void; + writeToolCall(toolCallId: string, name: string, args: unknown): void; + writeToolCallDelta( + toolCallId: string, + name: string | undefined, + argumentsPart: string | undefined, + ): void; + writeToolResult(toolCallId: string, output: unknown): void; + flushAssistant(): void; + discardAssistant(): void; + finish(): void; +} + +class PromptTranscriptWriter implements PromptTurnWriter { + private readonly assistantWriter: PromptBlockWriter; + private readonly thinkingWriter: PromptBlockWriter; + + constructor(stdout: PromptOutput, stderr: PromptOutput) { + this.assistantWriter = new PromptBlockWriter(stdout); + this.thinkingWriter = new PromptBlockWriter(stderr); + } + + writeAssistantDelta(delta: string): void { + this.thinkingWriter.finish(); + this.assistantWriter.write(delta); + } + + writeHookResult(event: HookResultEvent): void { + this.thinkingWriter.finish(); + this.assistantWriter.finish(); + this.assistantWriter.write(formatHookResultPlain(event)); + this.assistantWriter.finish(); + } + + writeThinkingDelta(delta: string): void { + this.thinkingWriter.write(delta); + } + + writeToolCall(): void {} + + writeToolCallDelta(): void {} + + writeToolResult(): void {} + + flushAssistant(): void {} + + discardAssistant(): void {} + + finish(): void { + this.thinkingWriter.finish(); + this.assistantWriter.finish(); + } +} + +interface PromptJsonToolCall { + type: 'function'; + id: string; + function: { + name: string; + arguments: string; + }; +} + +interface PromptJsonAssistantMessage { + role: 'assistant'; + content?: string; + tool_calls?: PromptJsonToolCall[]; +} + +interface PromptJsonToolMessage { + role: 'tool'; + tool_call_id: string; + content: string; +} + +class PromptJsonWriter implements PromptTurnWriter { + private assistantText = ''; + private readonly toolCalls: PromptJsonToolCall[] = []; + + constructor(private readonly stdout: PromptOutput) {} + + writeAssistantDelta(delta: string): void { + this.assistantText += delta; + } + + writeHookResult(event: HookResultEvent): void { + this.flushAssistant(); + this.writeJsonLine({ + role: 'assistant', + content: formatHookResultPlain(event), + }); + } + + writeThinkingDelta(): void {} + + writeToolCall(toolCallId: string, name: string, args: unknown): void { + const existing = this.toolCalls.find((toolCall) => toolCall.id === toolCallId); + if (existing !== undefined) { + existing.function.name = name; + existing.function.arguments = stringifyJsonValue(args); + return; + } + this.toolCalls.push({ + type: 'function', + id: toolCallId, + function: { + name, + arguments: stringifyJsonValue(args), + }, + }); + } + + writeToolCallDelta( + toolCallId: string, + name: string | undefined, + argumentsPart: string | undefined, + ): void { + const toolCall = this.findOrCreateToolCall(toolCallId, name ?? ''); + if (name !== undefined) { + toolCall.function.name = name; + } + if (argumentsPart !== undefined) { + toolCall.function.arguments += argumentsPart; + } + } + + writeToolResult(toolCallId: string, output: unknown): void { + this.flushAssistant(); + this.writeJsonLine({ + role: 'tool', + tool_call_id: toolCallId, + content: stringifyToolOutput(output), + }); + } + + flushAssistant(): void { + if (this.assistantText.length === 0 && this.toolCalls.length === 0) return; + const message: PromptJsonAssistantMessage = { + role: 'assistant', + content: this.assistantText.length > 0 ? this.assistantText : undefined, + tool_calls: this.toolCalls.length > 0 ? [...this.toolCalls] : undefined, + }; + this.writeJsonLine(message); + this.discardAssistant(); + } + + discardAssistant(): void { + this.assistantText = ''; + this.toolCalls.length = 0; + } + + finish(): void { + this.flushAssistant(); + } + + private findOrCreateToolCall(toolCallId: string, name: string): PromptJsonToolCall { + const existing = this.toolCalls.find((toolCall) => toolCall.id === toolCallId); + if (existing !== undefined) return existing; + const toolCall: PromptJsonToolCall = { + type: 'function', + id: toolCallId, + function: { + name, + arguments: '', + }, + }; + this.toolCalls.push(toolCall); + return toolCall; + } + + private writeJsonLine(message: PromptJsonAssistantMessage | PromptJsonToolMessage): void { + this.stdout.write(`${JSON.stringify(message)}\n`); + } +} + +class PromptBlockWriter { + private started = false; + private atLineStart = false; + private lineWidth = 0; + private readonly wrapWidth: number | undefined; + + constructor(private readonly output: PromptOutput) { + this.wrapWidth = + typeof output.columns === 'number' && output.columns > PROMPT_BLOCK_INDENT.length + 1 + ? output.columns + : undefined; + } + + write(chunk: string): void { + if (chunk.length === 0) return; + let rendered = this.start(); + for (const char of chunk) { + if (this.atLineStart && char !== '\n') { + rendered += PROMPT_BLOCK_INDENT; + this.atLineStart = false; + this.lineWidth = PROMPT_BLOCK_INDENT.length; + } + const charWidth = visibleCharWidth(char); + if ( + this.wrapWidth !== undefined && + !this.atLineStart && + char !== '\n' && + this.lineWidth + charWidth > this.wrapWidth + ) { + rendered += `\n${PROMPT_BLOCK_INDENT}`; + this.lineWidth = PROMPT_BLOCK_INDENT.length; + } + rendered += char; + if (char === '\n') { + this.atLineStart = true; + this.lineWidth = 0; + } else { + this.lineWidth += charWidth; + } + } + this.output.write(rendered); + } + + finish(): void { + if (!this.started) return; + this.output.write(this.atLineStart ? '\n' : '\n\n'); + this.started = false; + this.atLineStart = false; + this.lineWidth = 0; + } + + private start(): string { + if (this.started) return ''; + this.started = true; + this.atLineStart = false; + this.lineWidth = PROMPT_BLOCK_BULLET.length; + return PROMPT_BLOCK_BULLET; + } +} + +function visibleCharWidth(char: string): number { + return char === '\t' ? 4 : 1; +} + +function formatHookResultPlain(event: HookResultEvent): string { + return `${formatHookResultTitle(event)}\n\n${formatHookResultBody(event)}`; +} + +function formatHookResultTitle(event: HookResultEvent): string { + return `${event.hookEvent} hook${event.blocked === true ? ' blocked' : ''}`; +} + +function formatHookResultBody(event: HookResultEvent): string { + const content = event.content.trim(); + return content.length === 0 ? '(empty)' : content; +} + +function stringifyJsonValue(value: unknown): string { + if (typeof value === 'string') return value; + const json = JSON.stringify(value); + return json ?? ''; +} + +function stringifyToolOutput(output: unknown): string { + if (typeof output === 'string') return output; + const json = JSON.stringify(output); + return json ?? String(output); +} + +function hasTurnId(event: Event): event is Event & { readonly turnId: number } { + return 'turnId' in event; +} + +function formatTurnEndedFailure(event: Extract): string { + if (event.error !== undefined) return `${event.error.code}: ${event.error.message}`; + return `Prompt turn ended with reason: ${event.reason}`; +} diff --git a/apps/kimi-code/src/cli/run-shell.ts b/apps/kimi-code/src/cli/run-shell.ts new file mode 100644 index 000000000..6e3e3204b --- /dev/null +++ b/apps/kimi-code/src/cli/run-shell.ts @@ -0,0 +1,180 @@ +import { execSync } from 'node:child_process'; +import { homedir } from 'node:os'; +import { join } from 'node:path'; + +import { createKimiDeviceId, KIMI_CODE_PROVIDER_NAME } from '@moonshot-ai/kimi-code-oauth'; +import { + initializeTelemetry, + setCrashPhase, + setTelemetryContext, + shutdownTelemetry, + track, + withTelemetryContext, +} from '@moonshot-ai/kimi-telemetry'; +import { KimiHarness, log, type TelemetryClient } from '@moonshot-ai/kimi-code-sdk'; + +import { CLI_SHUTDOWN_TIMEOUT_MS, CLI_UI_MODE, CLI_USER_AGENT_PRODUCT } from '#/constant/app'; +import { detectPendingMigration } from '#/migration/index'; +import type { TuiConfig } from '#/tui/config'; +import { loadTuiConfig, TuiConfigParseError } from '#/tui/config'; +import { CHROME_GUTTER } from '#/tui/constant/rendering'; +import { KimiTUI } from '#/tui/index'; +import { detectTerminalTheme } from '#/tui/theme/detect'; + +import type { CLIOptions } from './options'; +import { createKimiCodeHostIdentity } from './version'; + +export async function runShell( + opts: CLIOptions, + version: string, + runOptions: { readonly migrateOnly?: boolean } = {}, +): Promise { + const startedAt = Date.now(); + const configStartedAt = startedAt; + let tuiConfig: TuiConfig; + let configWarning: string | undefined; + try { + tuiConfig = await loadTuiConfig(); + } catch (error) { + if (!(error instanceof TuiConfigParseError)) throw error; + tuiConfig = error.fallback; + configWarning = error.message; + } + + // Resolve `theme = "auto"` against the live terminal once, before pi-tui + // grabs stdin. Explicit `dark` / `light` skip detection. + const resolvedTheme = tuiConfig.theme === 'auto' ? await detectTerminalTheme() : tuiConfig.theme; + + const workDir = process.cwd(); + const telemetryClient: TelemetryClient = { + track, + withContext: withTelemetryContext, + setContext: setTelemetryContext, + }; + const harness = new KimiHarness({ + identity: createKimiCodeHostIdentity(version), + telemetry: telemetryClient, + onOAuthRefresh: (outcome) => { + if (outcome.success) { + track('oauth_refresh', { success: true }); + return; + } + track('oauth_refresh', { + success: false, + reason: outcome.reason, + }); + }, + }); + log.info('kimi-code starting', { + version, + uiMode: CLI_UI_MODE, + nodeVersion: process.version, + platform: `${process.platform}/${process.arch}`, + workDir, + }); + await harness.ensureConfigFile(); + const migrationPlan = await detectPendingMigration({ + sourceHome: join(homedir(), '.kimi'), + targetHome: harness.homeDir, + ignoreMarker: runOptions.migrateOnly, + }); + if (runOptions.migrateOnly === true && migrationPlan === null) { + process.stdout.write(' Nothing to migrate from ~/.kimi/.\n'); + await harness.close(); + return; + } + const config = await harness.getConfig(); + const configMs = Date.now() - configStartedAt; + const tui = new KimiTUI(harness, { + cliOptions: opts, + tuiConfig, + version, + workDir, + startupNotice: configWarning, + resolvedTheme, + migrationPlan, + migrateOnly: runOptions.migrateOnly, + }); + + let firstLaunch = false; + const deviceId = createKimiDeviceId(harness.homeDir, { + onFirstLaunch: () => { + firstLaunch = true; + }, + }); + initializeTelemetry({ + homeDir: harness.homeDir, + deviceId, + enabled: config.telemetry !== false, + appName: CLI_USER_AGENT_PRODUCT, + version, + uiMode: CLI_UI_MODE, + model: config.defaultModel, + getAccessToken: async () => + (await harness.auth.getCachedAccessToken(KIMI_CODE_PROVIDER_NAME)) ?? null, + }); + setCrashPhase('runtime'); + + if (firstLaunch) { + harness.track('first_launch'); + } + const resumed = opts.continue || opts.session !== undefined; + const trackLifecycleForSession = ( + sessionId: string, + event: string, + properties?: Parameters[1], + ) => { + if (sessionId.length === 0) { + harness.track(event, properties); + return; + } + withTelemetryContext({ sessionId }).track(event, properties); + }; + const trackLifecycle = (event: string, properties?: Parameters[1]) => { + trackLifecycleForSession(tui.getCurrentSessionId(), event, properties); + }; + + tui.onExit = async (exitCode = 0) => { + const sessionId = tui.getCurrentSessionId(); + const hasContent = tui.hasSessionContent(); + setCrashPhase('shutdown'); + trackLifecycle('exit', { duration_s: (Date.now() - startedAt) / 1000 }); + await shutdownTelemetry({ timeoutMs: CLI_SHUTDOWN_TIMEOUT_MS }); + const gutter = ' '.repeat(CHROME_GUTTER); + process.stdout.write(`${gutter}Bye!\n`); + if (sessionId !== '' && hasContent) { + process.stderr.write(`\n${gutter}To resume this session: kimi -r ${sessionId}\n`); + } + process.exit(exitCode); + }; + try { + execSync('stty -ixon', { stdio: 'ignore' }); + } catch { + /* ignore */ + } + try { + const initStartedAt = Date.now(); + await tui.start(); + const initMs = Date.now() - initStartedAt; + trackLifecycle('started', { + resumed, + yolo: opts.yolo, + plan: opts.plan, + afk: false, + }); + const startupSessionId = tui.getCurrentSessionId(); + const mcpMs = await tui.getStartupMcpMs(); + trackLifecycleForSession(startupSessionId, 'startup_perf', { + duration_ms: Date.now() - startedAt, + config_ms: configMs, + init_ms: initMs, + mcp_ms: mcpMs, + }); + } catch (error) { + setCrashPhase('shutdown'); + trackLifecycle('exit', { duration_s: (Date.now() - startedAt) / 1000 }); + await shutdownTelemetry({ timeoutMs: CLI_SHUTDOWN_TIMEOUT_MS }); + await harness.close(); + throw error; + } +} diff --git a/apps/kimi-code/src/cli/startup-error.ts b/apps/kimi-code/src/cli/startup-error.ts new file mode 100644 index 000000000..b40b714f2 --- /dev/null +++ b/apps/kimi-code/src/cli/startup-error.ts @@ -0,0 +1,35 @@ +import { KIMI_ERROR_INFO, isKimiError } from '@moonshot-ai/kimi-code-sdk'; +import { chalkStderr } from 'chalk'; + +import { STARTUP_ERROR_COLOR } from '#/constant/startup-error'; + +export interface StartupErrorFormatOptions { + readonly errorStyle?: (text: string) => string; + readonly operation?: string; +} + +function formatUnknownErrorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +export function formatStartupError( + error: unknown, + options: StartupErrorFormatOptions = {}, +): string { + const errorStyle = options.errorStyle ?? chalkStderr.hex(STARTUP_ERROR_COLOR); + + if (!isKimiError(error)) { + const operation = options.operation ?? 'start shell'; + return `${errorStyle(`error: failed to ${operation}: ${formatUnknownErrorMessage(error)}`)}\n`; + } + + const info = KIMI_ERROR_INFO[error.code]; + const lines = [ + errorStyle(`error: ${info.title}`), + '', + errorStyle('message:'), + errorStyle(error.message), + ]; + + return `${lines.join('\n')}\n`; +} diff --git a/apps/kimi-code/src/cli/sub/export.ts b/apps/kimi-code/src/cli/sub/export.ts new file mode 100644 index 000000000..93608cc3d --- /dev/null +++ b/apps/kimi-code/src/cli/sub/export.ts @@ -0,0 +1,224 @@ +/** + * `kimi export` sub-command. + * + * CLI glue only: session lookup, previous-session confirmation, and output. + * The actual ZIP/manifest export is owned by the SDK. + */ + +import { createInterface } from 'node:readline/promises'; + +import { createKimiDeviceId, KIMI_CODE_PROVIDER_NAME } from '@moonshot-ai/kimi-code-oauth'; +import { + initializeTelemetry, + setTelemetryContext, + shutdownTelemetry, + track, + withTelemetryContext, +} from '@moonshot-ai/kimi-telemetry'; +import { + KimiHarness, + type ExportSessionInput, + type ExportSessionResult, + type SessionSummary, + type TelemetryClient, +} from '@moonshot-ai/kimi-code-sdk'; +import type { Command } from 'commander'; + +import { CLI_SHUTDOWN_TIMEOUT_MS, CLI_UI_MODE, CLI_USER_AGENT_PRODUCT } from '#/constant/app'; +import { createKimiCodeHostIdentity } from '#/cli/version'; + +interface WritableLike { + write(chunk: string): boolean; +} + +export interface PreviousSessionSummary { + readonly workDir: string; + readonly sessionId: string; + readonly sessionDir: string; + readonly title?: string | undefined; +} + +export interface ExportDeps { + readonly listSessions: (workDir: string) => Promise; + readonly exportSession: (input: ExportSessionInput) => Promise; + readonly confirmPreviousSession: (summary: PreviousSessionSummary) => Promise; + readonly version: string; + readonly cwd: () => string; + readonly stdout: WritableLike; + readonly stderr: WritableLike; + readonly exit: (code: number) => never; +} + +export interface ExportOptions { + readonly yes: boolean; + readonly includeGlobalLog: boolean; +} + +export async function handleExport( + deps: ExportDeps, + sessionId: string | undefined, + output: string | undefined, + opts: ExportOptions, +): Promise { + const requestedId = normalizeOptionalSessionId(sessionId); + const previousSummary = requestedId === undefined ? await findPreviousSession(deps) : undefined; + + let resolvedId: string; + if (requestedId !== undefined) { + resolvedId = requestedId; + } else { + if (previousSummary === undefined) { + deps.stderr.write('No previous session found to export.\n'); + deps.exit(1); + } + resolvedId = previousSummary.id; + if (!opts.yes) { + const confirmed = await deps.confirmPreviousSession(toPreviousSessionSummary(previousSummary)); + if (!confirmed) { + deps.stdout.write('Export cancelled.\n'); + return; + } + } + } + + try { + const result = await deps.exportSession({ + id: resolvedId, + version: deps.version, + ...(output === undefined ? {} : { outputPath: output }), + ...(opts.includeGlobalLog ? { includeGlobalLog: true } : {}), + }); + deps.stdout.write(`${result.zipPath}\n`); + } catch (error) { + deps.stderr.write(`${errorMessage(error)}\n`); + deps.exit(1); + } +} + +export function registerExportCommand(parent: Command, deps?: Partial): void { + parent + .command('export') + .description('Export a session as a ZIP archive.') + .option('-o, --output ', 'Output ZIP path.') + .option('-y, --yes', 'Skip previous-session confirmation.') + .option( + '--no-include-global-log', + 'Skip bundling the active global diagnostic log (~/.kimi-code/logs/kimi-code.log, not rotated .1 files). By default the global log is included.', + ) + .argument('[sessionId]', 'Session id to export. Defaults to the most recent session.') + .action( + async ( + sessionId: string | undefined, + options: { output?: string; yes?: boolean; includeGlobalLog?: boolean }, + ) => { + await handleExport(createDefaultExportDeps(deps), sessionId, options.output, { + yes: options.yes === true, + includeGlobalLog: options.includeGlobalLog !== false, + }); + }, + ); +} + +function createDefaultExportDeps(overrides: Partial = {}): ExportDeps { + let harness: KimiHarness | undefined; + let telemetryInitialized = false; + let telemetryShutdown = false; + const identity = createKimiCodeHostIdentity(); + const telemetryClient: TelemetryClient = { + track, + withContext: withTelemetryContext, + setContext: setTelemetryContext, + }; + const getHarness = (): KimiHarness => { + harness ??= new KimiHarness({ + identity, + telemetry: telemetryClient, + }); + return harness; + }; + const initializeDefaultTelemetry = async (): Promise => { + if (telemetryInitialized) return; + const currentHarness = getHarness(); + await currentHarness.ensureConfigFile(); + const config = await currentHarness.getConfig(); + const deviceId = createKimiDeviceId(currentHarness.homeDir); + initializeTelemetry({ + homeDir: currentHarness.homeDir, + deviceId, + enabled: config.telemetry !== false, + appName: CLI_USER_AGENT_PRODUCT, + version: identity.version, + uiMode: CLI_UI_MODE, + model: config.defaultModel, + getAccessToken: async () => + (await currentHarness.auth.getCachedAccessToken(KIMI_CODE_PROVIDER_NAME)) ?? null, + }); + telemetryInitialized = true; + }; + const shutdownDefaultTelemetry = async (): Promise => { + if (!telemetryInitialized || telemetryShutdown) return; + telemetryShutdown = true; + await shutdownTelemetry({ timeoutMs: CLI_SHUTDOWN_TIMEOUT_MS }); + }; + return { + listSessions: + overrides.listSessions ?? + ((workDir: string) => + getHarness().listSessions({ + workDir, + })), + exportSession: + overrides.exportSession ?? + (async (input: ExportSessionInput) => { + await initializeDefaultTelemetry(); + try { + return await getHarness().exportSession(input); + } finally { + await shutdownDefaultTelemetry(); + } + }), + version: overrides.version ?? identity.version, + confirmPreviousSession: overrides.confirmPreviousSession ?? confirmPreviousSession, + cwd: overrides.cwd ?? (() => process.cwd()), + stdout: overrides.stdout ?? process.stdout, + stderr: overrides.stderr ?? process.stderr, + exit: overrides.exit ?? ((code: number) => process.exit(code)), + }; +} + +async function findPreviousSession(deps: Pick): Promise< + SessionSummary | undefined +> { + const sessions = await deps.listSessions(deps.cwd()); + return sessions[0]; +} + +function toPreviousSessionSummary(summary: SessionSummary): PreviousSessionSummary { + return { + workDir: summary.workDir, + sessionId: summary.id, + sessionDir: summary.sessionDir, + ...(summary.title === undefined ? {} : { title: summary.title }), + }; +} + +function normalizeOptionalSessionId(sessionId: string | undefined): string | undefined { + const trimmed = sessionId?.trim(); + return trimmed === undefined || trimmed === '' ? undefined : trimmed; +} + +async function confirmPreviousSession(summary: PreviousSessionSummary): Promise { + const rl = createInterface({ input: process.stdin, output: process.stderr }); + try { + const title = summary.title === undefined ? summary.sessionId : `${summary.title} (${summary.sessionId})`; + const answer = await rl.question(`Export previous session "${title}"? [Y/n] `); + const trimmed = answer.trim().toLowerCase(); + return trimmed === '' || trimmed === 'y' || trimmed === 'yes'; + } finally { + rl.close(); + } +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} diff --git a/apps/kimi-code/src/cli/update/cache.ts b/apps/kimi-code/src/cli/update/cache.ts new file mode 100644 index 000000000..83132bf36 --- /dev/null +++ b/apps/kimi-code/src/cli/update/cache.ts @@ -0,0 +1,31 @@ +import { z } from 'zod'; + +import { getUpdateStateFile } from '#/utils/paths'; +import { readJsonFile, writeJsonFile } from '#/utils/persistence'; + +import { emptyUpdateCache, type UpdateCache } from './types'; + +const UpdateCacheSchema: z.ZodType = z + .object({ + source: z.literal('cdn'), + checkedAt: z.string().min(1).nullable(), + latest: z.string().min(1).nullable(), + }) + .strict(); + +export async function readUpdateCache( + filePath: string = getUpdateStateFile(), +): Promise { + try { + return await readJsonFile(filePath, UpdateCacheSchema, emptyUpdateCache()); + } catch { + return emptyUpdateCache(); + } +} + +export async function writeUpdateCache( + value: UpdateCache, + filePath: string = getUpdateStateFile(), +): Promise { + await writeJsonFile(filePath, UpdateCacheSchema, value); +} diff --git a/apps/kimi-code/src/cli/update/cdn.ts b/apps/kimi-code/src/cli/update/cdn.ts new file mode 100644 index 000000000..5664f021f --- /dev/null +++ b/apps/kimi-code/src/cli/update/cdn.ts @@ -0,0 +1,27 @@ +import { valid } from 'semver'; + +import { KIMI_CODE_CDN_LATEST_URL } from '#/constant/app'; + +/** + * Fetch the latest published Kimi Code version from the CDN. + * + * **Throws** on any failure (network error, non-2xx, empty body, non-semver + * text). Callers must catch — `refreshUpdateCache` deliberately lets the + * error propagate so the existing cache stays intact instead of being + * overwritten with a null `latest` on a transient blip. + * + * `fetchImpl` is injectable for tests; defaults to the global `fetch`. + */ +export async function fetchLatestVersionFromCdn( + fetchImpl: typeof fetch = fetch, +): Promise { + const response = await fetchImpl(KIMI_CODE_CDN_LATEST_URL); + if (!response.ok) { + throw new Error(`CDN /latest returned HTTP ${response.status}`); + } + const raw = (await response.text()).trim(); + if (valid(raw) === null) { + throw new Error(`CDN /latest returned invalid semver: ${JSON.stringify(raw)}`); + } + return raw; +} diff --git a/apps/kimi-code/src/cli/update/preflight.ts b/apps/kimi-code/src/cli/update/preflight.ts new file mode 100644 index 000000000..385f1807e --- /dev/null +++ b/apps/kimi-code/src/cli/update/preflight.ts @@ -0,0 +1,246 @@ +import { spawn } from 'node:child_process'; + +import type { TelemetryProperties } from '@moonshot-ai/kimi-telemetry'; + +import { + NATIVE_INSTALL_COMMAND_UNIX, + NATIVE_INSTALL_COMMAND_WIN, +} from '#/constant/app'; + +import { readUpdateCache } from './cache'; +import { promptForInstallConfirmation, type InstallPromptOptions } from './prompt'; +import { refreshUpdateCache } from './refresh'; +import { selectUpdateTarget } from './select'; +import { detectInstallSource } from './source'; +import { + NPM_PACKAGE_NAME, + type InstallSource, + type UpdateDecision, + type UpdatePreflightResult, + type UpdateTarget, +} from './types'; + +export type { UpdatePreflightResult } from './types'; + +export interface RunUpdatePreflightOptions { + readonly stdout?: { write(chunk: string): boolean }; + readonly stderr?: { write(chunk: string): boolean }; + readonly isTTY?: boolean; + readonly track?: (event: string, properties?: TelemetryProperties) => void; +} + +function withCmdSuffix(base: string, platform: NodeJS.Platform): string { + return platform === 'win32' ? `${base}.cmd` : base; +} + +function bunCommand(platform: NodeJS.Platform): string { + return platform === 'win32' ? 'bun.exe' : 'bun'; +} + +function installCommandFor( + source: InstallSource, + version: string, + platform: NodeJS.Platform, +): string { + switch (source) { + case 'npm-global': + return `npm install -g ${NPM_PACKAGE_NAME}@${version}`; + case 'pnpm-global': + return `pnpm add -g ${NPM_PACKAGE_NAME}@${version}`; + case 'yarn-global': + return `yarn global add ${NPM_PACKAGE_NAME}@${version}`; + case 'bun-global': + return `bun add -g ${NPM_PACKAGE_NAME}@${version}`; + case 'native': + return platform === 'win32' ? NATIVE_INSTALL_COMMAND_WIN : NATIVE_INSTALL_COMMAND_UNIX; + case 'unsupported': + return `npm install -g ${NPM_PACKAGE_NAME}@${version}`; + } +} + +function canAutoInstall(source: InstallSource, platform: NodeJS.Platform): boolean { + switch (source) { + case 'npm-global': + case 'pnpm-global': + case 'yarn-global': + case 'bun-global': + return true; + case 'native': + return platform !== 'win32'; + case 'unsupported': + return false; + } +} + +interface SpawnCommand { + readonly cmd: string; + readonly args: readonly string[]; +} + +function spawnForSource( + source: InstallSource, + version: string, + platform: NodeJS.Platform, +): SpawnCommand { + switch (source) { + case 'npm-global': + return { cmd: withCmdSuffix('npm', platform), args: ['install', '-g', `${NPM_PACKAGE_NAME}@${version}`] }; + case 'pnpm-global': + return { cmd: withCmdSuffix('pnpm', platform), args: ['add', '-g', `${NPM_PACKAGE_NAME}@${version}`] }; + case 'yarn-global': + return { cmd: withCmdSuffix('yarn', platform), args: ['global', 'add', `${NPM_PACKAGE_NAME}@${version}`] }; + case 'bun-global': + return { cmd: bunCommand(platform), args: ['add', '-g', `${NPM_PACKAGE_NAME}@${version}`] }; + case 'native': + return { cmd: 'bash', args: ['-c', NATIVE_INSTALL_COMMAND_UNIX] }; + case 'unsupported': + throw new Error('unsupported install source cannot be auto-installed'); + } +} + +function formatErrorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +function renderManualUpdateMessage( + currentVersion: string, + target: UpdateTarget, + source: InstallSource, + installCommand: string, +): string { + const sourceDesc = + source === 'native' + ? 'native (windows). Auto-update is not supported on this platform.' + : 'unsupported package manager or layout.'; + return ( + `A newer version of ${NPM_PACKAGE_NAME} is available ` + + `(${currentVersion} -> ${target.version}).\n` + + `Detected install source: ${sourceDesc}\n` + + `To update manually, run: ${installCommand}\n` + ); +} + +function renderInstallSuccessMessage(target: UpdateTarget): string { + return `Updated ${NPM_PACKAGE_NAME} to ${target.version}. Restart the CLI to use the new version.\n`; +} + +function refreshInBackground(): void { + void refreshUpdateCache().catch(() => {}); +} + +function trackUpdatePrompted( + track: RunUpdatePreflightOptions['track'], + currentVersion: string, + target: UpdateTarget, + source: InstallSource, + decision: UpdateDecision, +): void { + try { + track?.('update_prompted', { + current: currentVersion, + latest: target.version, + current_version: currentVersion, + target_version: target.version, + source, + decision, + }); + } catch { + // Telemetry must never affect update prompting. + } +} + +async function promptInstall( + currentVersion: string, + target: UpdateTarget, + source: InstallSource, + installCommand: string, +): Promise { + const options: InstallPromptOptions = { + currentVersion, + target, + installSource: source, + installCommand, + }; + return promptForInstallConfirmation(options); +} + +async function installUpdate( + source: InstallSource, + version: string, + platform: NodeJS.Platform, +): Promise { + const { cmd, args } = spawnForSource(source, version, platform); + await new Promise((resolve, reject) => { + const child = spawn(cmd, [...args], { stdio: 'inherit' }); + child.once('error', reject); + child.once('exit', (code, signal) => { + if (code === 0) { + resolve(); + return; + } + const detail = signal !== null ? `signal ${signal}` : `code ${String(code)}`; + reject(new Error(`${cmd} exited with ${detail}`)); + }); + }); +} + +export function decideUpdateAction( + target: UpdateTarget | null, + isInteractive: boolean, + source: InstallSource, + platform: NodeJS.Platform, +): UpdateDecision { + if (target === null || !isInteractive) return 'none'; + return canAutoInstall(source, platform) ? 'prompt-install' : 'manual-command'; +} + +export async function runUpdatePreflight( + currentVersion: string, + options: RunUpdatePreflightOptions = {}, +): Promise { + const stdout = options.stdout ?? process.stdout; + const stderr = options.stderr ?? process.stderr; + const platform = process.platform; + + try { + const cache = await readUpdateCache().catch(() => null); + const latest = cache?.latest ?? null; + const target = selectUpdateTarget(currentVersion, latest); + refreshInBackground(); + + const isInteractive = + options.isTTY ?? (process.stdin.isTTY && process.stdout.isTTY); + const source: InstallSource = + target === null || !isInteractive + ? 'unsupported' + : await detectInstallSource().catch(() => 'unsupported' as const); + + const decision = decideUpdateAction(target, isInteractive, source, platform); + if (decision === 'none' || target === null) return 'continue'; + + const installCommand = installCommandFor(source, target.version, platform); + trackUpdatePrompted(options.track, currentVersion, target, source, decision); + + if (decision === 'manual-command') { + stdout.write(renderManualUpdateMessage(currentVersion, target, source, installCommand)); + return 'continue'; + } + + const confirmed = await promptInstall(currentVersion, target, source, installCommand); + if (!confirmed) return 'continue'; + + try { + await installUpdate(source, target.version, platform); + stdout.write(renderInstallSuccessMessage(target)); + return 'exit'; + } catch (error) { + stderr.write( + `warning: failed to install ${NPM_PACKAGE_NAME}@${target.version}: ` + + `${formatErrorMessage(error)}\n`, + ); + return 'continue'; + } + } catch { + return 'continue'; + } +} diff --git a/apps/kimi-code/src/cli/update/prompt.ts b/apps/kimi-code/src/cli/update/prompt.ts new file mode 100644 index 000000000..4bcd0e3e9 --- /dev/null +++ b/apps/kimi-code/src/cli/update/prompt.ts @@ -0,0 +1,184 @@ +import { clearLine, cursorTo, emitKeypressEvents, moveCursor } from 'node:readline'; + +import chalk from 'chalk'; + +import { PRODUCT_NAME } from '#/constant/app'; +import { HIDE_CURSOR, SHOW_CURSOR } from '#/constant/terminal'; +import { + UPDATE_PROMPT_MUTED, + UPDATE_PROMPT_PRIMARY, + UPDATE_PROMPT_SUCCESS, + UPDATE_PROMPT_TEXT_DIM, + UPDATE_PROMPT_WARNING, +} from '#/constant/update'; + +import { type InstallSource, type UpdateTarget } from './types'; + +export type InstallPromptChoiceValue = 'install' | 'skip'; + +export interface InstallPromptChoice { + readonly value: InstallPromptChoiceValue; + readonly label: string; +} + +export interface InstallPromptOptions { + readonly currentVersion: string; + readonly target: UpdateTarget; + readonly installCommand: string; + readonly installSource: InstallSource; + readonly input?: NodeJS.ReadStream; + readonly output?: NodeJS.WriteStream; +} + +const INSTALL_HINT = 'Install update now'; +const SKIP_HINT = 'Continue with current version'; + +export function createInstallPromptChoices(target: UpdateTarget): readonly InstallPromptChoice[] { + return [ + { value: 'install', label: `${INSTALL_HINT} (${target.version})` }, + { value: 'skip', label: SKIP_HINT }, + ]; +} + +export function getDefaultInstallPromptSelection(choices: readonly InstallPromptChoice[]): number { + const installIndex = choices.findIndex((choice) => choice.value === 'install'); + return Math.max(installIndex, 0); +} + +export function moveInstallPromptSelection( + currentIndex: number, + direction: 'up' | 'down', + choiceCount: number, +): number { + if (direction === 'up') { + return Math.max(0, currentIndex - 1); + } + return Math.min(choiceCount - 1, currentIndex + 1); +} + +function renderInstallPrompt( + options: InstallPromptOptions, + choices: readonly InstallPromptChoice[], + selectedIndex: number, +): readonly string[] { + const label = chalk.hex(UPDATE_PROMPT_TEXT_DIM).bold; + const currentVersion = chalk.hex(UPDATE_PROMPT_WARNING).bold(options.currentVersion); + const targetVersion = chalk.hex(UPDATE_PROMPT_SUCCESS).bold(options.target.version); + const sourceLabel = chalk.hex(UPDATE_PROMPT_PRIMARY).bold(options.installSource); + const command = chalk.hex(UPDATE_PROMPT_PRIMARY)(options.installCommand); + const lines = [ + chalk.hex(UPDATE_PROMPT_PRIMARY).bold('Kimi Code Update Available'), + chalk.hex(UPDATE_PROMPT_MUTED)(`${PRODUCT_NAME} has a newer release ready.`), + '', + `${label('Current')} ${currentVersion}`, + `${label('Target ')} ${targetVersion}`, + `${label('Source ')} ${sourceLabel}`, + `${label('Command')} ${command}`, + '', + chalk.hex(UPDATE_PROMPT_MUTED)('↑↓ choose · Enter confirm · Esc continue'), + '', + ]; + + for (let i = 0; i < choices.length; i++) { + const choice = choices[i]; + if (choice === undefined) continue; + const isSelected = i === selectedIndex; + const pointer = isSelected ? '❯' : ' '; + const content = ` ${pointer} ${choice.label}`; + if (isSelected) { + lines.push(chalk.hex(UPDATE_PROMPT_PRIMARY).bold(content)); + continue; + } + lines.push(chalk.hex(UPDATE_PROMPT_TEXT_DIM)(content)); + } + + return lines; +} + +function writePromptFrame( + output: NodeJS.WriteStream, + lines: readonly string[], + previousLineCount: number, +): number { + if (previousLineCount > 0) { + moveCursor(output, 0, -(previousLineCount - 1)); + } + + for (let i = 0; i < lines.length; i++) { + clearLine(output, 0); + cursorTo(output, 0); + output.write(lines[i] ?? ''); + if (i < lines.length - 1) { + output.write('\n'); + } + } + + return lines.length; +} + +export async function promptForInstallConfirmation( + options: InstallPromptOptions, +): Promise { + const input = options.input ?? process.stdin; + const output = options.output ?? process.stdout; + const choices = createInstallPromptChoices(options.target); + let selectedIndex = getDefaultInstallPromptSelection(choices); + + return new Promise((resolve) => { + let lineCount = 0; + const hadRawMode = 'isRaw' in input ? input.isRaw : false; + const canSetRawMode = typeof input.setRawMode === 'function'; + + const cleanup = (): void => { + input.off('keypress', onKeypress); + if (canSetRawMode) { + input.setRawMode(hadRawMode); + } + output.write(SHOW_CURSOR); + output.write('\n'); + }; + + const finish = (choice: InstallPromptChoiceValue): void => { + cleanup(); + resolve(choice === 'install'); + }; + + const render = (): void => { + lineCount = writePromptFrame( + output, + renderInstallPrompt(options, choices, selectedIndex), + lineCount, + ); + }; + + const onKeypress = (_input: string, key: { name?: string; ctrl?: boolean }): void => { + if (key.name === 'up') { + selectedIndex = moveInstallPromptSelection(selectedIndex, 'up', choices.length); + render(); + return; + } + if (key.name === 'down') { + selectedIndex = moveInstallPromptSelection(selectedIndex, 'down', choices.length); + render(); + return; + } + if (key.name === 'return' || key.name === 'enter') { + const chosen = choices[selectedIndex]?.value ?? 'skip'; + finish(chosen); + return; + } + if (key.name === 'escape' || (key.ctrl === true && key.name === 'c')) { + finish('skip'); + } + }; + + emitKeypressEvents(input); + if (canSetRawMode) { + input.setRawMode(true); + } + input.resume(); + input.on('keypress', onKeypress); + output.write(HIDE_CURSOR); + render(); + }); +} diff --git a/apps/kimi-code/src/cli/update/refresh.ts b/apps/kimi-code/src/cli/update/refresh.ts new file mode 100644 index 000000000..20bcdf0de --- /dev/null +++ b/apps/kimi-code/src/cli/update/refresh.ts @@ -0,0 +1,32 @@ +import { writeUpdateCache } from './cache'; +import { fetchLatestVersionFromCdn } from './cdn'; +import { type UpdateCache } from './types'; + +export interface RefreshUpdateCacheDeps { + /** Resolves with the latest semver. **Throws** on any failure — callers + * (including the default background invocation in preflight) must catch. + * Errors intentionally skip `writeCache` so a transient CDN blip does not + * overwrite a previously known `latest` with `null`. */ + readonly fetchLatest: () => Promise; + readonly writeCache: (cache: UpdateCache) => Promise; + readonly now: () => Date; +} + +export async function refreshUpdateCache( + overrides: Partial = {}, +): Promise { + const resolved: RefreshUpdateCacheDeps = { + fetchLatest: overrides.fetchLatest ?? (() => fetchLatestVersionFromCdn()), + writeCache: overrides.writeCache ?? writeUpdateCache, + now: overrides.now ?? (() => new Date()), + }; + + const latest = await resolved.fetchLatest(); + const cache: UpdateCache = { + source: 'cdn', + checkedAt: resolved.now().toISOString(), + latest, + }; + await resolved.writeCache(cache); + return cache; +} diff --git a/apps/kimi-code/src/cli/update/select.ts b/apps/kimi-code/src/cli/update/select.ts new file mode 100644 index 000000000..bf241ed80 --- /dev/null +++ b/apps/kimi-code/src/cli/update/select.ts @@ -0,0 +1,13 @@ +import { gt, valid } from 'semver'; + +import { type UpdateTarget } from './types'; + +export function selectUpdateTarget( + currentVersion: string, + latest: string | null, +): UpdateTarget | null { + if (latest === null) return null; + if (valid(currentVersion) === null || valid(latest) === null) return null; + if (!gt(latest, currentVersion)) return null; + return { version: latest }; +} diff --git a/apps/kimi-code/src/cli/update/source.ts b/apps/kimi-code/src/cli/update/source.ts new file mode 100644 index 000000000..4b544d140 --- /dev/null +++ b/apps/kimi-code/src/cli/update/source.ts @@ -0,0 +1,155 @@ +import { execFile } from 'node:child_process'; +import { realpathSync } from 'node:fs'; +import { createRequire } from 'node:module'; +import { join, resolve } from 'node:path'; + +import { getHostPackageRoot } from '#/cli/version'; + +import { NPM_PACKAGE_NAME, type InstallSource } from './types'; + +const nodeRequire = createRequire(import.meta.url); + +interface NodeSeaModule { + isSea(): boolean; +} + +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; +} + +/** Runtime SEA detection — true when running as a packaged native binary. */ +export function detectNativeInstall(): boolean { + const sea = loadSeaModule(); + if (sea === null) return false; + try { + return sea.isSea(); + } catch { + return false; + } +} + +// Path heuristic markers (compared in lowercase; both forward and backward slashes accepted). +const PNPM_PATH_SEGMENT = 'pnpm/global/'; +const YARN_PATH_SEGMENTS = ['.config/yarn/global/', '/.yarn/global/']; +const BUN_PATH_SEGMENT = '.bun/install/global/'; + +function normalizeForHeuristic(filePath: string): string { + return filePath.replaceAll('\\', '/').toLowerCase(); +} + +/** + * Heuristic classification by package root path segments. Returns the + * matching `InstallSource` or `null` if no heuristic matches (caller should + * fall through to npm-prefix comparison). + */ +export function classifyByPathHeuristic(packageRoot: string): InstallSource | null { + const normalized = normalizeForHeuristic(packageRoot); + if (normalized.includes(PNPM_PATH_SEGMENT)) return 'pnpm-global'; + for (const seg of YARN_PATH_SEGMENTS) { + if (normalized.includes(seg)) return 'yarn-global'; + } + if (normalized.includes(BUN_PATH_SEGMENT)) return 'bun-global'; + return null; +} + +export interface DetectInstallSourceDeps { + readonly getPackageRoot: () => string; + readonly getGlobalPrefix: () => Promise; + readonly detectNative: () => boolean; + readonly platform: NodeJS.Platform; +} + +function npmCommand(platform: NodeJS.Platform): string { + return platform === 'win32' ? 'npm.cmd' : 'npm'; +} + +function execFileText(command: string, args: readonly string[]): Promise { + return new Promise((resolveOutput, reject) => { + execFile(command, [...args], { encoding: 'utf-8' }, (error, stdout) => { + if (error) { + reject(error); + return; + } + resolveOutput(stdout); + }); + }); +} + +function normalizePathForComparison(filePath: string, platform: NodeJS.Platform): string | null { + const trimmed = filePath.trim(); + if (trimmed.length === 0) return null; + try { + return normalizeResolvedPath(realpathSync(trimmed), platform); + } catch { + return normalizeResolvedPath(resolve(trimmed), platform); + } +} + +function normalizeResolvedPath(filePath: string, platform: NodeJS.Platform): string { + const resolvedPath = resolve(filePath); + return platform === 'win32' ? resolvedPath.toLowerCase() : resolvedPath; +} + +function candidateGlobalPackageDirs( + globalPrefix: string, + platform: NodeJS.Platform, +): readonly string[] { + if (platform === 'win32') { + return [join(globalPrefix, 'node_modules', NPM_PACKAGE_NAME)]; + } + return [ + join(globalPrefix, 'lib', 'node_modules', NPM_PACKAGE_NAME), + join(globalPrefix, 'node_modules', NPM_PACKAGE_NAME), + ]; +} + +export function classifyInstallSource( + packageRoot: string, + globalPrefix: string, + platform: NodeJS.Platform = process.platform, +): InstallSource { + const normalizedPackageRoot = normalizePathForComparison(packageRoot, platform); + if (normalizedPackageRoot === null) return 'unsupported'; + + for (const candidate of candidateGlobalPackageDirs(globalPrefix, platform)) { + if (normalizePathForComparison(candidate, platform) === normalizedPackageRoot) { + return 'npm-global'; + } + } + return 'unsupported'; +} + +export async function detectInstallSource( + deps: Partial = {}, +): Promise { + const platform = deps.platform ?? process.platform; + const resolved: DetectInstallSourceDeps = { + getPackageRoot: deps.getPackageRoot ?? getHostPackageRoot, + getGlobalPrefix: + deps.getGlobalPrefix ?? + (() => execFileText(npmCommand(platform), ['prefix', '-g']).then((text) => text.trim())), + detectNative: deps.detectNative ?? detectNativeInstall, + platform, + }; + + if (resolved.detectNative()) return 'native'; + + const packageRoot = resolved.getPackageRoot(); + const heuristic = classifyByPathHeuristic(packageRoot); + if (heuristic !== null) return heuristic; + + try { + const globalPrefix = await resolved.getGlobalPrefix(); + return classifyInstallSource(packageRoot, globalPrefix, resolved.platform); + } catch { + return 'unsupported'; + } +} diff --git a/apps/kimi-code/src/cli/update/types.ts b/apps/kimi-code/src/cli/update/types.ts new file mode 100644 index 000000000..652fc97d5 --- /dev/null +++ b/apps/kimi-code/src/cli/update/types.ts @@ -0,0 +1,33 @@ +import { NPM_PACKAGE_NAME } from '#/constant/app'; + +export { NPM_PACKAGE_NAME }; + +/** Where the running CLI was installed from. Drives update command + spawn. */ +export type InstallSource = + | 'npm-global' + | 'pnpm-global' + | 'yarn-global' + | 'bun-global' + | 'native' + | 'unsupported'; + +export interface UpdateTarget { + readonly version: string; +} + +export interface UpdateCache { + readonly source: 'cdn'; + readonly checkedAt: string | null; + readonly latest: string | null; +} + +export type UpdateDecision = 'none' | 'prompt-install' | 'manual-command'; +export type UpdatePreflightResult = 'continue' | 'exit'; + +export function emptyUpdateCache(): UpdateCache { + return { + source: 'cdn', + checkedAt: null, + latest: null, + }; +} diff --git a/apps/kimi-code/src/cli/version.ts b/apps/kimi-code/src/cli/version.ts new file mode 100644 index 000000000..f0a9f695d --- /dev/null +++ b/apps/kimi-code/src/cli/version.ts @@ -0,0 +1,63 @@ +/** + * Kimi Code version helpers. + * + * `getVersion` reads the host CLI's `package.json#version`. + */ + +import { existsSync, readFileSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; + +import { createKimiDefaultHeaders, type KimiHostIdentity } from '@moonshot-ai/kimi-code-oauth'; + +import { CLI_USER_AGENT_PRODUCT } from '#/constant/app'; + +import { getDataDir } from '../utils/paths'; +import { KIMI_BUILD_INFO } from './build-info'; + +const MODULE_DIR = import.meta.dirname; + +export function getHostPackageJsonPath(): string { + // Walk upwards from this file's directory until a `package.json` shows up, + // so both dev (`tsx src/main.ts` — this file in `src/cli/`, pkg 2 levels + // up) and prod (`node dist/main.mjs` — this code bundled into `dist/`, + // pkg 1 level up) resolve correctly. + let dir = MODULE_DIR; + for (let i = 0; i < 6; i++) { + const candidate = resolve(dir, 'package.json'); + if (existsSync(candidate)) { + return candidate; + } + const parent = dirname(dir); + if (parent === dir) break; + dir = parent; + } + throw new Error(`Could not locate package.json near ${MODULE_DIR}`); +} + +export function getHostPackageRoot(): string { + return dirname(getHostPackageJsonPath()); +} + +export function getVersion(): string { + if (KIMI_BUILD_INFO.version !== undefined) { + return KIMI_BUILD_INFO.version; + } + const pkg = JSON.parse(readFileSync(getHostPackageJsonPath(), 'utf-8')) as { + version: string; + }; + return pkg.version; +} + +export function createKimiCodeHostIdentity(version = getVersion()): KimiHostIdentity { + return { + userAgentProduct: CLI_USER_AGENT_PRODUCT, + version, + }; +} + +export function buildKimiDefaultHeaders(version: string): Record { + return createKimiDefaultHeaders({ + homeDir: getDataDir(), + ...createKimiCodeHostIdentity(version), + }); +} diff --git a/apps/kimi-code/src/constant/app.ts b/apps/kimi-code/src/constant/app.ts new file mode 100644 index 000000000..d6d37c3a2 --- /dev/null +++ b/apps/kimi-code/src/constant/app.ts @@ -0,0 +1,49 @@ +import { ErrorCodes } from '@moonshot-ai/kimi-code-sdk'; + +export const PRODUCT_NAME = 'Kimi Code'; +export const CLI_COMMAND_NAME = 'kimi'; + +// 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'; + +// Give telemetry a short flush window without making CLI exit feel stuck. +export const CLI_SHUTDOWN_TIMEOUT_MS = 3000; + +// Published npm package name; this can differ from the executable command. +export const NPM_PACKAGE_NAME = '@moonshot-ai/kimi-code'; + +// App-owned data paths. SDK/core runtime config is intentionally not routed here. +export const KIMI_CODE_HOME_ENV = 'KIMI_CODE_HOME'; +export const KIMI_CODE_DATA_DIR_NAME = '.kimi-code'; +export const KIMI_CODE_LOG_DIR_NAME = 'logs'; +export const KIMI_CODE_UPDATE_DIR_NAME = 'updates'; +export const KIMI_CODE_UPDATE_STATE_FILE_NAME = 'latest.json'; +export const KIMI_CODE_INPUT_HISTORY_DIR_NAME = 'user-history'; + +// Managed Kimi auth provider key shared with OAuth/SDK config. +export const DEFAULT_OAUTH_PROVIDER_NAME = 'managed:kimi-code'; + +// SDK/core error code that tells the TUI to show a login-required startup +// notice. Derived from sdk's ErrorCodes so a future rename in core +// auto-propagates instead of silently breaking the startup recovery path. +export const OAUTH_LOGIN_REQUIRED_CODE = ErrorCodes.AUTH_LOGIN_REQUIRED; + +export const FEEDBACK_ISSUE_URL = 'https://github.com/MoonshotAI/kimi-code/issues'; + +// Sent in the feedback `version` field so the backend can distinguish this +// TypeScript client from clients that send a bare version. +export const FEEDBACK_VERSION_PREFIX = 'kimi-code-'; + +// Telemetry event name; keep stable for dashboard queries. +export const FEEDBACK_TELEMETRY_EVENT = 'feedback_submitted'; + +// CDN source of truth: all version checks and native install scripts pull from here. +export const KIMI_CODE_CDN_BASE = 'https://code.kimi.com/kimi-code'; +export const KIMI_CODE_CDN_LATEST_URL = `${KIMI_CODE_CDN_BASE}/latest`; +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`; + +// 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`; +export const NATIVE_INSTALL_COMMAND_WIN = `irm ${KIMI_CODE_INSTALL_PS1_URL} | iex`; diff --git a/apps/kimi-code/src/constant/index.ts b/apps/kimi-code/src/constant/index.ts new file mode 100644 index 000000000..afa11b2a1 --- /dev/null +++ b/apps/kimi-code/src/constant/index.ts @@ -0,0 +1,4 @@ +export * from './app'; +export * from './startup-error'; +export * from './terminal'; +export * from './update'; diff --git a/apps/kimi-code/src/constant/startup-error.ts b/apps/kimi-code/src/constant/startup-error.ts new file mode 100644 index 000000000..a59cb9093 --- /dev/null +++ b/apps/kimi-code/src/constant/startup-error.ts @@ -0,0 +1,2 @@ +// Pre-TUI startup errors render directly to stderr, before theme detection. +export const STARTUP_ERROR_COLOR = '#E85454'; diff --git a/apps/kimi-code/src/constant/terminal.ts b/apps/kimi-code/src/constant/terminal.ts new file mode 100644 index 000000000..22a4dfdfd --- /dev/null +++ b/apps/kimi-code/src/constant/terminal.ts @@ -0,0 +1,8 @@ +// C0/control-sequence bytes used to build terminal protocol messages. +export const ESC = '\u001B'; +export const BEL = '\u0007'; +export const ST = '\\'; + +// ANSI cursor visibility toggles used by CLI prompts. +export const HIDE_CURSOR = `${ESC}[?25l`; +export const SHOW_CURSOR = `${ESC}[?25h`; diff --git a/apps/kimi-code/src/constant/update.ts b/apps/kimi-code/src/constant/update.ts new file mode 100644 index 000000000..9881219cf --- /dev/null +++ b/apps/kimi-code/src/constant/update.ts @@ -0,0 +1,7 @@ +// Local palette for the pre-TUI update prompt. TUI colors still come +// from `src/tui/theme`; do not use these in interactive TUI components. +export const UPDATE_PROMPT_PRIMARY = '#1783ff'; +export const UPDATE_PROMPT_SUCCESS = '#16A34A'; +export const UPDATE_PROMPT_WARNING = '#CA8A04'; +export const UPDATE_PROMPT_MUTED = '#999999'; +export const UPDATE_PROMPT_TEXT_DIM = '#6B7280'; diff --git a/apps/kimi-code/src/main.ts b/apps/kimi-code/src/main.ts new file mode 100644 index 000000000..d2804a939 --- /dev/null +++ b/apps/kimi-code/src/main.ts @@ -0,0 +1,128 @@ +/** + * Kimi Code entry point. + * + * Parses CLI arguments via Commander.js, validates options, runs the + * outer update preflight, then delegates to the requested UI runner. + */ + +import { + flushDiagnosticLogs, + log, + resolveGlobalLogPath, + resolveKimiHome, +} from '@moonshot-ai/kimi-code-sdk'; +import { installCrashHandlers, track } from '@moonshot-ai/kimi-telemetry'; + +import { createProgram } from './cli/commands'; +import type { CLIOptions } from './cli/options'; +import { OptionConflictError, validateOptions } from './cli/options'; +import { runPrompt } from './cli/run-prompt'; +import { runShell } from './cli/run-shell'; +import { formatStartupError } from './cli/startup-error'; +import { runUpdatePreflight } from './cli/update/preflight'; +import { getVersion } from './cli/version'; +import { cleanupStaleNativeCacheForCurrent } from './native/native-assets'; +import { installNativeModuleHook } from './native/module-hook'; +import { runNativeAssetSmokeIfRequested } from './native/smoke'; +import { initProcessName } from './utils/process/proctitle'; + +export async function handleMainCommand(opts: CLIOptions, version: string): Promise { + let validated: ReturnType; + try { + validated = validateOptions(opts); + } catch (error) { + if (error instanceof OptionConflictError) { + process.stderr.write(`error: ${error.message}\n`); + process.exit(1); + } + throw error; + } + + const preflightResult = await runUpdatePreflight( + version, + validated.uiMode === 'print' ? { track, isTTY: false } : { track }, + ); + if (preflightResult === 'exit') { + process.exit(0); + } + + if (validated.uiMode === 'print') { + await runPrompt(validated.options, version); + return; + } + + await runShell(validated.options, version); +} + +/** `kimi migrate`: launch the migration screen only, then exit. */ +async function handleMigrateCommand(version: string): Promise { + await runShell(MIGRATE_CLI_OPTIONS, version, { migrateOnly: true }); +} + +/** A neutral CLIOptions value — `kimi migrate` never opens a chat session. */ +const MIGRATE_CLI_OPTIONS: CLIOptions = { + session: undefined, + continue: false, + yolo: false, + plan: false, + model: undefined, + outputFormat: undefined, + prompt: undefined, + skillsDirs: [], +}; + +export function main(): void { + initProcessName(); + installCrashHandlers(); + installNativeModuleHook(); + if (runNativeAssetSmokeIfRequested()) return; + + // Start the background cleanup of stale native cache. Fire-and-forget; must not block startup or throw. + queueMicrotask(() => { + try { + cleanupStaleNativeCacheForCurrent(); + } catch { + // ignore: cache GC must never affect process startup + } + }); + + const version = getVersion(); + + const program = createProgram( + version, + (opts) => { + void handleMainCommand(opts, version).catch(async (error: unknown) => { + const operation = opts.prompt !== undefined ? 'run prompt' : 'start shell'; + await logStartupFailure(operation, error); + process.stderr.write( + formatStartupError(error, { + operation, + }), + ); + process.stderr.write(`See log: ${resolveGlobalLogPath(resolveKimiHome())}\n`); + process.exit(1); + }); + }, + () => { + void handleMigrateCommand(version).catch(async (error: unknown) => { + await logStartupFailure('run migration', error); + process.stderr.write(formatStartupError(error, { operation: 'run migration' })); + process.stderr.write(`See log: ${resolveGlobalLogPath(resolveKimiHome())}\n`); + process.exit(1); + }); + }, + ); + + program.parse(process.argv); +} + +main(); + +async function logStartupFailure(operation: string, error: unknown): Promise { + log.error('startup failed', { operation, error }); + try { + await flushDiagnosticLogs(); + } catch { + // Best-effort diagnostic flush only. + } +} diff --git a/apps/kimi-code/src/migration/badge.ts b/apps/kimi-code/src/migration/badge.ts new file mode 100644 index 000000000..47b3fabb6 --- /dev/null +++ b/apps/kimi-code/src/migration/badge.ts @@ -0,0 +1,27 @@ +/** + * Pure helpers for composing session labels in the session picker. + * + * Detection rule for the `[imported]` badge: `metadata.imported_from_kimi_cli` + * is strictly the boolean `true`. This mirrors the value written by + * `migration-legacy` into the session's `state.json` `custom` block. + */ + +const IMPORTED_BADGE = '[imported]'; +const IMPORTED_FLAG_KEY = 'imported_from_kimi_cli'; + +export interface SessionLabelInput { + readonly title: string; + readonly metadata?: Readonly> | undefined; +} + +export function isImportedSession( + metadata: Readonly> | undefined, +): boolean { + if (metadata === undefined) return false; + return metadata[IMPORTED_FLAG_KEY] === true; +} + +export function formatSessionLabel(input: SessionLabelInput): string { + const prefix = isImportedSession(input.metadata) ? `${IMPORTED_BADGE} ` : ''; + return `${prefix}${input.title}`; +} diff --git a/apps/kimi-code/src/migration/command.ts b/apps/kimi-code/src/migration/command.ts new file mode 100644 index 000000000..13d02f885 --- /dev/null +++ b/apps/kimi-code/src/migration/command.ts @@ -0,0 +1,19 @@ +/** + * `kimi migrate` sub-command. + * + * A bare, flagless subcommand: it launches the native pi-tui migration screen + * (the same one shown on first launch), then exits. The screen collects the + * migration scope interactively, so there are no CLI options. The actual + * launch is delegated to a host-provided handler. + */ + +import type { Command } from 'commander'; + +export function registerMigrateCommand(parent: Command, onMigrate: () => void): void { + parent + .command('migrate') + .description('Migrate data from a legacy kimi-cli installation into kimi-code.') + .action(() => { + onMigrate(); + }); +} diff --git a/apps/kimi-code/src/migration/detect-pending.ts b/apps/kimi-code/src/migration/detect-pending.ts new file mode 100644 index 000000000..60e2219dd --- /dev/null +++ b/apps/kimi-code/src/migration/detect-pending.ts @@ -0,0 +1,69 @@ +/** + * Pre-TUI detection: decide whether a first-launch migration screen should be + * shown. Cheap, synchronous-ish, no TTY required. Returns the MigrationPlan to + * drive the screen, or null when there is nothing to offer. + */ +import { existsSync, readFileSync } from 'node:fs'; +import { join, resolve } from 'node:path'; + +import { detectMigration, type MigrationPlan } from '@moonshot-ai/migration-legacy'; + +export interface DetectPendingInput { + readonly sourceHome: string; + readonly targetHome: string; + /** + * When true, skip the marker-based suppression (`.migrated-to-kimi-code` / + * `.skip-migration-from-kimi-cli`). The explicit `kimi migrate` command sets + * this so a deliberate invocation always runs regardless of prior runs. + */ + readonly ignoreMarker?: boolean; +} + +export async function detectPendingMigration( + input: DetectPendingInput, +): Promise { + const { sourceHome, targetHome } = input; + if (!existsSync(sourceHome)) return null; + if (input.ignoreMarker !== true) { + if (migrationAlreadyTargeted(join(sourceHome, '.migrated-to-kimi-code'), targetHome)) { + return null; + } + if (existsSync(join(targetHome, '.skip-migration-from-kimi-cli'))) return null; + } + + let plan: MigrationPlan; + try { + plan = await detectMigration({ sourcePath: sourceHome }); + } catch { + // Detection failure must never block startup; skip the screen. + return null; + } + + const nothingToMigrate = + plan.totalSessions === 0 && + !plan.hasConfig && + !plan.hasMcp && + !plan.hasUserHistory && + plan.oauthCredentials.length === 0; + if (nothingToMigrate) return null; + + return plan; +} + +/** + * True when the legacy `.migrated-to-kimi-code` marker records a migration + * into *this* target home. A marker written for a different `KIMI_CODE_HOME` + * must not suppress the prompt — that target has never received migrated data. + * An unreadable/old marker without `target_path` is treated as "matches" + * (conservative: do not re-prompt when the marker exists but is ambiguous). + */ +function migrationAlreadyTargeted(markerPath: string, targetHome: string): boolean { + if (!existsSync(markerPath)) return false; + try { + const parsed = JSON.parse(readFileSync(markerPath, 'utf-8')) as { target_path?: unknown }; + if (typeof parsed.target_path !== 'string') return true; + return resolve(parsed.target_path) === resolve(targetHome); + } catch { + return true; + } +} diff --git a/apps/kimi-code/src/migration/index.ts b/apps/kimi-code/src/migration/index.ts new file mode 100644 index 000000000..fa4a385d2 --- /dev/null +++ b/apps/kimi-code/src/migration/index.ts @@ -0,0 +1,12 @@ +/** + * kimi-cli → kimi-code migration: host integration surface. + * + * Removable glue: the `kimi migrate` sub-command, the first-launch detection, + * the native pi-tui migration screen, and the session-picker `[imported]` + * badge helper. Migration logic itself lives in + * `@moonshot-ai/migration-legacy`. + */ +export { registerMigrateCommand } from './command'; +export { formatSessionLabel, isImportedSession, type SessionLabelInput } from './badge'; +export { detectPendingMigration } from './detect-pending'; +export { MigrationScreenComponent, type MigrationScreenResult } from './migration-screen'; diff --git a/apps/kimi-code/src/migration/migration-screen.ts b/apps/kimi-code/src/migration/migration-screen.ts new file mode 100644 index 000000000..3f9814924 --- /dev/null +++ b/apps/kimi-code/src/migration/migration-screen.ts @@ -0,0 +1,532 @@ +/** + * MigrationScreenComponent — native pi-tui first-launch migration experience. + * + * A single mounted Container & Focusable that runs a 3-phase state machine: + * ask (2-step choice wizard) -> progress -> result + * + * Pure decision mapping (choices -> MigrationScope) is delegated to the + * package's `resolveMigrationScope`. Rendering follows the `ChoicePicker` + * conventions in `apps/kimi-code/src/tui/components/dialogs/choice-picker.ts`. + * + * This file implements the ask, progress, and result phases. `beginMigration` + * drives the real runMigration flow (injectable for tests). + */ +import { Container, matchesKey, Key, truncateToWidth, type Focusable } from '@earendil-works/pi-tui'; +import chalk from 'chalk'; + +import type { ColorPalette } from '#/tui/theme/colors'; +import { + resolveMigrationScope, + runMigration as realRunMigration, + type AnyChoice, + type MigrationPlan, + type MigrationPromptResult, + type MigrationReport, + type MigrationScope, + type Prompt1Choice, + type Prompt2Choice, + type RunMigrationInput, +} from '@moonshot-ai/migration-legacy'; + +type Phase = 'ask1' | 'ask2' | 'progress' | 'result'; + +const SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'] as const; + +/** Spinner frame cadence — one full braille cycle every ~800ms. */ +const SPINNER_INTERVAL_MS = 80; + +const STEP_LABELS: ReadonlyArray = [ + ['config', 'Config'], + ['mcp', 'MCP'], + ['user-history', 'REPL history'], + ['sessions', 'Sessions'], +]; + +export interface MigrationScreenOptions { + readonly plan: MigrationPlan; + readonly sourceHome: string; + readonly targetHome: string; + readonly colors: ColorPalette; + /** Called once the screen is finished; the host then restores the editor. */ + readonly onComplete: (result: MigrationScreenResult) => void; + /** Triggers a re-render; the host wires this to `ui.requestRender()`. */ + readonly requestRender?: () => void; + /** Injectable for tests; defaults to the package's runMigration. */ + readonly runMigration?: (input: RunMigrationInput) => Promise; + /** + * When true, the screen starts at the scope question and skips the + * now/later/never gate — used by the explicit `kimi migrate` command, where + * invoking the command is itself the decision to migrate. + */ + readonly skipDecisionStep?: boolean; +} + +/** What the screen reports back to the host when finished. */ +export interface MigrationScreenResult { + readonly decision: 'now' | 'later' | 'never'; + /** Resolved migration scope; present only when decision === 'now'. */ + readonly scope?: MigrationScope; + // present only when decision === 'now' and migration ran + readonly migrated?: boolean; +} + +interface StepDef { + readonly title: string; + readonly options: ReadonlyArray<{ readonly label: string; readonly value: AnyChoice }>; +} + +export class MigrationScreenComponent extends Container implements Focusable { + focused = false; + private readonly opts: MigrationScreenOptions; + private phase: Phase = 'ask1'; + private selectedIndex = 0; + private readonly choices: AnyChoice[] = []; + private progressDone = 0; + private progressTotal = 0; + private readonly stepStatus = new Map([ + ['config', 'pending'], + ['mcp', 'pending'], + ['user-history', 'pending'], + ['sessions', 'pending'], + ]); + private spinnerFrame = 0; + private spinnerTimer: ReturnType | undefined; + private report: MigrationReport | undefined; + private migrationFailed = false; + + constructor(opts: MigrationScreenOptions) { + super(); + this.opts = opts; + if (opts.skipDecisionStep === true) { + // Explicit `kimi migrate`: the now/later/never gate is meaningless, so + // start at the scope question with the decision already fixed to 'now'. + this.phase = 'ask2'; + this.choices.push('now'); + } + } + + /** Host calls this once runMigration resolves. */ + showResult(report: MigrationReport): void { + this.report = report; + this.phase = 'result'; + this.stopSpinner(); + } + + /** Host calls this if runMigration threw. */ + showFailure(): void { + this.migrationFailed = true; + this.phase = 'result'; + this.stopSpinner(); + } + + /** Host calls this when migration starts. */ + enterProgress(): void { + this.phase = 'progress'; + } + + /** Host wires this to runMigration's onProgress (step-level messages). */ + reportStep(msg: string): void { + // msg is like 'config done', 'mcp done', 'sessions done' + const key = msg.replace(/ done$/, ''); + if (this.stepStatus.has(key)) this.stepStatus.set(key, 'done'); + } + + /** Host wires this to runMigration's onSessionProgress. */ + reportSessionProgress(done: number, total: number): void { + this.progressDone = done; + this.progressTotal = total; + } + + // The braille spinner advances on its own timer so the progress screen stays + // visibly alive even while a single step (e.g. session translation) runs for + // a while without emitting progress events. Runs only for the progress + // phase: started on entering it, stopped the moment it ends. + private startSpinner(): void { + this.stopSpinner(); + this.spinnerTimer = setInterval(() => { + this.spinnerFrame = (this.spinnerFrame + 1) % SPINNER_FRAMES.length; + this.opts.requestRender?.(); + }, SPINNER_INTERVAL_MS); + // A decorative timer must never keep the process alive on its own. + this.spinnerTimer.unref(); + } + + private stopSpinner(): void { + if (this.spinnerTimer !== undefined) { + clearInterval(this.spinnerTimer); + this.spinnerTimer = undefined; + } + } + + // test hooks (thin aliases so tests don't depend on host wiring) + _testEnterProgress(): void { + this.enterProgress(); + } + _testUpdateStep(msg: string): void { + this.reportStep(msg); + } + _testUpdateSessionProgress(done: number, total: number): void { + this.reportSessionProgress(done, total); + } + _testShowResult(report: MigrationReport): void { + this.showResult(report); + } + + handleInput(data: string): void { + if (this.phase === 'ask1' || this.phase === 'ask2') { + this.handleAskInput(data); + return; + } + if (this.phase === 'result') { + if (matchesKey(data, Key.enter)) { + this.opts.onComplete({ decision: 'now', migrated: !this.migrationFailed }); + } + return; + } + // progress phase: ignore input + } + + private currentStep(): StepDef { + return stepFor(this.phase, this.opts.plan); + } + + private handleAskInput(data: string): void { + const step = this.currentStep(); + if (matchesKey(data, Key.up)) { + this.selectedIndex = Math.max(0, this.selectedIndex - 1); + return; + } + if (matchesKey(data, Key.down)) { + this.selectedIndex = Math.min(step.options.length - 1, this.selectedIndex + 1); + return; + } + if (matchesKey(data, Key.escape)) { + // Esc anywhere in ask == "later" + this.opts.onComplete({ decision: 'later' }); + return; + } + if (matchesKey(data, Key.enter)) { + const chosen = step.options[this.selectedIndex]; + if (chosen === undefined) return; + this.advance(chosen.value); + return; + } + } + + /** Apply a chosen value and move the state machine forward. */ + private advance(value: AnyChoice): void { + this.choices.push(value); + this.selectedIndex = 0; + + const result: MigrationPromptResult = resolveMigrationScope(this.choices); + if (this.phase === 'ask1') { + if (value === 'now') { + this.phase = 'ask2'; + return; + } + // 'later' | 'never' + this.opts.onComplete({ decision: value as 'later' | 'never' }); + return; + } + // ask2 — either choice resolves the full scope; run migration immediately. + this.beginMigration(result); + } + + /** Enter the progress phase and run the migration to completion. */ + private beginMigration(result: MigrationPromptResult): void { + if (result.decision !== 'now' || result.scope === undefined) { + this.opts.onComplete({ decision: 'later' }); + return; + } + this.enterProgress(); + this.startSpinner(); + this.opts.requestRender?.(); + const run = this.opts.runMigration ?? realRunMigration; + void run({ + plan: this.opts.plan, + scope: result.scope, + source: this.opts.sourceHome, + target: this.opts.targetHome, + onProgress: (msg) => { + this.reportStep(msg); + this.opts.requestRender?.(); + }, + onSessionProgress: (done, total) => { + this.reportSessionProgress(done, total); + this.opts.requestRender?.(); + }, + }).then( + (report) => { + this.showResult(report); + this.opts.requestRender?.(); + }, + () => { + this.showFailure(); + this.opts.requestRender?.(); + }, + ); + } + + override render(width: number): string[] { + if (this.phase === 'ask1' || this.phase === 'ask2') { + return this.renderAsk(width); + } + if (this.phase === 'progress') return this.renderProgress(width); + return this.renderResult(width); + } + + private renderResult(width: number): string[] { + const { colors } = this.opts; + const lines: string[] = [chalk.hex(colors.primary)('─'.repeat(width))]; + if (this.migrationFailed) { + lines.push(chalk.hex(colors.error).bold(' Migration failed')); + lines.push(''); + lines.push(chalk.hex(colors.text)(' You can retry later by running "kimi migrate".')); + lines.push(''); + lines.push(chalk.hex(colors.textMuted)(' ⏎ continue to kimi-code')); + lines.push(chalk.hex(colors.primary)('─'.repeat(width))); + return lines.map((l) => truncateToWidth(l, width)); + } + const r = this.report; + lines.push(chalk.hex(colors.primary).bold(' Migration complete')); + lines.push(''); + if (r !== undefined) { + const sum = r.summary; + if (sum.sessions.sessionsMigrated > 0) { + lines.push( + chalk.hex(colors.success)(` ✓ ${sum.sessions.sessionsMigrated} sessions migrated`), + ); + } + // Only claim a data class was migrated when the summary says it was — + // a skipped/failed step (e.g. malformed config.toml) must not show ✓. + const migratedKinds: string[] = []; + if (sum.config.migrated) migratedKinds.push('config'); + if (sum.config.migratedHooks > 0) migratedKinds.push('hooks'); + if (sum.mcp.mergedServers.length > 0) migratedKinds.push('MCP'); + if (sum.userHistory.copied > 0) migratedKinds.push('REPL history'); + if (migratedKinds.length > 0) { + lines.push(chalk.hex(colors.success)(` ✓ ${migratedKinds.join(' · ')}`)); + } + if (sum.sessions.sessionsMigrated === 0 && migratedKinds.length === 0) { + lines.push(chalk.hex(colors.textMuted)(' Nothing needed migrating.')); + } + if (r.notices.detectedPlugins.length > 0) { + lines.push( + chalk.hex(colors.warning)( + ` ⚠ ${r.notices.detectedPlugins.length} kimi-cli plugins — not yet supported for migration`, + ), + ); + } + if (r.notices.oauthLoginsRequiringRelogin.length > 0) { + lines.push( + chalk.hex(colors.warning)( + ' ⚠ kimi-cli login not migrated — run /login in kimi-code to sign in', + ), + ); + } + if (sum.config.droppedHooks > 0) { + lines.push( + chalk.hex(colors.warning)( + ` ⚠ ${sum.config.droppedHooks} hooks dropped (incompatible)`, + ), + ); + } + // Conflicts and partial failures: the report records them, so surface + // them here too — otherwise "✓ config / MCP" hides that the data only + // landed in a *.migrated-from-kimi-cli.* sibling or that sessions failed. + if (sum.config.configConflicts.length > 0) { + lines.push( + chalk.hex(colors.warning)( + ` ⚠ ${sum.config.configConflicts.length} config conflicts kept yours: ${sum.config.configConflicts.join(' · ')}`, + ), + ); + } + if (sum.config.wroteSiblingDueToConflict) { + // Sibling mode: the live config.toml could not be parsed, so the + // migrated content went to `config.migrated-from-kimi-cli.toml` and + // the user must merge it by hand. Show the enumeration of contents + // on a SEPARATE line below — a single-line message with the contents + // appended would overflow 80 columns and be truncated, silently + // hiding the very info we want users to see. + lines.push( + chalk.hex(colors.warning)( + ' ⚠ config.toml could not be parsed — review config.migrated-from-kimi-cli.toml', + ), + ); + const sc = sum.config.siblingContents; + const items: string[] = []; + if (sc.providers.length > 0) { + items.push(`${sc.providers.length} provider${sc.providers.length === 1 ? '' : 's'}`); + } + if (sc.models.length > 0) { + items.push(`${sc.models.length} model${sc.models.length === 1 ? '' : 's'}`); + } + if (sc.hooks > 0) { + items.push(`${sc.hooks} hook${sc.hooks === 1 ? '' : 's'}`); + } + if (items.length > 0) { + lines.push(chalk.hex(colors.warning)(` contains: ${items.join(', ')}`)); + } + } + if (sum.config.wroteTuiSibling) { + lines.push( + chalk.hex(colors.warning)( + ' ⚠ tui.toml conflicted — review tui.migrated-from-kimi-cli.toml', + ), + ); + } + if (sum.mcp.wroteSiblingDueToConflict) { + lines.push( + chalk.hex(colors.warning)( + ' ⚠ mcp.json unreadable — review mcp.migrated-from-kimi-cli.json', + ), + ); + } + if (r.notices.mcpOauthServersRequiringReauth.length > 0) { + lines.push( + chalk.hex(colors.warning)( + ` ⚠ ${r.notices.mcpOauthServersRequiringReauth.length} MCP servers need re-authentication`, + ), + ); + } + if (sum.sessions.sessionsFailed.length > 0) { + lines.push( + chalk.hex(colors.warning)( + ` ⚠ ${sum.sessions.sessionsFailed.length} sessions failed to migrate`, + ), + ); + } + if (sum.sessions.sessionsConflicts.length > 0) { + lines.push( + chalk.hex(colors.warning)( + ` ⚠ ${sum.sessions.sessionsConflicts.length} sessions skipped (target already occupied)`, + ), + ); + } + // Empty / user-cleared sessions carry no conversation — neutral info, + // not a failure, so it is shown muted rather than as a ⚠ warning. + if (sum.sessions.sessionsSkippedEmpty > 0) { + lines.push( + chalk.hex(colors.textMuted)( + ` ${sum.sessions.sessionsSkippedEmpty} empty sessions skipped`, + ), + ); + } + lines.push(''); + lines.push( + chalk.hex(colors.textMuted)(' Old data kept at ~/.kimi/ — kimi-cli still works.'), + ); + } + lines.push(''); + lines.push(chalk.hex(colors.textMuted)(' ⏎ continue to kimi-code')); + lines.push(chalk.hex(colors.primary)('─'.repeat(width))); + return lines.map((l) => truncateToWidth(l, width)); + } + + private renderProgress(width: number): string[] { + const { colors } = this.opts; + const spinner = SPINNER_FRAMES[this.spinnerFrame] ?? SPINNER_FRAMES[0]; + const lines: string[] = [ + chalk.hex(colors.primary)('─'.repeat(width)), + chalk.hex(colors.primary).bold(' Migrating from kimi-cli'), + '', + ]; + if (this.progressTotal > 0) { + lines.push( + chalk.hex(colors.accent)(` ${spinner} `) + + chalk.hex(colors.text)( + `Translating sessions… ${this.progressDone} / ${this.progressTotal}`, + ), + ); + lines.push(''); + } + for (const [key, label] of STEP_LABELS) { + const status = this.stepStatus.get(key) ?? 'pending'; + const mark = + status === 'done' + ? chalk.hex(colors.success)('✓') + : chalk.hex(colors.textDim)('◐'); + lines.push(` ${mark} ${chalk.hex(colors.text)(label)}`); + } + lines.push(''); + lines.push(chalk.hex(colors.primary)('─'.repeat(width))); + return lines.map((l) => truncateToWidth(l, width)); + } + + private renderAsk(width: number): string[] { + const { colors } = this.opts; + const step = this.currentStep(); + const lines: string[] = [ + chalk.hex(colors.primary)('─'.repeat(width)), + chalk.hex(colors.primary).bold(' Migrate from kimi-cli'), + '', + ]; + if (this.phase === 'ask1') { + lines.push(chalk.hex(colors.text)(' Found an existing kimi-cli installation:')); + lines.push(chalk.hex(colors.textMuted)(` ${summarizePlan(this.opts.plan)}`)); + lines.push(''); + } + lines.push(chalk.hex(colors.text)(` ${step.title}`)); + lines.push(''); + for (let i = 0; i < step.options.length; i++) { + const opt = step.options[i]!; + const isSel = i === this.selectedIndex; + const pointer = isSel ? '❯' : ' '; + const labelStyle = isSel ? chalk.hex(colors.primary).bold : chalk.hex(colors.text); + lines.push( + chalk.hex(isSel ? colors.primary : colors.textDim)(` ${pointer} `) + + labelStyle(opt.label), + ); + } + lines.push(''); + lines.push( + chalk.hex(colors.textMuted)( + ` ↑/↓ move · ⏎ select · esc ${this.opts.skipDecisionStep === true ? 'cancel' : 'later'}`, + ), + ); + lines.push(chalk.hex(colors.primary)('─'.repeat(width))); + return lines.map((l) => truncateToWidth(l, width)); + } +} + +function summarizePlan(plan: MigrationPlan): string { + const parts: string[] = []; + if (plan.totalSessions > 0) parts.push(`${plan.totalSessions} sessions`); + if (plan.hasConfig) parts.push('config.toml'); + if (plan.hasMcp) parts.push('mcp.json'); + if (plan.hasUserHistory) parts.push('REPL history'); + // OAuth credentials are detected but not migrated (refresh tokens rotate; + // re-login in kimi-code is the right answer). Surface the detection up + // front so an install whose only data is `credentials/*.json` does not + // render this line blank, and so the pre-migration screen stays consistent + // with the result screen's "kimi-cli login not migrated — run /login" line. + if (plan.oauthCredentials.length > 0) parts.push('kimi-cli login (needs /login)'); + return parts.join(' · '); +} + +function stepFor(phase: Phase, plan: MigrationPlan): StepDef { + if (phase === 'ask1') { + return { + title: 'Migrate this data to kimi-code?', + options: [ + { label: 'Migrate now', value: 'now' satisfies Prompt1Choice }, + { label: 'Ask me later', value: 'later' satisfies Prompt1Choice }, + { label: 'Never ask again', value: 'never' satisfies Prompt1Choice }, + ], + }; + } + // ask2 — the second option carries the actual session count so users can see + // the cost they are signing up for. Falls back to the singular "sessions" + // word only (no count) when no sessions were detected. + const sessionsLabel = + plan.totalSessions > 0 + ? `Config + ${plan.totalSessions} sessions` + : 'Config + all sessions'; + return { + title: 'Migrate chat sessions too? (they are bulky and slower)', + options: [ + { label: 'Config only', value: 'config-only' satisfies Prompt2Choice }, + { label: sessionsLabel, value: 'all-sessions' satisfies Prompt2Choice }, + ], + }; +} diff --git a/apps/kimi-code/src/native/module-hook.ts b/apps/kimi-code/src/native/module-hook.ts new file mode 100644 index 000000000..bbef5d4cb --- /dev/null +++ b/apps/kimi-code/src/native/module-hook.ts @@ -0,0 +1,40 @@ +import { createRequire } from 'node:module'; + +import { loadNativePackage } from './native-require'; + +type ModuleLoad = (request: string, parent: unknown, isMain: boolean) => unknown; + +interface ModuleWithLoad { + _load?: ModuleLoad; +} + +const nodeRequire = createRequire(import.meta.url); +let installed = false; +let loadingNativePackage = false; + +export function installNativeModuleHook(): void { + if (installed) return; + installed = true; + + const moduleBuiltin = nodeRequire('node:module') as ModuleWithLoad; + const originalLoad = moduleBuiltin._load; + if (originalLoad === undefined) return; + + moduleBuiltin._load = function loadWithNativeAssets( + this: unknown, + request: string, + parent: unknown, + isMain: boolean, + ): unknown { + if (request === 'koffi' && !loadingNativePackage) { + loadingNativePackage = true; + try { + const pkg = loadNativePackage('koffi'); + if (pkg !== null) return pkg; + } finally { + loadingNativePackage = false; + } + } + return originalLoad.call(this, request, parent, isMain); + }; +} diff --git a/apps/kimi-code/src/native/native-assets.ts b/apps/kimi-code/src/native/native-assets.ts new file mode 100644 index 000000000..576c8079e --- /dev/null +++ b/apps/kimi-code/src/native/native-assets.ts @@ -0,0 +1,371 @@ +import { createHash } from 'node:crypto'; +import { + existsSync, + mkdirSync, + readFileSync, + readdirSync, + renameSync, + rmSync, + statSync, + writeFileSync, +} from 'node:fs'; +import { createRequire } from 'node:module'; +import { homedir } from 'node:os'; +import { dirname, join, win32 as pathWin32 } from 'node:path'; + +import { KIMI_BUILD_INFO } from '#/cli/build-info'; +import { NATIVE_ASSET_MANIFEST_VERSION as MANIFEST_VERSION, buildManifestKey } from '../../scripts/native/manifest.mjs'; + +export const NATIVE_ASSET_MANIFEST_VERSION = MANIFEST_VERSION; + +export interface NativeAssetFile { + readonly assetKey: string; + readonly relativePath: string; + readonly sha256: string; + readonly mode?: number; +} + +export interface NativeAssetPackage { + readonly name: string; + readonly root: string; + readonly files: readonly NativeAssetFile[]; +} + +export interface NativeAssetManifest { + readonly version: typeof NATIVE_ASSET_MANIFEST_VERSION; + readonly target: string; + readonly packages: readonly NativeAssetPackage[]; +} + +export interface NativeAssetSource { + getAssetKeys(): readonly string[]; + getRawAsset(assetKey: string): ArrayBuffer | ArrayBufferView | Buffer | string; +} + +export interface NativeAssetOptions { + readonly source?: NativeAssetSource | null; + readonly manifest?: NativeAssetManifest | null; + readonly cacheBase?: string; + readonly env?: NodeJS.ProcessEnv; + readonly platform?: NodeJS.Platform; + readonly homeDir?: string; + readonly version?: string; +} + +type RawNativeAssetManifest = Omit & { + readonly version: number; +}; + +interface NodeSeaModule { + isSea(): boolean; + getAssetKeys(): string[]; + getRawAsset(assetKey: string): ArrayBuffer; +} + +const nodeRequire = createRequire(import.meta.url); +let seaModule: NodeSeaModule | null | undefined; + +function loadSeaModule(): NodeSeaModule | null { + if (seaModule !== undefined) return seaModule; + try { + seaModule = nodeRequire('node:sea') as NodeSeaModule; + } catch { + seaModule = null; + } + return seaModule; +} + +function currentTarget(): string { + return KIMI_BUILD_INFO.buildTarget ?? `${process.platform}-${process.arch}`; +} + +export function nativeAssetManifestKey(target: string = currentTarget()): string { + return buildManifestKey(target); +} + +function toBuffer(value: ArrayBuffer | ArrayBufferView | Buffer | string): Buffer { + if (Buffer.isBuffer(value)) return value; + if (typeof value === 'string') return Buffer.from(value); + if (ArrayBuffer.isView(value)) { + return Buffer.from(value.buffer, value.byteOffset, value.byteLength); + } + return Buffer.from(value); +} + +function sha256(bytes: Buffer | Uint8Array | string): string { + return createHash('sha256').update(bytes).digest('hex'); +} + +function optionalEnvValue(env: NodeJS.ProcessEnv, key: string): string | null { + const value = env[key]; + return typeof value === 'string' && value.length > 0 ? value : null; +} + +function sanitizeSegment(value: string): string { + const sanitized = value.replaceAll(/[^a-zA-Z0-9._-]/g, '_'); + return sanitized.length > 0 ? sanitized : 'unknown'; +} + +export function getSeaAssetSource(): NativeAssetSource | null { + const sea = loadSeaModule(); + if (sea === null || !sea.isSea()) return null; + return { + getAssetKeys: () => sea.getAssetKeys(), + getRawAsset: (assetKey) => sea.getRawAsset(assetKey), + }; +} + +export function getEmbeddedNativeAssetManifest( + source = getSeaAssetSource(), + target = currentTarget(), +): NativeAssetManifest | null { + if (source === null) return null; + const key = nativeAssetManifestKey(target); + if (!source.getAssetKeys().includes(key)) return null; + const raw = source.getRawAsset(key); + const manifest = JSON.parse(toBuffer(raw).toString('utf-8')) as RawNativeAssetManifest; + if (manifest.version !== NATIVE_ASSET_MANIFEST_VERSION) { + throw new Error(`Unsupported native asset manifest version: ${manifest.version}`); + } + if (manifest.target !== target) { + throw new Error(`Native asset manifest target mismatch: ${manifest.target} !== ${target}`); + } + return manifest as NativeAssetManifest; +} + +export function getNativeCacheBase(options: NativeAssetOptions = {}): string { + if (options.cacheBase !== undefined) return options.cacheBase; + + const env = options.env ?? process.env; + const platform = options.platform ?? process.platform; + const home = options.homeDir ?? homedir(); + + const cacheDirEnv = optionalEnvValue(env, 'KIMI_CODE_CACHE_DIR'); + if (cacheDirEnv !== null) return cacheDirEnv; + + if (platform === 'darwin') return join(home, 'Library', 'Caches', 'kimi-code'); + if (platform === 'win32') { + const localAppData = optionalEnvValue(env, 'LOCALAPPDATA'); + return localAppData !== null + ? pathWin32.join(localAppData, 'kimi-code') + : pathWin32.join(home, 'AppData', 'Local', 'kimi-code', 'Cache'); + } + + return join(optionalEnvValue(env, 'XDG_CACHE_HOME') ?? join(home, '.cache'), 'kimi-code'); +} + +export function getNativeAssetCacheRoot( + manifest: NativeAssetManifest, + options: NativeAssetOptions = {}, +): string { + const version = sanitizeSegment(options.version ?? KIMI_BUILD_INFO.version ?? 'dev'); + const manifestHash = sha256(JSON.stringify(manifest)); + return join( + getNativeCacheBase(options), + 'native', + version, + sanitizeSegment(manifest.target), + manifestHash, + ); +} + +function readFileSha256(path: string): string | null { + try { + return sha256(readFileSync(path)); + } catch { + return null; + } +} + +function ensureFile(path: string, bytes: Buffer, expectedSha256: string, mode?: number): void { + if (readFileSha256(path) === expectedSha256) return; + + mkdirSync(dirname(path), { recursive: true }); + const tempPath = `${path}.${process.pid}.${Date.now()}.tmp`; + writeFileSync(tempPath, bytes, { mode: mode ?? 0o644 }); + + try { + renameSync(tempPath, path); + return; + } catch { + if (readFileSha256(path) === expectedSha256) { + rmSync(tempPath, { force: true }); + return; + } + } + + try { + rmSync(path, { force: true }); + renameSync(tempPath, path); + } catch (error) { + rmSync(tempPath, { force: true }); + if (readFileSha256(path) === expectedSha256) return; + throw error; + } +} + +function ensureEntryFile(cacheRoot: string): void { + const entryPath = join(cacheRoot, 'node_modules', '.kimi-native-entry.cjs'); + ensureFile( + entryPath, + Buffer.from('module.exports = require;\n'), + sha256('module.exports = require;\n'), + 0o644, + ); +} + +export function ensureNativeAssetTree(options: NativeAssetOptions = {}): string | null { + const source = options.source ?? getSeaAssetSource(); + if (source === null) return null; + + const manifest = + options.manifest ?? getEmbeddedNativeAssetManifest(source, currentTarget()); + if (manifest === null) return null; + + const cacheRoot = getNativeAssetCacheRoot(manifest, options); + for (const pkg of manifest.packages) { + for (const file of pkg.files) { + const bytes = toBuffer(source.getRawAsset(file.assetKey)); + const actualSha256 = sha256(bytes); + if (actualSha256 !== file.sha256) { + throw new Error( + `Native asset checksum mismatch for ${file.assetKey}: ${actualSha256} !== ${file.sha256}`, + ); + } + ensureFile(join(cacheRoot, file.relativePath), bytes, file.sha256, file.mode); + } + } + ensureEntryFile(cacheRoot); + return cacheRoot; +} + +export function getNativePackageRoot( + packageName: string, + options: NativeAssetOptions = {}, +): string | null { + const source = options.source ?? getSeaAssetSource(); + if (source === null) return null; + + const manifest = + options.manifest ?? getEmbeddedNativeAssetManifest(source, currentTarget()); + if (manifest === null) return null; + + const pkg = manifest.packages.find((entry) => entry.name === packageName); + if (pkg === undefined) return null; + + const cacheRoot = ensureNativeAssetTree({ ...options, source, manifest }); + return cacheRoot === null ? null : join(cacheRoot, pkg.root); +} + +export function hasNativePackage(packageName: string, manifest: NativeAssetManifest): boolean { + return manifest.packages.some((pkg) => pkg.name === packageName); +} + +export function nativeAssetCacheExists( + packageName: string, + options: NativeAssetOptions = {}, +): boolean { + const root = getNativePackageRoot(packageName, options); + return root !== null && existsSync(root); +} + +export interface CleanupOptions { + readonly cacheBase: string; + readonly version: string; + readonly target: string; + readonly currentRoot: string; +} + +export interface CleanupResult { + readonly kept: string[]; + readonly removed: string[]; + readonly errors: Array<{ path: string; error: unknown }>; +} + +/** + * Remove stale native asset cache directories for the current (version, target). + * + * Keeps: + * - the currentRoot (passed in by caller) + * - the most recently modified sibling (defensive: in case currentRoot calc changed) + * + * Deletes all other sibling directories. Other versions and + * other targets are never touched. Errors per-entry are collected and returned + * (never throw — this is fire-and-forget background work). + */ +export function cleanupStaleNativeCache(options: CleanupOptions): CleanupResult { + const { cacheBase, version, target, currentRoot } = options; + const targetDir = join(cacheBase, 'native', version, target); + const result: CleanupResult = { kept: [], removed: [], errors: [] }; + + let entries: string[]; + try { + entries = readdirSync(targetDir); + } catch { + return result; + } + + const siblings: Array<{ path: string; mtimeMs: number }> = []; + for (const name of entries) { + const path = join(targetDir, name); + try { + const st = statSync(path); + if (!st.isDirectory()) continue; + siblings.push({ path, mtimeMs: st.mtimeMs }); + } catch (error) { + (result.errors as Array<{ path: string; error: unknown }>).push({ path, error }); + } + } + + if (siblings.length === 0) return result; + + // sort newest first + siblings.sort((a, b) => b.mtimeMs - a.mtimeMs); + // Defensive: keep the most recently modified sibling that is not currentRoot + // so a previously-written cache survives in case currentRoot calc changed. + const mostRecentOther = siblings.find((entry) => entry.path !== currentRoot)?.path; + const keepSet = new Set( + mostRecentOther === undefined ? [currentRoot] : [currentRoot, mostRecentOther], + ); + + for (const { path } of siblings) { + if (keepSet.has(path)) { + result.kept.push(path); + continue; + } + try { + rmSync(path, { recursive: true, force: true }); + result.removed.push(path); + } catch (error) { + (result.errors as Array<{ path: string; error: unknown }>).push({ path, error }); + } + } + + return result; +} + +/** + * Convenience: discover currentRoot from embedded manifest + run cleanup. + * Safe to call without args from main.ts startup. Returns null if not in SEA mode. + */ +export function cleanupStaleNativeCacheForCurrent( + options: NativeAssetOptions = {}, +): CleanupResult | null { + const source = options.source ?? getSeaAssetSource(); + if (source === null) return null; + + const manifest = + options.manifest ?? getEmbeddedNativeAssetManifest(source, currentTarget()); + if (manifest === null) return null; + + const cacheBase = getNativeCacheBase(options); + const version = KIMI_BUILD_INFO.version ?? 'dev'; + const currentRoot = getNativeAssetCacheRoot(manifest, options); + + return cleanupStaleNativeCache({ + cacheBase, + version, + target: manifest.target, + currentRoot, + }); +} diff --git a/apps/kimi-code/src/native/native-require.ts b/apps/kimi-code/src/native/native-require.ts new file mode 100644 index 000000000..6840d610b --- /dev/null +++ b/apps/kimi-code/src/native/native-require.ts @@ -0,0 +1,30 @@ +import { createRequire } from 'node:module'; +import { join } from 'node:path'; + +import { + ensureNativeAssetTree, + getNativePackageRoot, + type NativeAssetOptions, +} from './native-assets'; + +export function createNativePackageRequire( + packageName: string, + options: NativeAssetOptions = {}, +): ReturnType | null { + const packageRoot = getNativePackageRoot(packageName, options); + if (packageRoot === null) return null; + + const cacheRoot = ensureNativeAssetTree(options); + if (cacheRoot === null) return null; + + return createRequire(join(cacheRoot, 'node_modules', '.kimi-native-entry.cjs')); +} + +export function loadNativePackage( + packageName: string, + options: NativeAssetOptions = {}, +): T | null { + const nativeRequire = createNativePackageRequire(packageName, options); + if (nativeRequire === null) return null; + return nativeRequire(packageName) as T; +} diff --git a/apps/kimi-code/src/native/smoke.ts b/apps/kimi-code/src/native/smoke.ts new file mode 100644 index 000000000..56d39253f --- /dev/null +++ b/apps/kimi-code/src/native/smoke.ts @@ -0,0 +1,26 @@ +import { getEmbeddedNativeAssetManifest, getNativePackageRoot } from './native-assets'; + +const smokePackages = ['@mariozechner/clipboard', 'koffi']; + +export function runNativeAssetSmokeIfRequested(): boolean { + if (process.env['KIMI_CODE_NATIVE_ASSET_SMOKE'] !== '1') return false; + + try { + const manifest = getEmbeddedNativeAssetManifest(); + if (manifest === null) { + throw new Error('Native asset manifest is not available.'); + } + for (const packageName of smokePackages) { + const packageRoot = getNativePackageRoot(packageName, { manifest }); + if (packageRoot === null) { + throw new Error(`Native package is not available: ${packageName}`); + } + } + process.stdout.write(`Native asset smoke passed: ${manifest.target}\n`); + process.exit(0); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + process.stderr.write(`Native asset smoke failed: ${message}\n`); + process.exit(1); + } +} diff --git a/apps/kimi-code/src/tui/actions/replay-ops.ts b/apps/kimi-code/src/tui/actions/replay-ops.ts new file mode 100644 index 000000000..8b11a2c7a --- /dev/null +++ b/apps/kimi-code/src/tui/actions/replay-ops.ts @@ -0,0 +1,699 @@ +/** + * Session replay hydration. + * + * Core owns durable history as raw session records. The TUI projects those + * records into the same transcript entries/components used by live events, + * without mutating core session state or responding to replayed data. + */ + +import type { + AgentReplayRecord, + BackgroundTaskInfo, + ContentPart, + ContextMessage, + PromptOrigin, + PermissionMode, + ResumedAgentState, + Session, + ToolCall, +} from '@moonshot-ai/kimi-code-sdk'; + +import { AgentGroupComponent } from '#/tui/components/messages/agent-group'; +import type { TodoItem } from '#/tui/components/chrome/todo-panel'; +import { ToolCallComponent } from '#/tui/components/messages/tool-call'; +import type { TUIState } from '#/tui/kimi-tui'; +import type { + AppState, + BackgroundAgentMetadata, + BackgroundAgentStatusData, + ToolCallBlockData, + TranscriptEntry, +} from '#/tui/types'; +import { formatErrorMessage, isTodoItemShape } from '#/tui/utils/event-payload'; +import { formatBackgroundAgentTranscript } from '#/tui/utils/background-agent-status'; +import { mediaUrlPartToText } from '#/tui/utils/media-url'; +import { nextTranscriptId } from '#/tui/utils/transcript-id'; + +export interface ReplayHydrationHooks { + readonly setAppState: (patch: Partial) => void; + readonly appendEntry: (entry: TranscriptEntry) => void; + readonly setTodoList: (todos: readonly TodoItem[]) => void; + readonly emitError: (message: string) => void; +} + +interface ReplayProjection { + readonly entries: readonly TranscriptEntry[]; + /** + * Background subagents still not completed or failed when replay ends, + * keyed by agent_id. `hydrateTranscriptFromReplay` seeds + * `state.backgroundAgents` from this so the footer badge starts accurate. + */ + readonly backgroundAgents: ReadonlySet; + /** + * Background agent metadata that remains needed after replay so live + * terminal events can keep rendering transcript copy after resume. + */ + readonly backgroundAgentMetadata: ReadonlyMap; +} + +interface OpenAssistant { + thinking: string[]; + text: string[]; +} + +interface ProjectionState { + entries: TranscriptEntry[]; + toolCalls: Map; + assistant: OpenAssistant; + skillActivationIds: Set; + permissionMode?: PermissionMode; + backgroundAgents: Set; + backgroundAgentMetadata: Map; + backgroundTasks: ReadonlyMap; +} + +type BackgroundTaskOrigin = Extract; + +const REPLAY_TURN_LIMIT = 10; + +export async function hydrateTranscriptFromReplay( + state: TUIState, + hooks: ReplayHydrationHooks, + session: Session, +): Promise { + hooks.setAppState({ isReplaying: true }); + try { + const main = session.getResumeState()?.agents['main']; + if (main === undefined) { + hooks.emitError('Session history is unavailable for this session.'); + return false; + } + + const projection = projectReplayRecords(main.replay, main.background); + hydrateProjectedEntries(state, projection.entries, hooks.appendEntry); + hydrateTodoPanelFromResume(main, hooks); + state.backgroundAgents = new Set(projection.backgroundAgents); + state.backgroundAgentMetadata = new Map(projection.backgroundAgentMetadata); + + // Seed the BPM-derived store from the resume snapshot. This is the + // authoritative source for footer count + transcript dedupe; the + // subagent-derived `backgroundAgents` set above is kept for legacy + // metadata lookups (agent name / description) until removed. + state.backgroundTasks = new Map( + main.background.map((info) => [info.taskId, info]), + ); + state.backgroundTaskTranscriptedTerminal.clear(); + // Resumed terminal tasks should not re-emit transcript cards. + for (const info of main.background) { + if ( + info.status === 'completed' || + info.status === 'failed' || + info.status === 'killed' || + info.status === 'lost' + ) { + state.backgroundTaskTranscriptedTerminal.add(info.taskId); + } + } + const counts = countActiveBackgroundTasks(state.backgroundTasks); + state.footer.setBackgroundCounts(counts); + hooks.setAppState(appStateFromResumeAgent(main)); + return true; + } catch (error) { + const message = formatErrorMessage(error); + hooks.emitError(`Failed to replay session history: ${message}`); + return false; + } finally { + hooks.setAppState({ isReplaying: false }); + } +} + +function hydrateTodoPanelFromResume( + agent: ResumedAgentState, + hooks: ReplayHydrationHooks, +): void { + const rawTodos = agent.toolStore?.['todo']; + if (!Array.isArray(rawTodos)) { + hooks.setTodoList([]); + return; + } + const todos = rawTodos + .filter((todo): todo is TodoItem => isTodoItemShape(todo)) + .map((todo) => ({ title: todo.title, status: todo.status })); + hooks.setTodoList(todos); +} + +function countActiveBackgroundTasks(tasks: ReadonlyMap): { + bashTasks: number; + agentTasks: number; +} { + let bashTasks = 0; + let agentTasks = 0; + for (const info of tasks.values()) { + if ( + info.status === 'completed' || + info.status === 'failed' || + info.status === 'killed' || + info.status === 'lost' + ) { + continue; + } + if (info.taskId.startsWith('agent-')) { + agentTasks += 1; + } else { + bashTasks += 1; + } + } + return { bashTasks, agentTasks }; +} + +function appStateFromResumeAgent(agent: ResumedAgentState): Partial { + const maxContextTokens = agent.config.modelCapabilities?.max_context_tokens ?? 0; + const contextTokens = agent.context.tokenCount; + const contextUsage = maxContextTokens > 0 ? contextTokens / maxContextTokens : 0; + return { + // `?? ''` so a resumed session with no resolvable model yields a string, + // not `undefined` — the editor's `appState.model.trim()` would otherwise + // throw. An empty model surfaces the normal "LLM not set" state. + model: agent.config.modelAlias ?? agent.config.provider?.model ?? '', + contextTokens, + maxContextTokens, + contextUsage, + planMode: agent.plan !== null, + yolo: agent.permission.mode === 'yolo', + permissionMode: agent.permission.mode, + }; +} + +export function projectReplayRecords( + records: readonly AgentReplayRecord[], + backgroundTasks: readonly BackgroundTaskInfo[] = [], +): ReplayProjection { + const state: ProjectionState = { + entries: [], + toolCalls: new Map(), + assistant: { thinking: [], text: [] }, + skillActivationIds: new Set(), + backgroundAgents: new Set(), + backgroundAgentMetadata: new Map(), + backgroundTasks: new Map(backgroundTasks.map((info) => [info.taskId, info])), + }; + + for (const record of limitReplayRecordsByTurn(records, REPLAY_TURN_LIMIT)) { + projectReplayRecord(state, record); + } + flushAssistant(state); + + return { + entries: state.entries, + backgroundAgents: state.backgroundAgents, + backgroundAgentMetadata: state.backgroundAgentMetadata, + }; +} + +function limitReplayRecordsByTurn( + records: readonly AgentReplayRecord[], + maxTurns: number, +): readonly AgentReplayRecord[] { + if (maxTurns <= 0) return []; + + const turnStarts = records.flatMap((record, index) => + isReplayUserTurnRecord(record) ? [index] : [], + ); + if (turnStarts.length <= maxTurns) return records; + + return records.slice(turnStarts[turnStarts.length - maxTurns]); +} + +function isReplayUserTurnRecord(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 'background_task': + case 'compaction_summary': + case 'hook_result': + case 'injection': + case 'system_trigger': + return false; + } +} + +function projectReplayRecord(state: ProjectionState, record: AgentReplayRecord): void { + switch (record.type) { + case 'message': + projectContextMessage(state, record.message); + return; + case 'plan_updated': + flushAssistant(state); + state.entries.push(entry('status', `Plan mode: ${record.enabled ? 'ON' : 'OFF'}`, 'notice')); + return; + case 'permission_updated': + flushAssistant(state); + projectPermissionUpdate(state, record.mode); + return; + case 'approval_result': { + flushAssistant(state); + const { record: approvalRecord } = record; + const { result } = approvalRecord; + const parts: string[] = []; + switch (result.decision) { + case 'approved': + parts.push(result.scope === 'session' ? 'Approved for session' : 'Approved'); + break; + case 'rejected': + parts.push('Rejected'); + break; + case 'cancelled': + parts.push('Cancelled'); + break; + } + parts.push(`: ${approvalRecord.action}`); + if (result.feedback !== undefined && result.feedback.length > 0) { + parts.push(` — "${result.feedback}"`); + } + state.entries.push(entry('status', parts.join(''), 'notice')); + return; + } + case 'config_updated': + return; + } +} + +function projectPermissionUpdate(state: ProjectionState, mode: PermissionMode): void { + if (mode === 'yolo') { + state.entries.push( + entry('status', 'YOLO mode: ON', 'notice', { + detail: 'All actions will be approved automatically. Use with caution.', + }), + ); + state.permissionMode = mode; + return; + } + if (state.permissionMode === 'yolo' && mode === 'manual') { + state.entries.push(entry('status', 'YOLO mode: OFF', 'notice')); + state.permissionMode = mode; + return; + } + state.entries.push(entry('status', `Permission mode: ${mode}`, 'notice')); + state.permissionMode = mode; +} + +interface SkillActivationProjection { + readonly activationId: string; + readonly skillName: string; + readonly skillArgs?: string; +} + +function projectSkillActivation( + state: ProjectionState, + skill: SkillActivationProjection | undefined, +): void { + if (skill === undefined) return; + if (state.skillActivationIds.has(skill.activationId)) return; + state.skillActivationIds.add(skill.activationId); + state.entries.push( + entry('skill_activation', `Activated skill: ${skill.skillName}`, 'plain', { + skillActivationId: skill.activationId, + skillName: skill.skillName, + skillArgs: skill.skillArgs, + }), + ); +} + +function projectContextMessage(state: ProjectionState, message: ContextMessage): void { + switch (message.role) { + case 'user': { + const origin = backgroundOrigin(message); + if (origin !== undefined) { + flushAssistant(state); + projectBackgroundTaskNotification(state, origin); + return; + } + if (message.origin?.kind === 'hook_result') { + projectHookResultMessage(state, message); + return; + } + if (message.origin?.kind === 'injection') { + return; + } + flushAssistant(state); + const skill = skillActivationFromOrigin(message.origin); + if (skill !== undefined) { + projectSkillActivation(state, skill); + return; + } + state.entries.push(entry('user', contentPartsToText(message.content), 'plain')); + return; + } + case 'assistant': + if (message.origin?.kind === 'hook_result') { + projectHookResultMessage(state, message); + projectMessageToolCalls(state, message.toolCalls); + return; + } + collectMessageContent(state.assistant, message.content); + flushAssistant(state); + projectMessageToolCalls(state, message.toolCalls); + return; + case 'tool': + flushAssistant(state); + projectMessageToolResult(state, message); + return; + case 'system': + return; + default: + return; + } +} + +function projectMessageToolCalls(state: ProjectionState, toolCalls: readonly ToolCall[]): void { + for (const rawToolCall of toolCalls) { + const toolCall = toolCallFromMessage(rawToolCall); + if (toolCall === undefined) continue; + state.toolCalls.set(toolCall.id, toolCall); + state.entries.push( + entry('tool_call', '', 'plain', { + toolCallData: toolCall, + }), + ); + } +} + +function projectMessageToolResult(state: ProjectionState, message: ContextMessage): void { + const toolCallId = message.toolCallId; + if (toolCallId === undefined) return; + const call = state.toolCalls.get(toolCallId); + if (call === undefined) return; + call.result = { + tool_call_id: toolCallId, + output: toolResultOutput(message.content), + is_error: message.isError, + }; +} + +function projectBackgroundTaskNotification( + state: ProjectionState, + origin: BackgroundTaskOrigin, +): void { + const task = state.backgroundTasks.get(origin.taskId); + const meta: BackgroundAgentMetadata = { + agentId: origin.taskId, + parentToolCallId: origin.taskId, + description: task?.description, + }; + let status = formatBackgroundAgentTranscript( + origin.status === 'completed' ? 'completed' : 'failed', + meta, + ); + if (origin.status === 'lost') { + status = { + ...status, + headline: status.headline.replace(' failed in background', ' lost in background'), + }; + } else if (origin.status === 'killed') { + status = { + ...status, + headline: status.headline.replace(' failed in background', ' stopped'), + }; + } + state.entries.push( + entry('status', status.headline, 'plain', { + detail: status.detail, + backgroundAgentStatus: status, + }), + ); + state.backgroundAgents.delete(meta.agentId); + state.backgroundAgentMetadata.delete(meta.agentId); +} + +function toolResultOutput(content: readonly ContentPart[]): string { + if (content.some((part) => part.type !== 'text')) { + return JSON.stringify(content); + } + return contentPartsToText(content); +} + +function flushAssistant(state: ProjectionState): void { + const thinking = state.assistant.thinking.join(''); + const text = state.assistant.text.join(''); + state.assistant = { thinking: [], text: [] }; + if (thinking.length > 0) { + state.entries.push(entry('thinking', thinking, 'plain')); + } + if (text.length > 0) { + state.entries.push(entry('assistant', text, 'markdown')); + } +} + +function projectHookResultMessage(state: ProjectionState, message: ContextMessage): void { + if (message.origin?.kind !== 'hook_result') return; + flushAssistant(state); + state.entries.push( + entry( + 'assistant', + formatHookResultMessageForTranscript( + contentPartsToText(message.content), + message.origin.event, + message.origin.blocked === true, + ), + 'markdown', + ), + ); +} + +const HOOK_RESULT_RE = + /\n?([\s\S]*?)\n?<\/hook_result>/g; + +function formatHookResultMessageForTranscript( + text: string, + fallbackEvent: string, + blocked: boolean, +): string { + const results: Array<{ event: string; body: string }> = []; + let lastIndex = 0; + + for (const match of text.matchAll(HOOK_RESULT_RE)) { + if (text.slice(lastIndex, match.index).trim().length > 0) { + return formatHookResultBlock(fallbackEvent, text, blocked); + } + const event = match[1]; + const body = match[2]; + if (event === undefined || body === undefined) { + return formatHookResultBlock(fallbackEvent, text, blocked); + } + results.push({ event, body }); + lastIndex = match.index + match[0].length; + } + + if (results.length === 0 || text.slice(lastIndex).trim().length > 0) { + return formatHookResultBlock(fallbackEvent, text, blocked); + } + + return results.map(({ event, body }) => formatHookResultBlock(event, body, blocked)).join('\n\n'); +} + +function formatHookResultBlock(event: string, body: string, blocked: boolean): string { + return `*${event} hook${blocked ? ' blocked' : ''}*\n\n${body.trim() || '(empty)'}`; +} + +function collectMessageContent(target: OpenAssistant, content: readonly ContentPart[]): void { + for (const part of content) { + switch (part.type) { + case 'think': + target.thinking.push(part.think); + break; + case 'text': + target.text.push(part.text); + break; + case 'audio_url': + case 'image_url': + case 'video_url': + break; + } + } +} + +function toolCallFromMessage(rawToolCall: ToolCall): ToolCallBlockData | undefined { + const id = rawToolCall.id; + const name = rawToolCall.function.name; + if (id.length === 0 || name.length === 0) return undefined; + return { + id, + name, + args: parseToolArguments(rawToolCall.function.arguments), + }; +} + +function parseToolArguments(value: string | null): Record { + if (value === null || value.length === 0) return {}; + try { + const parsed = JSON.parse(value); + return isObject(parsed) ? parsed : {}; + } catch { + return {}; + } +} + +function entry( + kind: TranscriptEntry['kind'], + content: string, + renderMode: TranscriptEntry['renderMode'], + extras?: { + turnId?: string; + toolCallData?: ToolCallBlockData; + detail?: string; + color?: string; + backgroundAgentStatus?: BackgroundAgentStatusData; + skillActivationId?: string; + skillName?: string; + skillArgs?: string; + }, +): TranscriptEntry { + return { + id: nextTranscriptId(), + kind, + renderMode, + content, + turnId: extras?.turnId, + detail: extras?.detail, + color: extras?.color, + toolCallData: extras?.toolCallData, + backgroundAgentStatus: extras?.backgroundAgentStatus, + skillActivationId: extras?.skillActivationId, + skillName: extras?.skillName, + skillArgs: extras?.skillArgs, + }; +} + +function contentPartsToText(content: readonly ContentPart[]): string { + return content.map(userPartToText).join(''); +} + +function backgroundOrigin(message: ContextMessage): BackgroundTaskOrigin | undefined { + return message.origin?.kind === 'background_task' ? message.origin : undefined; +} + +function userPartToText(part: ContentPart): string { + switch (part.type) { + case 'text': + return part.text; + case 'think': + return part.think; + case 'image_url': + return mediaUrlPartToText('image', part.imageUrl.url); + case 'video_url': + return mediaUrlPartToText('video', part.videoUrl.url); + case 'audio_url': + return mediaUrlPartToText('audio', part.audioUrl.url); + } +} + +function skillActivationFromOrigin( + origin: PromptOrigin | undefined, +): SkillActivationProjection | undefined { + if (origin?.kind !== 'skill_activation') return undefined; + return { + activationId: origin.activationId, + skillName: origin.skillName, + skillArgs: origin.skillArgs, + }; +} + +function isObject(value: unknown): value is Record { + return typeof value === 'object' && value !== null; +} + +/** + * Inject projected flat entries into live state. Adjacent Agent tool_call + * entries sharing `(turnId, step)` are grouped into an AgentGroupComponent so + * replay matches live behavior. Other entries use the original append path. + * + * Unlike `tryAttachAgentToolCall`, this does not write + * `state.pendingAgentGroup`; after replay, live events must take over from a + * clean pending group state. + */ +export function hydrateProjectedEntries( + state: TUIState, + entries: readonly TranscriptEntry[], + appendEntry: (entry: TranscriptEntry) => void, +): void { + let i = 0; + while (i < entries.length) { + const cur = entries[i]; + if (cur === undefined) { + i += 1; + continue; + } + if (cur.kind === 'skill_activation' && cur.skillActivationId !== undefined) { + if (state.renderedSkillActivationIds.has(cur.skillActivationId)) { + i += 1; + continue; + } + state.renderedSkillActivationIds.add(cur.skillActivationId); + } + const tc = cur.toolCallData; + if ( + cur.kind === 'tool_call' && + tc !== undefined && + tc.name === 'Agent' && + tc.step !== undefined + ) { + // Collect all adjacent Agent calls with the same step and turn id. + const batch: TranscriptEntry[] = [cur]; + let j = i + 1; + while (j < entries.length) { + const next = entries[j]; + if (next === undefined) break; + const nextTc = next.toolCallData; + if ( + next.kind === 'tool_call' && + nextTc !== undefined && + nextTc.name === 'Agent' && + nextTc.step === tc.step && + nextTc.turnId === tc.turnId + ) { + batch.push(next); + j++; + continue; + } + break; + } + if (batch.length >= 2) { + attachAgentBatchAsGroup(state, batch); + i = j; + continue; + } + // A single Agent stays on the standalone card path. + } + appendEntry(cur); + i++; + } +} + +function attachAgentBatchAsGroup(state: TUIState, batch: readonly TranscriptEntry[]): void { + const group = new AgentGroupComponent(state.theme.colors, state.ui); + state.transcriptContainer.addChild(group); + for (const item of batch) { + const tc = item.toolCallData; + if (tc === undefined) continue; + state.transcriptEntries.push(item); + const component = new ToolCallComponent( + tc, + tc.result, + state.theme.colors, + state.ui, + state.theme.markdownTheme, + state.appState.workDir, + ); + if (state.toolOutputExpanded) component.setExpanded(true); + if (state.planExpanded) component.setPlanExpanded(true); + state.pendingToolComponents.set(tc.id, component); + group.attach(tc.id, component); + } + state.ui.requestRender(); +} diff --git a/apps/kimi-code/src/tui/commands/index.ts b/apps/kimi-code/src/tui/commands/index.ts new file mode 100644 index 000000000..fab43f75c --- /dev/null +++ b/apps/kimi-code/src/tui/commands/index.ts @@ -0,0 +1,5 @@ +export * from './parse'; +export * from './registry'; +export * from './resolve'; +export * from './skills'; +export * from './types'; diff --git a/apps/kimi-code/src/tui/commands/parse.ts b/apps/kimi-code/src/tui/commands/parse.ts new file mode 100644 index 000000000..ee19d3585 --- /dev/null +++ b/apps/kimi-code/src/tui/commands/parse.ts @@ -0,0 +1,12 @@ +import type { ParsedSlashInput } from './types'; + +export function parseSlashInput(input: string): ParsedSlashInput | null { + if (!input.startsWith('/')) return null; + const trimmed = input.slice(1).trim(); + if (trimmed.length === 0) return null; + const spaceIdx = trimmed.indexOf(' '); + const name = spaceIdx === -1 ? trimmed : trimmed.slice(0, spaceIdx); + const args = spaceIdx === -1 ? '' : trimmed.slice(spaceIdx + 1).trim(); + if (name.includes('/')) return null; + return { name, args }; +} diff --git a/apps/kimi-code/src/tui/commands/registry.ts b/apps/kimi-code/src/tui/commands/registry.ts new file mode 100644 index 000000000..5195bf5e7 --- /dev/null +++ b/apps/kimi-code/src/tui/commands/registry.ts @@ -0,0 +1,181 @@ +import type { KimiSlashCommand, SlashCommandAvailability } from './types'; + +export const BUILTIN_SLASH_COMMANDS = [ + { + name: 'yolo', + aliases: ['yes'], + description: 'Toggle auto-approve mode', + priority: 100, + availability: 'always', + }, + { + name: 'permission', + aliases: [], + description: 'Select permission mode', + priority: 100, + availability: 'always', + }, + { + name: 'settings', + aliases: ['config'], + description: 'Open TUI settings', + priority: 100, + availability: 'always', + }, + { + name: 'plan', + aliases: [], + description: 'Toggle plan mode', + priority: 100, + availability: (args) => (args.trim().toLowerCase() === 'clear' ? 'idle-only' : 'always'), + }, + { + name: 'model', + aliases: [], + description: 'Switch LLM model', + priority: 100, + availability: 'always', + }, + { + name: 'help', + aliases: ['h', '?'], + description: 'Show available commands and shortcuts', + priority: 80, + availability: 'always', + }, + { + name: 'new', + aliases: ['clear'], + description: 'Start a fresh session in the current workspace', + priority: 80, + }, + { + name: 'sessions', + aliases: ['resume'], + description: 'Browse and resume sessions', + priority: 80, + availability: 'always', + }, + { + name: 'tasks', + aliases: ['task'], + description: 'Browse background tasks', + priority: 80, + availability: 'always', + }, + { + name: 'mcp', + aliases: [], + description: 'Show MCP server status', + priority: 60, + availability: 'always', + }, + { + name: 'compact', + aliases: [], + description: 'Compact the conversation context', + priority: 80, + }, + { + name: 'init', + aliases: [], + description: 'Analyze the codebase and generate AGENTS.md', + }, + { + name: 'fork', + aliases: [], + description: 'Fork the current session', + priority: 80, + }, + { + name: 'title', + aliases: ['rename'], + description: 'Set or show session title', + priority: 60, + availability: 'always', + }, + { + name: 'usage', + aliases: [], + description: 'Show session tokens + context window + plan quotas', + priority: 60, + availability: 'always', + }, + { + name: 'status', + aliases: [], + description: 'Show current session and runtime status', + priority: 60, + availability: 'always', + }, + { + name: 'feedback', + aliases: [], + description: 'Send feedback to make Kimi Code better', + priority: 60, + availability: 'always', + }, + { + name: 'editor', + aliases: [], + description: 'Set the external editor for Ctrl-G', + priority: 60, + availability: 'always', + }, + { + name: 'theme', + aliases: [], + description: 'Set the terminal UI theme', + priority: 60, + availability: 'always', + }, + { + name: 'logout', + aliases: [], + description: 'Clear credentials for the current platform', + priority: 40, + }, + { + name: 'login', + aliases: [], + description: 'Select a platform and authenticate', + priority: 40, + }, + { + name: 'exit', + aliases: ['quit', 'q'], + description: 'Exit the application', + priority: 20, + }, + { + name: 'version', + aliases: [], + description: 'Show version information', + priority: 20, + availability: 'always', + }, +] as const satisfies readonly KimiSlashCommand[]; + +export type BuiltinSlashCommand = (typeof BUILTIN_SLASH_COMMANDS)[number]; +export type BuiltinSlashCommandName = BuiltinSlashCommand['name']; + +export function findBuiltInSlashCommand(commandName: string): BuiltinSlashCommand | undefined { + const commands = BUILTIN_SLASH_COMMANDS as readonly KimiSlashCommand[]; + return commands.find( + (command) => command.name === commandName || command.aliases.includes(commandName), + ) as BuiltinSlashCommand | undefined; +} + +export function resolveSlashCommandAvailability( + command: KimiSlashCommand, + args: string, +): SlashCommandAvailability { + const availability = command.availability ?? 'idle-only'; + return typeof availability === 'function' ? availability(args) : availability; +} + +export function sortSlashCommands(commands: readonly KimiSlashCommand[]): KimiSlashCommand[] { + return [...commands].toSorted( + (a, b) => (b.priority ?? 0) - (a.priority ?? 0) || a.name.localeCompare(b.name), + ); +} diff --git a/apps/kimi-code/src/tui/commands/resolve.ts b/apps/kimi-code/src/tui/commands/resolve.ts new file mode 100644 index 000000000..2d0807bf4 --- /dev/null +++ b/apps/kimi-code/src/tui/commands/resolve.ts @@ -0,0 +1,115 @@ +import { + findBuiltInSlashCommand, + resolveSlashCommandAvailability, + type BuiltinSlashCommand, + type BuiltinSlashCommandName, +} from './registry'; +import { parseSlashInput } from './parse'; +import type { SlashCommandBusyReason, SlashCommandInvalidReason } from './types'; + +export type SlashCommandIntent = + | { readonly kind: 'not-command' } + | { + readonly kind: 'builtin'; + readonly command: BuiltinSlashCommand; + readonly name: BuiltinSlashCommandName; + readonly args: string; + } + | { + readonly kind: 'skill'; + readonly commandName: string; + readonly skillName: string; + readonly args: string; + } + | { readonly kind: 'message'; readonly input: string } + | { + readonly kind: 'blocked'; + readonly commandName: string; + readonly reason: SlashCommandBusyReason; + } + | { + readonly kind: 'invalid'; + readonly commandName: string; + readonly reason: SlashCommandInvalidReason; + }; + +export interface ResolveSlashCommandInput { + readonly input: string; + readonly skillCommandMap: ReadonlyMap; + readonly isStreaming: boolean; + readonly isCompacting: boolean; +} + +export function resolveSlashCommandInput(options: ResolveSlashCommandInput): SlashCommandIntent { + const parsed = parseSlashInput(options.input); + if (parsed === null) return { kind: 'not-command' }; + + const command = findBuiltInSlashCommand(parsed.name); + if (command !== undefined) { + const busyReason = slashCommandBusyReason(options); + if ( + busyReason !== undefined && + resolveSlashCommandAvailability(command, parsed.args) === 'idle-only' + ) { + return { + kind: 'blocked', + commandName: parsed.name, + reason: busyReason, + }; + } + return { + kind: 'builtin', + command, + name: command.name, + args: parsed.args, + }; + } + + const skillName = resolveSkillCommand(options.skillCommandMap, parsed.name); + if (skillName !== undefined) { + const busyReason = slashCommandBusyReason(options); + if (busyReason !== undefined) { + return { + kind: 'blocked', + commandName: parsed.name, + reason: busyReason, + }; + } + return { + kind: 'skill', + commandName: parsed.name, + skillName, + args: parsed.args.trim(), + }; + } + + return { + kind: 'message', + input: options.input, + }; +} + +export function resolveSkillCommand( + skillCommandMap: ReadonlyMap, + commandName: string, +): string | undefined { + return skillCommandMap.get(commandName) ?? skillCommandMap.get(`skill:${commandName}`); +} + +export function slashCommandBusyReason( + options: Pick, +): SlashCommandBusyReason | undefined { + if (options.isStreaming) return 'streaming'; + if (options.isCompacting) return 'compacting'; + return undefined; +} + +export function slashBusyMessage( + commandName: string, + reason: SlashCommandBusyReason, +): string { + if (reason === 'streaming') { + return `Cannot /${commandName} while streaming — press Esc or Ctrl-C first.`; + } + return `Cannot /${commandName} while compacting — wait for compaction to finish first.`; +} diff --git a/apps/kimi-code/src/tui/commands/skills.ts b/apps/kimi-code/src/tui/commands/skills.ts new file mode 100644 index 000000000..08dfafc7e --- /dev/null +++ b/apps/kimi-code/src/tui/commands/skills.ts @@ -0,0 +1,33 @@ +import type { Session, SkillSummary } from '@moonshot-ai/kimi-code-sdk'; + +import type { KimiSlashCommand } from './types'; + +export type SkillListSession = Pick; + +export interface SkillSlashCommands { + readonly commands: readonly KimiSlashCommand[]; + readonly commandMap: ReadonlyMap; +} + +export function isUserActivatableSkill(skill: SkillSummary): boolean { + return ( + skill.type === undefined || + skill.type === 'prompt' || + skill.type === 'inline' || + skill.type === 'flow' + ); +} + +export function buildSkillSlashCommands(skills: readonly SkillSummary[]): SkillSlashCommands { + const commandMap = new Map(); + const commands = skills.filter(isUserActivatableSkill).map((skill) => { + const commandName = `skill:${skill.name}`; + commandMap.set(commandName, skill.name); + return { + name: commandName, + aliases: [], + description: skill.description ?? '', + }; + }); + return { commands, commandMap }; +} diff --git a/apps/kimi-code/src/tui/commands/types.ts b/apps/kimi-code/src/tui/commands/types.ts new file mode 100644 index 000000000..cb784f84d --- /dev/null +++ b/apps/kimi-code/src/tui/commands/types.ts @@ -0,0 +1,20 @@ +import type { SlashCommand } from '@earendil-works/pi-tui'; + +export type SlashCommandAvailability = 'always' | 'idle-only'; + +export interface KimiSlashCommand extends SlashCommand { + readonly name: Name; + readonly aliases: readonly string[]; + readonly description: string; + readonly priority?: number; + readonly availability?: SlashCommandAvailability | ((args: string) => SlashCommandAvailability); +} + +export interface ParsedSlashInput { + readonly name: string; + readonly args: string; +} + +export type SlashCommandBusyReason = 'streaming' | 'compacting'; + +export type SlashCommandInvalidReason = 'unknown'; diff --git a/apps/kimi-code/src/tui/components/chrome/device-code-box.ts b/apps/kimi-code/src/tui/components/chrome/device-code-box.ts new file mode 100644 index 000000000..de2704228 --- /dev/null +++ b/apps/kimi-code/src/tui/components/chrome/device-code-box.ts @@ -0,0 +1,76 @@ +/** + * OAuth device-code panel rendered inside the transcript. + * + * Borrows the rounded-border layout from `WelcomeComponent` so the login + * prompt matches the rest of the chrome. All colors flow through the + * active palette so theme switches take effect on the next render. + */ + +import type { Component } from '@earendil-works/pi-tui'; +import { truncateToWidth, visibleWidth } from '@earendil-works/pi-tui'; +import chalk from 'chalk'; + +import type { ColorPalette } from '#/tui/theme/colors'; + +export interface DeviceCodeBoxParams { + readonly title: string; + readonly url: string; + readonly code: string; + readonly hint?: string; + readonly colors: ColorPalette; +} + +export class DeviceCodeBoxComponent implements Component { + private readonly params: DeviceCodeBoxParams; + + constructor(params: DeviceCodeBoxParams) { + this.params = params; + } + + invalidate(): void {} + + render(width: number): string[] { + const { title, url, code, hint, colors } = this.params; + const border = (s: string): string => chalk.hex(colors.primary)(s); + const safeWidth = Math.max(28, width); + const innerWidth = Math.max(10, safeWidth - 4); + const pad = ' '; + + const titleLine = truncateToWidth(chalk.bold.hex(colors.textStrong)(title), innerWidth, '…'); + const promptLine = truncateToWidth( + chalk.hex(colors.textDim)('Visit the URL below in your browser to authorize:'), + innerWidth, + '…', + ); + const urlLine = truncateToWidth(chalk.hex(colors.primary)(url), innerWidth, '…'); + + const codeLabel = chalk.bold.hex(colors.textDim)('Verification code: '); + const codeValue = chalk.bold.hex(colors.accent)(code); + const codeLine = truncateToWidth(`${codeLabel}${codeValue}`, innerWidth, '…'); + + const contentLines: string[] = [titleLine, '', promptLine, urlLine, '', codeLine]; + if (hint !== undefined && hint.length > 0) { + contentLines.push(''); + contentLines.push(truncateToWidth(chalk.hex(colors.textDim)(hint), innerWidth, '…')); + } + + const lines: string[] = [ + '', + border('╭' + '─'.repeat(safeWidth - 2) + '╮'), + border('│') + ' '.repeat(safeWidth - 2) + border('│'), + ]; + + for (const content of contentLines) { + const truncated = content; + const vis = visibleWidth(truncated); + const rightPad = Math.max(0, innerWidth - vis); + lines.push(border('│') + pad + truncated + ' '.repeat(rightPad) + border('│')); + } + + lines.push(border('│') + ' '.repeat(safeWidth - 2) + border('│')); + lines.push(border('╰' + '─'.repeat(safeWidth - 2) + '╯')); + lines.push(''); + + return lines; + } +} diff --git a/apps/kimi-code/src/tui/components/chrome/footer.ts b/apps/kimi-code/src/tui/components/chrome/footer.ts new file mode 100644 index 000000000..9079da44e --- /dev/null +++ b/apps/kimi-code/src/tui/components/chrome/footer.ts @@ -0,0 +1,266 @@ +/** + * Footer/status bar — multi-line status display at the bottom of the TUI. + * + * Layout: + * Line 1: [yolo] [plan] + * Line 2: context: XX.X% (tokens/max) + */ + +import type { Component } from '@earendil-works/pi-tui'; +import { truncateToWidth, visibleWidth } from '@earendil-works/pi-tui'; +import chalk from 'chalk'; + +import type { ColorPalette } from '#/tui/theme/colors'; +import type { AppState } from '#/tui/types'; +import { + createGitStatusCache, + formatGitBadgeBase, + formatPullRequestBadge, + type GitStatus, + type GitStatusCache, +} from '#/utils/git/git-status'; +import { safeUsageRatio } from '#/utils/usage/usage-format'; + +const MAX_CWD_SEGMENTS = 3; + +// Toolbar tips — rotates every 30s, shows 2 tips joined by " | " when +// space allows, falls back to 1. +const TIP_ROTATE_INTERVAL_MS = 30_000; +const TIP_SEPARATOR = ' | '; +const TOOLBAR_TIPS: readonly string[] = [ + 'shift+tab: plan mode', + '/yolo: toggle yolo', + 'ctrl+c: cancel', + '/help: show commands', + '/model: switch model', + '@: mention files', +]; + +function currentTipIndex(): number { + return Math.floor(Date.now() / TIP_ROTATE_INTERVAL_MS); +} + +function twoRotatingTips(index: number): string { + const n = TOOLBAR_TIPS.length; + if (n === 0) return ''; + if (n === 1) return TOOLBAR_TIPS[0]!; + const offset = ((index % n) + n) % n; + return TOOLBAR_TIPS[offset]! + TIP_SEPARATOR + TOOLBAR_TIPS[(offset + 1) % n]!; +} + +function oneRotatingTip(index: number): string { + const n = TOOLBAR_TIPS.length; + if (n === 0) return ''; + const offset = ((index % n) + n) % n; + return TOOLBAR_TIPS[offset]!; +} + +function shortenModel(model: string): string { + if (!model) return model; + const slash = model.lastIndexOf('/'); + return slash >= 0 ? model.slice(slash + 1) : model; +} + +function modelDisplayName(state: AppState): string { + const model = state.availableModels[state.model]; + return model?.displayName ?? model?.model ?? state.model; +} + +function shortenCwd(path: string): string { + if (!path) return path; + const home = process.env['HOME'] ?? ''; + let work = path; + if (home && path === home) { + return '~'; + } + if (home && path.startsWith(home + '/')) { + work = '~' + path.slice(home.length); + } + + const segments = work.split('/').filter((s) => s.length > 0); + if (segments.length <= MAX_CWD_SEGMENTS) return work; + const tail = segments.slice(-MAX_CWD_SEGMENTS).join('/'); + return `…/${tail}`; +} + +function formatTokenCount(n: number): string { + if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`; + if (n >= 1_000) return `${(n / 1_000).toFixed(1)}k`; + return String(n); +} + +function safeUsage(usage: number): number { + return safeUsageRatio(usage); +} + +function formatContextStatus(usage: number, tokens?: number, maxTokens?: number): string { + const pct = `${(safeUsage(usage) * 100).toFixed(1)}%`; + if (maxTokens && maxTokens > 0 && tokens !== undefined) { + return `context: ${pct} (${formatTokenCount(tokens)}/${formatTokenCount(maxTokens)})`; + } + return `context: ${pct}`; +} + +export function formatFooterGitBadge(status: GitStatus, colors: ColorPalette): string { + const base = chalk.hex(colors.status)(formatGitBadgeBase(status)); + if (status.pullRequest === null) return base; + + const pullRequest = chalk.hex(colors.primary)( + formatPullRequestBadge(status.pullRequest, { linkPullRequest: true }), + ); + return `${base} ${pullRequest}`; +} + +export class FooterComponent implements Component { + private state: AppState; + private colors: ColorPalette; + private readonly onGitStatusChange: () => void; + private gitCache: GitStatusCache; + private gitCacheWorkDir: string; + private transientHint: string | null = null; + /** + * Non-terminal background-task counts split by kind so the footer can + * render two distinct badges. `bashTasks` covers `bash-*` BPM tasks + * spawned via `Shell run_in_background=true`; `agentTasks` covers + * `agent-*` BPM tasks (background subagents). Either zero hides its + * respective badge. + */ + private backgroundBashTaskCount = 0; + private backgroundAgentCount = 0; + + constructor(state: AppState, colors: ColorPalette, onGitStatusChange: () => void = () => {}) { + this.state = state; + this.colors = colors; + this.onGitStatusChange = onGitStatusChange; + this.gitCacheWorkDir = state.workDir; + this.gitCache = createGitStatusCache(state.workDir, { onChange: this.onGitStatusChange }); + } + + setState(state: AppState): void { + if (state.workDir !== this.gitCacheWorkDir) { + this.gitCacheWorkDir = state.workDir; + this.gitCache = createGitStatusCache(state.workDir, { onChange: this.onGitStatusChange }); + } + this.state = state; + } + + setColors(colors: ColorPalette): void { + this.colors = colors; + } + + /** + * Short-lived hint that replaces the rotating toolbar tips on line 1. + * Used by the exit-confirmation double-tap flow to show "Press Ctrl+C + * again to exit" without requiring a toast/overlay subsystem. + * Pass `null` to clear. + */ + setTransientHint(hint: string | null): void { + this.transientHint = hint; + } + + /** + * Sync both background-task badges with live counts. Each non-zero + * count produces its own bracketed badge on line 1; zeros hide them + * independently. + */ + setBackgroundCounts(counts: { bashTasks: number; agentTasks: number }): void { + this.backgroundBashTaskCount = Math.max(0, counts.bashTasks); + this.backgroundAgentCount = Math.max(0, counts.agentTasks); + } + + invalidate(): void {} + + render(width: number): string[] { + const colors = this.colors; + const state = this.state; + + // ── Line 1: mode badges + model + [N task(s) running] + [N agent(s) running] + cwd + git + hints ── + const left: string[] = []; + if (state.permissionMode === 'auto') left.push(chalk.hex(colors.warning).bold('auto')); + if (state.permissionMode === 'yolo') left.push(chalk.hex(colors.warning).bold('yolo')); + if (state.planMode) left.push(chalk.hex(colors.primary).bold('plan')); + + const model = shortenModel(modelDisplayName(state)); + if (model) { + const thinkingLabel = state.thinking ? ' thinking' : ''; + left.push(chalk.hex(colors.text)(`${model}${thinkingLabel}`)); + } + + // Background-task badges sit immediately before cwd. `bash-*` tasks + // (shell processes) and `agent-*` tasks (background subagents) get + // separate badges so the user can distinguish them at a glance. + if (this.backgroundBashTaskCount > 0) { + const noun = this.backgroundBashTaskCount === 1 ? 'task' : 'tasks'; + left.push( + chalk.hex(colors.primary)(`[${String(this.backgroundBashTaskCount)} ${noun} running]`), + ); + } + if (this.backgroundAgentCount > 0) { + const noun = this.backgroundAgentCount === 1 ? 'agent' : 'agents'; + left.push( + chalk.hex(colors.primary)(`[${String(this.backgroundAgentCount)} ${noun} running]`), + ); + } + + const cwd = shortenCwd(state.workDir); + if (cwd) left.push(chalk.hex(colors.status)(cwd)); + + const git = this.gitCache.getStatus(); + if (git !== null) { + left.push(formatFooterGitBadge(git, colors)); + } + + const leftLine = left.join(' '); + const leftWidth = visibleWidth(leftLine); + + // Rotating hint tips, fill remaining space on line 1. + const tipIndex = currentTipIndex(); + const tipTwo = twoRotatingTips(tipIndex); + const tipOne = oneRotatingTip(tipIndex); + const gap = 2; + const remaining = Math.max(0, width - leftWidth - gap); + let tipText = ''; + if (tipTwo && visibleWidth(tipTwo) <= remaining) { + tipText = tipTwo; + } else if (tipOne && visibleWidth(tipOne) <= remaining) { + tipText = tipOne; + } + + let line1: string; + if (tipText) { + const pad = width - leftWidth - visibleWidth(tipText); + line1 = leftLine + ' '.repeat(Math.max(0, pad)) + chalk.hex(colors.textMuted)(tipText); + } else if (leftWidth <= width) { + line1 = leftLine; + } else { + line1 = truncateToWidth(leftLine, width, '…'); + } + + // ── Line 2: transient hint (bottom-left) + context (right) ── + const contextText = formatContextStatus( + state.contextUsage, + state.contextTokens, + state.maxContextTokens, + ); + const contextWidth = visibleWidth(contextText); + let line2: string; + if (this.transientHint) { + const maxHintWidth = Math.max(0, width - contextWidth - 1); + const shownHint = + visibleWidth(this.transientHint) <= maxHintWidth + ? this.transientHint + : truncateToWidth(this.transientHint, maxHintWidth, '…'); + const hintWidth = visibleWidth(shownHint); + const pad = Math.max(0, width - hintWidth - contextWidth); + line2 = + chalk.hex(colors.warning).bold(shownHint) + + ' '.repeat(pad) + + chalk.hex(colors.text)(contextText); + } else { + const leftPad = Math.max(0, width - contextWidth); + line2 = ' '.repeat(leftPad) + chalk.hex(colors.text)(contextText); + } + + return [truncateToWidth(line1, width), truncateToWidth(line2, width)]; + } +} diff --git a/apps/kimi-code/src/tui/components/chrome/gutter-container.ts b/apps/kimi-code/src/tui/components/chrome/gutter-container.ts new file mode 100644 index 000000000..39ed97c49 --- /dev/null +++ b/apps/kimi-code/src/tui/components/chrome/gutter-container.ts @@ -0,0 +1,33 @@ +/** + * Container that reserves left/right gutter columns around its children, + * so the chrome (statusline, transcript, panels) lines up with the input + * box's inner content area instead of butting up against the terminal edge. + * + * Children are rendered at `width - left - right` and each emitted line is + * 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. + */ + +import { Container } from '@earendil-works/pi-tui'; + +export class GutterContainer extends Container { + constructor( + private readonly leftPad: number, + private readonly rightPad: number, + ) { + super(); + } + + override render(width: number): string[] { + const inner = Math.max(1, width - this.leftPad - this.rightPad); + const lead = ' '.repeat(this.leftPad); + const out: string[] = []; + for (const child of this.children) { + for (const line of child.render(inner)) { + out.push(lead + line); + } + } + return out; + } +} diff --git a/apps/kimi-code/src/tui/components/chrome/moon-loader.ts b/apps/kimi-code/src/tui/components/chrome/moon-loader.ts new file mode 100644 index 000000000..0e88e6af4 --- /dev/null +++ b/apps/kimi-code/src/tui/components/chrome/moon-loader.ts @@ -0,0 +1,68 @@ +import { Text } from '@earendil-works/pi-tui'; +import type { TUI } from '@earendil-works/pi-tui'; + +import { + BRAILLE_SPINNER_FRAMES, + BRAILLE_SPINNER_INTERVAL_MS, + MOON_SPINNER_FRAMES, + MOON_SPINNER_INTERVAL_MS, +} from '#/tui/constant/rendering'; + +export type SpinnerStyle = 'moon' | 'braille'; + +export class MoonLoader extends Text { + private currentFrame = 0; + private intervalId: ReturnType | null = null; + private ui: TUI; + private frames: string[]; + private interval: number; + private colorFn?: (s: string) => string; + private label: string; + + constructor( + ui: TUI, + style: SpinnerStyle = 'moon', + colorFn?: (s: string) => string, + label: string = '', + ) { + super('', 1, 0); + this.ui = ui; + this.frames = style === 'moon' ? [...MOON_SPINNER_FRAMES] : [...BRAILLE_SPINNER_FRAMES]; + this.interval = style === 'moon' ? MOON_SPINNER_INTERVAL_MS : BRAILLE_SPINNER_INTERVAL_MS; + this.colorFn = colorFn; + this.label = label; + this.start(); + } + + start(): void { + this.updateDisplay(); + this.intervalId = setInterval(() => { + this.currentFrame = (this.currentFrame + 1) % this.frames.length; + this.updateDisplay(); + }, this.interval); + } + + stop(): void { + if (this.intervalId) { + clearInterval(this.intervalId); + this.intervalId = null; + } + } + + setLabel(label: string): void { + this.label = label; + this.updateDisplay(); + } + + setColorFn(colorFn: (s: string) => string): void { + this.colorFn = colorFn; + this.updateDisplay(); + } + + private updateDisplay(): void { + const frame = this.frames[this.currentFrame]!; + const coloredFrame = this.colorFn ? this.colorFn(frame) : frame; + this.setText(this.label ? `${coloredFrame} ${this.label}` : coloredFrame); + this.ui.requestRender(); + } +} diff --git a/apps/kimi-code/src/tui/components/chrome/todo-panel.ts b/apps/kimi-code/src/tui/components/chrome/todo-panel.ts new file mode 100644 index 000000000..ede986e60 --- /dev/null +++ b/apps/kimi-code/src/tui/components/chrome/todo-panel.ts @@ -0,0 +1,96 @@ +/** + * TodoPanel — live-updating TODO list shown before the input area. + * + * Mounted as a dedicated `Container` slot between the activity pane + * (spinners / thinking stream) and the queue / editor block. The host + * calls {@link setTodos} whenever the LLM invokes the `TodoList` + * tool; state survives across turns so the list stays visible until + * explicitly cleared (`todos: []`), a new session starts, or `/clear` + * is issued. + */ + +import type { Component } from '@earendil-works/pi-tui'; +import { truncateToWidth } from '@earendil-works/pi-tui'; +import chalk from 'chalk'; + +import type { ColorPalette } from '#/tui/theme/colors'; + +export type TodoStatus = 'pending' | 'in_progress' | 'done'; + +export interface TodoItem { + readonly title: string; + readonly status: TodoStatus; +} + +export class TodoPanelComponent implements Component { + private todos: readonly TodoItem[] = []; + private colors: ColorPalette; + + constructor(colors: ColorPalette) { + this.colors = colors; + } + + setTodos(todos: readonly TodoItem[]): void { + this.todos = todos.map((t) => ({ title: t.title, status: t.status })); + } + + getTodos(): readonly TodoItem[] { + return this.todos; + } + + clear(): void { + this.todos = []; + } + + isEmpty(): boolean { + return this.todos.length === 0; + } + + setColors(colors: ColorPalette): void { + this.colors = colors; + } + + invalidate(): void {} + + render(width: number): string[] { + if (this.todos.length === 0) return []; + const c = this.colors; + const lines: string[] = [ + chalk.hex(c.border)('─'.repeat(width)), + chalk.hex(c.primary).bold(' Todo'), + ]; + for (const todo of this.todos) { + lines.push(renderRow(todo, c)); + } + + return lines.map((line) => truncateToWidth(line, width)); + } +} + +function renderRow(todo: TodoItem, colors: ColorPalette): string { + const marker = statusMarker(todo.status, colors); + const titleStyled = styleTitle(todo.title, todo.status, colors); + return ` ${marker} ${titleStyled}`; +} + +function statusMarker(status: TodoStatus, colors: ColorPalette): string { + switch (status) { + case 'in_progress': + return chalk.hex(colors.primary).bold('●'); + case 'done': + return chalk.hex(colors.success)('✓'); + case 'pending': + return chalk.hex(colors.textDim)('○'); + } +} + +function styleTitle(title: string, status: TodoStatus, colors: ColorPalette): string { + switch (status) { + case 'in_progress': + return chalk.hex(colors.text).bold(title); + case 'done': + return chalk.hex(colors.textDim).strikethrough(title); + case 'pending': + return chalk.hex(colors.text)(title); + } +} diff --git a/apps/kimi-code/src/tui/components/chrome/welcome.ts b/apps/kimi-code/src/tui/components/chrome/welcome.ts new file mode 100644 index 000000000..e75d46df9 --- /dev/null +++ b/apps/kimi-code/src/tui/components/chrome/welcome.ts @@ -0,0 +1,86 @@ +/** + * Welcome panel shown at the top of the TUI. + * Renders a round-bordered box with the logo, session, model, and version. + */ + +import type { Component } from '@earendil-works/pi-tui'; +import { truncateToWidth, visibleWidth } from '@earendil-works/pi-tui'; +import chalk from 'chalk'; + +import type { ColorPalette } from '#/tui/theme/colors'; +import type { AppState } from '#/tui/types'; + +export class WelcomeComponent implements Component { + private state: AppState; + private colors: ColorPalette; + + constructor(state: AppState, colors: ColorPalette) { + this.state = state; + this.colors = colors; + } + + invalidate(): void {} + + render(width: number): string[] { + const primary = (s: string): string => chalk.hex(this.colors.primary)(s); + const innerWidth = Math.max(10, width - 4); + const pad = ' '; + + // Logo + side-by-side text. + const logo = ['▐█▛█▛█▌', '▐█████▌']; + const logoWidth = Math.max(...logo.map((row) => visibleWidth(row))); + const gap = ' '; + const textWidth = Math.max(4, innerWidth - logoWidth - gap.length); + + const rightRow0 = truncateToWidth( + chalk.bold.hex(this.colors.primary)('Welcome to Kimi Code!'), + textWidth, + '…', + ); + const isLoggedOut = !this.state.model; + const dim = chalk.hex(this.colors.textDim); + const labelStyle = chalk.bold.hex(this.colors.textDim); + const rightRow1 = truncateToWidth( + dim(isLoggedOut ? 'Run /login to sign in.' : 'Send /help for help information.'), + textWidth, + '…', + ); + + const headerLines = [ + primary(logo[0]!.padEnd(logoWidth)) + gap + rightRow0, + primary(logo[1]!.padEnd(logoWidth)) + gap + rightRow1, + ]; + + const modelValue = isLoggedOut + ? chalk.hex(this.colors.warning)('not set, send /login to login') + : this.state.model; + + const infoLines = [ + labelStyle('Directory: ') + this.state.workDir, + labelStyle('Session: ') + this.state.sessionId, + labelStyle('Model: ') + modelValue, + labelStyle('Version: ') + this.state.version, + ]; + + const contentLines: string[] = [...headerLines, '', ...infoLines]; + + const lines: string[] = [ + '', + primary('╭' + '─'.repeat(width - 2) + '╮'), + primary('│') + ' '.repeat(width - 2) + primary('│'), + ]; + + for (const content of contentLines) { + const truncated = truncateToWidth(content, innerWidth, '…'); + const vis = visibleWidth(truncated); + const rightPad = Math.max(0, innerWidth - vis); + lines.push(primary('│') + pad + truncated + ' '.repeat(rightPad) + primary('│')); + } + + lines.push(primary('│') + ' '.repeat(width - 2) + primary('│')); + lines.push(primary('╰' + '─'.repeat(width - 2) + '╯')); + lines.push(''); + + return lines; + } +} 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 new file mode 100644 index 000000000..abf1b3e99 --- /dev/null +++ b/apps/kimi-code/src/tui/components/dialogs/api-key-input-dialog.ts @@ -0,0 +1,148 @@ +import { + Container, + Input, + Key, + matchesKey, + truncateToWidth, + visibleWidth, + type Focusable, +} from '@earendil-works/pi-tui'; +import chalk from 'chalk'; + +import type { ColorPalette } from '#/tui/theme/colors'; + +export type ApiKeyInputResult = + | { readonly kind: 'ok'; readonly value: string } + | { readonly kind: 'cancel' }; + +const FOOTER = 'Enter to submit · Esc to cancel'; + +function maskInputLine(raw: string): string { + const prefix = '> '; + if (!raw.startsWith(prefix)) return raw; + + // Strip trailing padding spaces so they stay as spaces. + let end = raw.length; + while (end > prefix.length && raw[end - 1] === ' ') { + end--; + } + const padding = raw.slice(end); + const content = raw.slice(prefix.length, end); + + // Protect ANSI escape sequences (reverse-video cursor, IME marker, etc.) + // while masking every other visible character. + const parts = content.split(/(\u001B(?:\[[0-9;]*m|_pi:c\u0007))/); + const maskedContent = parts + .map((part, index) => { + if (index % 2 === 1) return part; // ANSI sequence + return part.replaceAll(/./g, '•'); + }) + .join(''); + + return prefix + maskedContent + padding; +} + +export class ApiKeyInputDialogComponent extends Container implements Focusable { + focused = false; + + private readonly input = new Input(); + private readonly onDone: (result: ApiKeyInputResult) => void; + private readonly colors: ColorPalette; + private readonly title: string; + private readonly subtitle: string; + private done = false; + private emptyHinted = false; + + constructor( + platformName: string, + onDone: (result: ApiKeyInputResult) => void, + colors: ColorPalette, + ) { + super(); + this.onDone = onDone; + this.colors = colors; + this.title = `Enter API key for ${platformName}`; + this.subtitle = 'Your key will be saved to ~/.kimi-code/config.toml'; + this.input.onSubmit = (value) => { + this.submit(value); + }; + } + + handleInput(data: string): void { + if (this.done) return; + if ( + matchesKey(data, Key.escape) || + matchesKey(data, Key.ctrl('c')) || + matchesKey(data, Key.ctrl('d')) + ) { + this.cancel(); + return; + } + if (this.emptyHinted) { + this.emptyHinted = false; + } + this.input.handleInput(data); + } + + override invalidate(): void { + super.invalidate(); + this.input.invalidate(); + } + + override render(width: number): string[] { + this.input.focused = this.focused && !this.done; + + const safeWidth = Math.max(28, width); + const innerWidth = Math.max(10, safeWidth - 4); + const pad = ' '; + + const border = (s: string): string => chalk.hex(this.colors.primary)(s); + const titleStyled = chalk.bold.hex(this.colors.textStrong)(this.title); + const subtitleText = this.emptyHinted ? 'API key cannot be empty.' : this.subtitle; + const subtitleStyled = chalk.hex(this.colors.textDim)(subtitleText); + const footerStyled = chalk.hex(this.colors.textDim)(FOOTER); + + const titleLine = truncateToWidth(titleStyled, innerWidth, '…'); + const subtitleLine = truncateToWidth(subtitleStyled, innerWidth, '…'); + const footerLine = truncateToWidth(footerStyled, innerWidth, '…'); + const rawInputLine = this.input.render(innerWidth)[0] ?? '> '; + const inputLine = maskInputLine(rawInputLine); + + const contentLines: string[] = [titleLine, '', subtitleLine, '', inputLine, '', footerLine]; + + const lines: string[] = [ + '', + border('╭' + '─'.repeat(safeWidth - 2) + '╮'), + border('│') + ' '.repeat(safeWidth - 2) + border('│'), + ]; + + for (const content of contentLines) { + const vis = visibleWidth(content); + const rightPad = Math.max(0, innerWidth - vis); + lines.push(border('│') + pad + content + ' '.repeat(rightPad) + border('│')); + } + + lines.push(border('│') + ' '.repeat(safeWidth - 2) + border('│')); + lines.push(border('╰' + '─'.repeat(safeWidth - 2) + '╯')); + lines.push(''); + + return lines; + } + + private submit(value: string): void { + if (this.done) return; + const trimmed = value.trim(); + if (trimmed.length === 0) { + this.emptyHinted = true; + return; + } + this.done = true; + this.onDone({ kind: 'ok', value: trimmed }); + } + + private cancel(): void { + if (this.done) return; + this.done = true; + this.onDone({ kind: 'cancel' }); + } +} diff --git a/apps/kimi-code/src/tui/components/dialogs/approval-panel.ts b/apps/kimi-code/src/tui/components/dialogs/approval-panel.ts new file mode 100644 index 000000000..ed93602f6 --- /dev/null +++ b/apps/kimi-code/src/tui/components/dialogs/approval-panel.ts @@ -0,0 +1,406 @@ +/** + * ApprovalPanel — pi-tui version of the approval request UI. + * + * Container-based component with keyboard navigation. + */ + +import { + Container, + Input, + matchesKey, + Key, + decodeKittyPrintable, + type Focusable, + truncateToWidth, + visibleWidth, +} from '@earendil-works/pi-tui'; +import chalk from 'chalk'; + +import { highlightLines, langFromPath } from '#/tui/components/media/code-highlight'; +import { renderDiffLinesClustered } from '#/tui/components/media/diff-preview'; +import type { ApprovalPanelChoice, DisplayBlock, PendingApproval } from '#/tui/reverse-rpc/types'; +import type { ColorPalette } from '#/tui/theme/colors'; + +export interface ApprovalPanelResponse { + readonly response: 'approved' | 'approved_for_session' | 'rejected' | 'cancelled'; + readonly feedback?: string | undefined; + readonly selected_label?: string | undefined; +} + +function truncateOneLine(text: string, max: number): string { + const firstLine = text.split('\n')[0] ?? ''; + return firstLine.length > max ? firstLine.slice(0, max - 1) + '…' : firstLine; +} + +const DIFF_SUMMARY_MAX_LINES = 10; +const CONTENT_SUMMARY_MAX_LINES = 10; + +interface BlockStyles { + strong: (s: string) => string; + dim: (s: string) => string; + accent: (s: string) => string; + gutter: (s: string) => string; + errorBold: (s: string) => string; +} + +function makeBlockStyles(colors: ColorPalette): BlockStyles { + return { + strong: (s) => chalk.hex(colors.textStrong)(s), + dim: (s) => chalk.hex(colors.textDim)(s), + accent: (s) => chalk.hex(colors.accent)(s), + gutter: (s) => chalk.hex(colors.diffGutter)(s), + errorBold: (s) => chalk.bold.hex(colors.error)(s), + }; +} + +function renderDisplayBlock( + block: DisplayBlock, + expanded: boolean, + s: BlockStyles, + colors: ColorPalette, +): string[] { + switch (block.type) { + case 'diff': + return renderDiffLinesClustered(block.old_text, block.new_text, block.path, colors, { + contextLines: 3, + expandKeyHint: 'ctrl+e', + ...(expanded ? {} : { maxLines: DIFF_SUMMARY_MAX_LINES }), + }); + case 'file_content': { + const lang = block.language ?? langFromPath(block.path); + const allLines = highlightLines(block.content, lang); + const cap = expanded ? allLines.length : CONTENT_SUMMARY_MAX_LINES; + const shown = allLines.slice(0, cap); + const lines = [s.strong(block.path)]; + for (const [i, line] of shown.entries()) { + lines.push(s.gutter(String(i + 1).padStart(4) + ' ') + line); + } + const remaining = allLines.length - shown.length; + if (remaining > 0) { + lines.push( + s.dim( + ` … ${String(remaining)} more line${remaining > 1 ? 's' : ''} hidden (ctrl+e to expand)`, + ), + ); + } + return lines; + } + case 'shell': { + const lines: string[] = []; + if (block.cwd !== undefined && block.cwd.length > 0) { + lines.push(s.dim(`cwd: ${block.cwd}`)); + } + if (block.danger !== undefined) { + lines.push(s.errorBold(`Dangerous: ${block.danger}`)); + } + const cmdLines = block.command.length > 0 ? block.command.split('\n') : ['']; + cmdLines.forEach((cmdLine, idx) => { + const prefix = idx === 0 ? s.accent('$') : s.dim('·'); + lines.push(`${prefix} ${s.strong(cmdLine)}`); + }); + if (block.description !== undefined && block.description.length > 0) { + lines.push(` ${s.dim(block.description)}`); + } + return lines; + } + case 'file_op': { + const op = s.accent(block.operation.padEnd(5)); + const lines = [`${op} ${s.strong(block.path)}`]; + if (block.detail !== undefined && block.detail.length > 0) { + lines.push(s.dim(block.detail)); + } + return lines; + } + case 'url_fetch': { + const method = s.accent((block.method ?? 'GET').toUpperCase().padEnd(5)); + return [`${method} ${s.strong(block.url)}`]; + } + case 'search': { + const lines = [`${s.accent('search')} ${s.strong(block.query)}`]; + if (block.scope !== undefined && block.scope.length > 0) { + lines.push(s.dim(`scope: ${block.scope}`)); + } + return lines; + } + case 'invocation': { + const lines = [`${s.accent(block.kind.padEnd(5))} ${s.strong(block.name)}`]; + if (block.description !== undefined && block.description.length > 0) { + lines.push(s.dim(truncateOneLine(block.description, 200))); + } + return lines; + } + case 'brief': + return block.text + ? block.text.split('\n').map((line) => (line.length > 0 ? s.strong(line) : '')) + : []; + case 'background_task': + return [ + s.strong(`${block.status} ${block.kind} task ${block.task_id}: ${block.description}`), + ]; + case 'todo': + return block.items.map((item) => s.strong(`- [${item.status}] ${item.title}`)); + default: + return []; + } +} + +function normalizeApprovalText(text: string): string { + return text.replaceAll('\r\n', '\n').trim(); +} + +function isDuplicateBriefBlock(block: DisplayBlock, description: string): boolean { + if (block.type !== 'brief' || block.text.trim().length === 0) return false; + const normalizedDescription = normalizeApprovalText(description); + if (normalizedDescription.length === 0) return false; + const normalizedBlockText = normalizeApprovalText(block.text); + if (normalizedBlockText === normalizedDescription) return true; + const blockLines = normalizedBlockText.split('\n'); + if (blockLines.length <= 1) return false; + return normalizeApprovalText(blockLines.slice(1).join('\n')) === normalizedDescription; +} + +function headerFor(toolName: string): string { + switch (toolName) { + case 'Bash': + return 'Run this command?'; + case 'Write': + return 'Write this file?'; + case 'Edit': + return 'Apply these edits?'; + case 'TaskStop': + return 'Stop this task?'; + case 'ExitPlanMode': + return 'Ready to build with this plan?'; + default: + return `Approve ${toolName}?`; + } +} + +export class ApprovalPanelComponent extends Container implements Focusable { + focused = false; + private selectedIndex = 0; + private feedbackMode = false; + private readonly feedbackInput = new Input(); + private expanded = false; + private onResponse: (response: ApprovalPanelResponse) => void; + private request: PendingApproval; + private readonly colors: ColorPalette; + private readonly onToggleToolOutput: (() => void) | undefined; + private readonly onTogglePlanExpand: (() => void) | undefined; + + constructor( + request: PendingApproval, + onResponse: (response: ApprovalPanelResponse) => void, + colors: ColorPalette, + onToggleToolOutput?: () => void, + onTogglePlanExpand?: () => void, + ) { + super(); + this.request = request; + this.onResponse = onResponse; + this.colors = colors; + this.onToggleToolOutput = onToggleToolOutput; + this.onTogglePlanExpand = onTogglePlanExpand; + this.feedbackInput.onSubmit = (value) => { + this.submit(this.selectedIndex, value); + }; + this.feedbackInput.onEscape = () => { + this.feedbackMode = false; + this.feedbackInput.setValue(''); + }; + } + + private submit(index: number, feedback: string = ''): void { + const option = this.choiceAt(index); + if (!option) return; + this.onResponse({ + response: option.response, + feedback: feedback || undefined, + selected_label: option.selected_label, + }); + } + + private selectAndSubmit(index: number): void { + const option = this.choiceAt(index); + if (!option) return; + if (option.requires_feedback === true) { + this.selectedIndex = index; + this.feedbackMode = true; + } else { + this.submit(index); + } + } + + handleInput(data: string): void { + if ( + matchesKey(data, Key.escape) || + matchesKey(data, Key.ctrl('c')) || + matchesKey(data, Key.ctrl('d')) + ) { + this.onResponse({ response: 'rejected' }); + return; + } + + if (matchesKey(data, Key.ctrl('e'))) { + this.expanded = !this.expanded; + this.onTogglePlanExpand?.(); + return; + } + + if (matchesKey(data, Key.ctrl('o'))) { + this.onToggleToolOutput?.(); + return; + } + + if (this.feedbackMode) { + if (matchesKey(data, Key.up)) { + this.feedbackMode = false; + this.selectedIndex = (this.selectedIndex - 1 + this.choiceCount()) % this.choiceCount(); + return; + } + if (matchesKey(data, Key.down)) { + this.feedbackMode = false; + this.selectedIndex = (this.selectedIndex + 1) % this.choiceCount(); + return; + } + this.feedbackInput.handleInput(data); + return; + } + + if (this.choiceCount() === 0) return; + if (matchesKey(data, Key.up)) { + this.selectedIndex = (this.selectedIndex - 1 + this.choiceCount()) % this.choiceCount(); + return; + } + if (matchesKey(data, Key.down)) { + this.selectedIndex = (this.selectedIndex + 1) % this.choiceCount(); + return; + } + if (matchesKey(data, Key.enter)) { + this.selectAndSubmit(this.selectedIndex); + return; + } + + const printable = decodeKittyPrintable(data) ?? data; + const numericIndex = Number(printable) - 1; + if (Number.isInteger(numericIndex) && numericIndex >= 0 && numericIndex < this.choiceCount()) { + this.selectAndSubmit(numericIndex); + } + } + + override render(width: number): string[] { + this.clear(); + this.ensureValidSelection(); + this.feedbackInput.focused = this.focused && this.feedbackMode; + const { data } = this.request; + const blockStyles = makeBlockStyles(this.colors); + const borderColor = chalk.hex(this.colors.borderFocus); + const borderColorBold = chalk.bold.hex(this.colors.borderFocus); + const selectColorBold = chalk.bold.hex(this.colors.accent); + const dim = chalk.hex(this.colors.textDim); + const strong = chalk.hex(this.colors.textStrong); + const horizontalBar = borderColor('─'.repeat(width)); + const indent = (s: string): string => ` ${s}`; + + const title = headerFor(data.tool_name); + const lines: string[] = [ + horizontalBar, + indent(`${borderColorBold('▶')} ${borderColorBold(title)}`), + ]; + + const dedupedBlocks = data.display.filter( + (block) => !isDuplicateBriefBlock(block, data.description), + ); + const visibleBlocks = dedupedBlocks.slice(0, 5); + const hasExpandable = visibleBlocks.some( + (block) => block.type === 'diff' || block.type === 'file_content', + ); + + if (visibleBlocks.length > 0) { + lines.push(''); + for (const block of visibleBlocks) { + const blockLines = renderDisplayBlock(block, this.expanded, blockStyles, this.colors); + for (const line of blockLines) { + lines.push(indent(line)); + } + } + } else if (data.description) { + lines.push(''); + for (const descLine of data.description.split('\n')) { + lines.push(indent(dim(descLine))); + } + } + + lines.push(''); + for (let idx = 0; idx < data.choices.length; idx++) { + const option = data.choices[idx]; + if (option === undefined) continue; + const isSelected = idx === this.selectedIndex; + const num = idx + 1; + + const labelWithNum = `${String(num)}. ${option.label}`; + if (this.feedbackMode && option.requires_feedback === true && isSelected) { + lines.push(indent(this.renderInlineFeedbackLine(width - 2, labelWithNum))); + } else if (isSelected) { + lines.push(indent(`${selectColorBold('▶')} ${selectColorBold(labelWithNum)}`)); + } else { + lines.push(indent(strong(` ${labelWithNum}`))); + } + } + + lines.push(''); + if (this.feedbackMode) { + lines.push(indent(dim('Type feedback · ↵ submit.'))); + } else { + const expandHint = hasExpandable ? ` · ctrl+e ${this.expanded ? 'collapse' : 'expand'}` : ''; + lines.push( + indent( + dim( + `↑/↓ select · ${buildNumericHint(data.choices.length)} choose · ↵ confirm${expandHint}`, + ), + ), + ); + } + lines.push(horizontalBar); + + return lines.map((line) => truncateToWidth(line, width)); + } + + private choiceAt(index: number): ApprovalPanelChoice | undefined { + return this.request.data.choices[index]; + } + + private choiceCount(): number { + return this.request.data.choices.length; + } + + private ensureValidSelection(): void { + const count = this.choiceCount(); + if (count === 0) { + this.selectedIndex = 0; + return; + } + if (this.selectedIndex < 0 || this.selectedIndex >= count) { + this.selectedIndex = Math.max(0, Math.min(this.selectedIndex, count - 1)); + } + } + + private renderInlineFeedbackLine(width: number, labelWithNum: string): string { + const selectColorBold = chalk.bold.hex(this.colors.accent); + const prefix = `${selectColorBold('▶')} ${selectColorBold(labelWithNum)} `; + const inputWidth = Math.max(4, width - visibleWidth(prefix) + 2); + const inputLine = this.feedbackInput.render(inputWidth)[0] ?? '> '; + const inlineInput = inputLine.startsWith('> ') ? inputLine.slice(2) : inputLine; + return prefix + inlineInput; + } + + override invalidate(): void { + super.invalidate(); + this.feedbackInput.invalidate(); + } +} + +function buildNumericHint(count: number): string { + if (count <= 0) return '↵'; + return Array.from({ length: Math.min(count, 9) }, (_, idx) => String(idx + 1)).join('/'); +} diff --git a/apps/kimi-code/src/tui/components/dialogs/choice-picker.ts b/apps/kimi-code/src/tui/components/dialogs/choice-picker.ts new file mode 100644 index 000000000..66bca1a65 --- /dev/null +++ b/apps/kimi-code/src/tui/components/dialogs/choice-picker.ts @@ -0,0 +1,133 @@ +/** + * ChoicePicker — modal single-select list for slash commands that ask + * the user to pick from a small set of preset values. + * + * Mirrors SessionPickerComponent's container-replacement pattern: host + * calls `showChoicePicker(...)` which clears the editor container, + * addChild(picker), setFocus(picker); the picker invokes `onSelect` or + * `onCancel`, and the host tears it down. + */ + +import { + Container, + matchesKey, + Key, + truncateToWidth, + visibleWidth, + type Focusable, +} from '@earendil-works/pi-tui'; +import chalk from 'chalk'; + +import type { ColorPalette } from '#/tui/theme/colors'; + +export interface ChoiceOption { + /** Value passed to onSelect (e.g. the actual editor command string). */ + readonly value: string; + /** Display text shown in the list. */ + readonly label: string; + /** Optional explanatory text shown below the label. */ + readonly description?: string | undefined; +} + +export interface ChoicePickerOptions { + readonly title: string; + readonly hint?: string; + readonly options: readonly ChoiceOption[]; + readonly currentValue?: string; + readonly colors: ColorPalette; + readonly onSelect: (value: string) => void; + readonly onCancel: () => void; +} + +const CURRENT_MARK = '← current'; + +function wrapDescription(text: string, width: number): string[] { + const maxWidth = Math.max(1, width); + const words = text + .trim() + .split(/\s+/) + .filter((word) => word.length > 0); + const lines: string[] = []; + let current = ''; + + for (const word of words) { + const candidate = current.length === 0 ? word : `${current} ${word}`; + if (visibleWidth(candidate) <= maxWidth) { + current = candidate; + continue; + } + if (current.length > 0) lines.push(current); + current = visibleWidth(word) <= maxWidth ? word : truncateToWidth(word, maxWidth, '…'); + } + + if (current.length > 0) lines.push(current); + return lines; +} + +export class ChoicePickerComponent extends Container implements Focusable { + focused = false; + private readonly opts: ChoicePickerOptions; + private selectedIndex: number; + + constructor(opts: ChoicePickerOptions) { + super(); + this.opts = opts; + const currentIdx = opts.options.findIndex((o) => o.value === opts.currentValue); + this.selectedIndex = Math.max(currentIdx, 0); + } + + handleInput(data: string): void { + if (matchesKey(data, Key.escape)) { + this.opts.onCancel(); + return; + } + if (matchesKey(data, Key.up)) { + this.selectedIndex = Math.max(0, this.selectedIndex - 1); + return; + } + if (matchesKey(data, Key.down)) { + this.selectedIndex = Math.min(this.opts.options.length - 1, this.selectedIndex + 1); + return; + } + if (matchesKey(data, Key.enter)) { + const chosen = this.opts.options[this.selectedIndex]; + if (chosen !== undefined) this.opts.onSelect(chosen.value); + return; + } + } + + override render(width: number): string[] { + const { colors } = this.opts; + const hint = this.opts.hint ?? '↑↓ navigate · Enter select · Esc cancel'; + const lines: string[] = [ + chalk.hex(colors.primary)('─'.repeat(width)), + chalk.hex(colors.primary).bold(` ${this.opts.title}`), + chalk.hex(colors.textMuted)(` ${hint}`), + '', + ]; + + for (let i = 0; i < this.opts.options.length; i++) { + const opt = this.opts.options[i]!; + const isSelected = i === this.selectedIndex; + const isCurrent = opt.value === this.opts.currentValue; + const pointer = isSelected ? '❯' : ' '; + const labelStyle = isSelected ? chalk.hex(colors.primary).bold : chalk.hex(colors.text); + let line = chalk.hex(isSelected ? colors.primary : colors.textDim)(` ${pointer} `); + line += labelStyle(opt.label); + if (isCurrent) { + line += ' ' + chalk.hex(colors.success)(CURRENT_MARK); + } + lines.push(line); + if (opt.description !== undefined && opt.description.length > 0) { + const descriptionWidth = Math.max(1, width - 4); + for (const descLine of wrapDescription(opt.description, descriptionWidth)) { + lines.push(chalk.hex(colors.textMuted)(` ${descLine}`)); + } + } + } + + lines.push(''); + lines.push(chalk.hex(colors.primary)('─'.repeat(width))); + return lines.map((line) => truncateToWidth(line, width)); + } +} diff --git a/apps/kimi-code/src/tui/components/dialogs/compaction.ts b/apps/kimi-code/src/tui/components/dialogs/compaction.ts new file mode 100644 index 000000000..6a55ede98 --- /dev/null +++ b/apps/kimi-code/src/tui/components/dialogs/compaction.ts @@ -0,0 +1,109 @@ +/** + * Renders a compaction block in the transcript. + * + * Lifecycle: + * - constructed on `compaction.started` → blinking white bullet + + * "Compacting context..." and optional custom instruction + * - `markDone()` on `compaction.completed` → solid green bullet + + * "Compaction complete (X → Y tokens)" + * - `markCanceled()` on `compaction.cancelled` → solid warning bullet + + * "Compaction cancelled" + * + * Bullet animation mirrors `ToolCallComponent` (500ms blink) so the user + * reads the same "work in progress" signal across the UI. + */ + +import { Container, Text, Spacer } from '@earendil-works/pi-tui'; +import type { TUI } from '@earendil-works/pi-tui'; +import chalk from 'chalk'; + +import { STATUS_BULLET } from '#/tui/constant/symbols'; +import type { ColorPalette } from '#/tui/theme/colors'; + +const BLINK_INTERVAL = 500; + +export class CompactionComponent extends Container { + private readonly colors: ColorPalette; + private readonly ui: TUI | undefined; + private readonly headerText: Text; + private blinkOn = true; + private blinkTimer: ReturnType | null = null; + private done = false; + private canceled = false; + private tokensBefore: number | undefined; + private tokensAfter: number | undefined; + + constructor(colors: ColorPalette, ui?: TUI, instruction?: string | undefined) { + super(); + this.colors = colors; + this.ui = ui; + + // Top margin so the block isn't glued to the previous transcript + // entry (status line, tool result, etc.). + this.addChild(new Spacer(1)); + this.headerText = new Text(this.buildHeader(), 0, 0); + this.addChild(this.headerText); + if (instruction !== undefined) { + this.addChild(new Text(chalk.dim(` ${instruction}`), 0, 0)); + } + + this.startBlink(); + } + + markDone(tokensBefore?: number, tokensAfter?: number): void { + if (this.done || this.canceled) return; + this.done = true; + this.tokensBefore = tokensBefore; + this.tokensAfter = tokensAfter; + this.stopBlink(); + this.headerText.setText(this.buildHeader()); + this.ui?.requestRender(); + } + + markCanceled(): void { + if (this.done || this.canceled) return; + this.canceled = true; + this.stopBlink(); + this.headerText.setText(this.buildHeader()); + this.ui?.requestRender(); + } + + dispose(): void { + this.stopBlink(); + } + + private buildHeader(): string { + if (this.done) { + const bullet = chalk.hex(this.colors.success)(STATUS_BULLET); + const label = chalk.hex(this.colors.success).bold('Compaction complete'); + const detail = + this.tokensBefore !== undefined && this.tokensAfter !== undefined + ? chalk.dim(` (${String(this.tokensBefore)} → ${String(this.tokensAfter)} tokens)`) + : ''; + return `${bullet}${label}${detail}`; + } + if (this.canceled) { + const bullet = chalk.hex(this.colors.warning)(STATUS_BULLET); + const label = chalk.hex(this.colors.warning).bold('Compaction cancelled'); + return `${bullet}${label}`; + } + const bullet = this.blinkOn ? chalk.hex(this.colors.roleAssistant)(STATUS_BULLET) : ' '; + const label = chalk.hex(this.colors.primary).bold('Compacting context...'); + return `${bullet}${label}`; + } + + private startBlink(): void { + this.blinkTimer = setInterval(() => { + this.blinkOn = !this.blinkOn; + this.headerText.setText(this.buildHeader()); + this.ui?.requestRender(); + }, BLINK_INTERVAL); + } + + private stopBlink(): void { + if (this.blinkTimer !== null) { + clearInterval(this.blinkTimer); + this.blinkTimer = null; + } + } +} diff --git a/apps/kimi-code/src/tui/components/dialogs/editor-selector.ts b/apps/kimi-code/src/tui/components/dialogs/editor-selector.ts new file mode 100644 index 000000000..24467dd05 --- /dev/null +++ b/apps/kimi-code/src/tui/components/dialogs/editor-selector.ts @@ -0,0 +1,31 @@ +import { ChoicePickerComponent, type ChoiceOption } from './choice-picker'; + +import type { ColorPalette } from '#/tui/theme/colors'; + +const EDITOR_OPTIONS: readonly ChoiceOption[] = [ + { value: 'code --wait', label: 'VS Code (code --wait)' }, + { value: 'vim', label: 'Vim' }, + { value: 'nvim', label: 'Neovim' }, + { value: 'nano', label: 'Nano' }, + { value: '', label: 'Auto-detect ($VISUAL / $EDITOR)' }, +]; + +export interface EditorSelectorOptions { + readonly currentValue: string; + readonly colors: ColorPalette; + readonly onSelect: (value: string) => void; + readonly onCancel: () => void; +} + +export class EditorSelectorComponent extends ChoicePickerComponent { + constructor(opts: EditorSelectorOptions) { + super({ + title: 'Select external editor', + options: [...EDITOR_OPTIONS], + currentValue: opts.currentValue, + colors: opts.colors, + onSelect: opts.onSelect, + onCancel: opts.onCancel, + }); + } +} diff --git a/apps/kimi-code/src/tui/components/dialogs/feedback-input-dialog.ts b/apps/kimi-code/src/tui/components/dialogs/feedback-input-dialog.ts new file mode 100644 index 000000000..1fb963625 --- /dev/null +++ b/apps/kimi-code/src/tui/components/dialogs/feedback-input-dialog.ts @@ -0,0 +1,126 @@ +/** + * FeedbackInputDialog — blue rounded box that collects a single line of + * user feedback before submitting it to the managed Kimi Code platform. + * + * Geometry mirrors `DeviceCodeBox` so the chrome stays consistent with + * the OAuth login flow. The box embeds a `pi-tui` Input for the actual + * text entry; cursor visibility tracks the dialog's `focused` flag. + */ + +import { + Container, + Input, + Key, + matchesKey, + truncateToWidth, + visibleWidth, + type Focusable, +} from '@earendil-works/pi-tui'; +import chalk from 'chalk'; + +import type { ColorPalette } from '#/tui/theme/colors'; + +export type FeedbackInputDialogResult = + | { readonly kind: 'ok'; readonly value: string } + | { readonly kind: 'cancel' }; + +const TITLE = 'Send feedback to Kimi Code'; +const SUBTITLE_DEFAULT = "Tell us what's working or what's not."; +const SUBTITLE_EMPTY = 'Feedback cannot be empty.'; +const FOOTER = 'Enter to submit · Esc to cancel'; + +export class FeedbackInputDialogComponent extends Container implements Focusable { + focused = false; + + private readonly input = new Input(); + private readonly onDone: (result: FeedbackInputDialogResult) => void; + private readonly colors: ColorPalette; + private done = false; + private emptyHinted = false; + + constructor(onDone: (result: FeedbackInputDialogResult) => void, colors: ColorPalette) { + super(); + this.onDone = onDone; + this.colors = colors; + this.input.onSubmit = (value) => { + this.submit(value); + }; + } + + handleInput(data: string): void { + if (this.done) return; + if ( + matchesKey(data, Key.escape) || + matchesKey(data, Key.ctrl('c')) || + matchesKey(data, Key.ctrl('d')) + ) { + this.cancel(); + return; + } + if (this.emptyHinted) { + this.emptyHinted = false; + } + this.input.handleInput(data); + } + + override invalidate(): void { + super.invalidate(); + this.input.invalidate(); + } + + override render(width: number): string[] { + this.input.focused = this.focused && !this.done; + + const safeWidth = Math.max(28, width); + const innerWidth = Math.max(10, safeWidth - 4); + const pad = ' '; + + const border = (s: string): string => chalk.hex(this.colors.primary)(s); + const titleStyled = chalk.bold.hex(this.colors.textStrong)(TITLE); + const subtitleText = this.emptyHinted ? SUBTITLE_EMPTY : SUBTITLE_DEFAULT; + const subtitleStyled = chalk.hex(this.colors.textDim)(subtitleText); + const footerStyled = chalk.hex(this.colors.textDim)(FOOTER); + + const titleLine = truncateToWidth(titleStyled, innerWidth, '…'); + const subtitleLine = truncateToWidth(subtitleStyled, innerWidth, '…'); + const footerLine = truncateToWidth(footerStyled, innerWidth, '…'); + const inputLine = this.input.render(innerWidth)[0] ?? '> '; + + const contentLines: string[] = [titleLine, '', subtitleLine, '', inputLine, '', footerLine]; + + const lines: string[] = [ + '', + border('╭' + '─'.repeat(safeWidth - 2) + '╮'), + border('│') + ' '.repeat(safeWidth - 2) + border('│'), + ]; + + for (const content of contentLines) { + const vis = visibleWidth(content); + const rightPad = Math.max(0, innerWidth - vis); + lines.push(border('│') + pad + content + ' '.repeat(rightPad) + border('│')); + } + + lines.push(border('│') + ' '.repeat(safeWidth - 2) + border('│')); + lines.push(border('╰' + '─'.repeat(safeWidth - 2) + '╯')); + lines.push(''); + + return lines; + } + + private submit(value: string): void { + if (this.done) return; + const trimmed = value.trim(); + if (trimmed.length === 0) { + this.emptyHinted = true; + return; + } + this.done = true; + this.onDone({ kind: 'ok', value: trimmed }); + } + + private cancel(): void { + if (this.done) return; + this.done = true; + this.onDone({ kind: 'cancel' }); + } +} diff --git a/apps/kimi-code/src/tui/components/dialogs/help-panel.ts b/apps/kimi-code/src/tui/components/dialogs/help-panel.ts new file mode 100644 index 000000000..36713a204 --- /dev/null +++ b/apps/kimi-code/src/tui/components/dialogs/help-panel.ts @@ -0,0 +1,148 @@ +/** + * HelpPanel — modal `/help` display. Lists keyboard shortcuts, slash + * commands (with aliases + descriptions) in colour-coded sections. + * + * Mirrors the container-replacement pattern used by SessionPicker / + * ApprovalPanel: host mounts the panel into `editorContainer`, picks + * it as the focused component, and tears it down on the `onClose` + * callback (fired on Esc / Enter / q). + */ + +import { + Container, + matchesKey, + Key, + decodeKittyPrintable, + type Focusable, + truncateToWidth, +} from '@earendil-works/pi-tui'; +import chalk from 'chalk'; + +import type { ColorPalette } from '#/tui/theme/colors'; + +export interface KeyboardShortcut { + readonly keys: string; + readonly description: string; +} + +export interface HelpPanelCommand { + readonly name: string; + readonly aliases: readonly string[]; + readonly description: string; +} + +/** Static list — keep in sync with the global editor bindings. */ +export const DEFAULT_KEYBOARD_SHORTCUTS: readonly KeyboardShortcut[] = [ + { keys: 'Shift-Tab', description: 'Toggle plan mode' }, + // { keys: 'Ctrl-G', description: 'Edit in external editor ($VISUAL / $EDITOR)' }, + { keys: 'Ctrl-O', description: 'Toggle tool output expansion' }, + { keys: 'Ctrl-S', description: 'Steer — inject a follow-up during streaming' }, + { keys: 'Shift-Enter', description: 'Insert newline' }, + { keys: 'Ctrl-C', description: 'Interrupt stream / clear input' }, + { keys: 'Ctrl-D', description: 'Exit (on empty input)' }, + { keys: 'Esc', description: 'Close dialogs / interrupt streaming' }, + { keys: '↑ / ↓', description: 'Browse input history' }, + { keys: 'Enter', description: 'Submit' }, +]; + +export interface HelpPanelOptions { + readonly commands: readonly HelpPanelCommand[]; + readonly shortcuts?: readonly KeyboardShortcut[]; + readonly colors: ColorPalette; + readonly onClose: () => void; + /** Terminal height — used to decide whether to show the hint tail. */ + readonly maxVisible?: number; +} + +export class HelpPanelComponent extends Container implements Focusable { + focused = false; + private readonly opts: HelpPanelOptions; + private scrollTop = 0; + + constructor(opts: HelpPanelOptions) { + super(); + this.opts = opts; + } + + handleInput(data: string): void { + const printable = decodeKittyPrintable(data) ?? data; + if ( + matchesKey(data, Key.escape) || + matchesKey(data, Key.enter) || + printable === 'q' || + printable === 'Q' + ) { + this.opts.onClose(); + return; + } + if (matchesKey(data, Key.up)) { + this.scrollTop = Math.max(0, this.scrollTop - 1); + return; + } + if (matchesKey(data, Key.down)) { + this.scrollTop += 1; // render clamps + return; + } + if (matchesKey(data, Key.pageUp)) { + this.scrollTop = Math.max(0, this.scrollTop - 10); + return; + } + if (matchesKey(data, Key.pageDown)) { + this.scrollTop += 10; + } + } + + override render(width: number): string[] { + const colors = this.opts.colors; + const accent = chalk.hex(colors.primary); + const dim = chalk.hex(colors.textDim); + const muted = chalk.hex(colors.textMuted); + const kbdColor = chalk.hex(colors.warning); + const slashColor = chalk.hex(colors.primary); + + const shortcuts = this.opts.shortcuts ?? DEFAULT_KEYBOARD_SHORTCUTS; + const kbdWidth = Math.max(8, ...shortcuts.map((s) => s.keys.length)); + const sortedCmds = [...this.opts.commands].toSorted((a, b) => a.name.localeCompare(b.name)); + const cmdLabels = sortedCmds.map((c) => { + const aliases = c.aliases.length > 0 ? ` (${c.aliases.map((a) => '/' + a).join(', ')})` : ''; + return `/${c.name}${aliases}`; + }); + const cmdWidth = Math.max(12, ...cmdLabels.map((l) => l.length)); + const lines: string[] = [ + accent('─'.repeat(width)), + accent.bold(' help ') + muted('· Esc / Enter / q to close · ↑↓ scroll'), + '', + // Greeting + ` ${dim('Sure, Kimi is ready to help! Just send a message to get started.')}`, + '', + // Section: keyboard shortcuts + ` ${chalk.bold('Keyboard shortcuts')}`, + ...shortcuts.map((s) => ` ${kbdColor(s.keys.padEnd(kbdWidth))} ${dim(s.description)}`), + '', + // Section: slash commands + ` ${chalk.bold('Slash commands')}`, + ...sortedCmds.map((cmd, i) => { + const label = cmdLabels[i] ?? `/${cmd.name}`; + return ` ${slashColor(label.padEnd(cmdWidth))} ${dim(cmd.description)}`; + }), + '', + accent('─'.repeat(width)), + ]; + + // Apply scroll windowing — keep the borders visible. + const content = lines.slice(1, lines.length - 1); + const maxVisible = Math.max(5, this.opts.maxVisible ?? 24); + if (content.length > maxVisible) { + this.scrollTop = Math.max(0, Math.min(this.scrollTop, content.length - maxVisible)); + const slice = content.slice(this.scrollTop, this.scrollTop + maxVisible); + const scrollInfo = muted( + ` showing ${String(this.scrollTop + 1)}-${String(this.scrollTop + slice.length)} of ${String(content.length)}`, + ); + return [lines[0] ?? '', ...slice, scrollInfo, lines.at(-1) ?? ''].map((line) => + truncateToWidth(line, width), + ); + } + this.scrollTop = 0; + return lines.map((line) => truncateToWidth(line, width)); + } +} diff --git a/apps/kimi-code/src/tui/components/dialogs/model-selector.ts b/apps/kimi-code/src/tui/components/dialogs/model-selector.ts new file mode 100644 index 000000000..600d0ca5a --- /dev/null +++ b/apps/kimi-code/src/tui/components/dialogs/model-selector.ts @@ -0,0 +1,184 @@ +import type { ModelAlias } from '@moonshot-ai/kimi-code-sdk'; +import { + Container, + Key, + matchesKey, + truncateToWidth, + type Focusable, +} from '@earendil-works/pi-tui'; +import chalk from 'chalk'; + +import { DEFAULT_OAUTH_PROVIDER_NAME, PRODUCT_NAME } from '#/constant/app'; +import type { ColorPalette } from '#/tui/theme/colors'; + +import type { ChoiceOption } from './choice-picker'; + +type ThinkingAvailability = 'toggle' | 'always-on' | 'unsupported'; + +interface ModelChoice { + readonly alias: string; + readonly model: ModelAlias; + readonly label: string; +} + +export interface ModelSelection { + readonly alias: string; + readonly thinking: boolean; +} + +export function modelDisplayName(alias: string, model: ModelAlias | undefined): string { + return model?.displayName ?? model?.model ?? alias; +} + +export function providerDisplayName(provider: string): string { + if (provider === DEFAULT_OAUTH_PROVIDER_NAME) return PRODUCT_NAME; + if (provider.startsWith('managed:')) return provider.slice('managed:'.length); + return provider; +} + +export function createModelChoiceOptions( + models: Record, +): readonly ChoiceOption[] { + return Object.entries(models).map(([alias, cfg]) => ({ + value: alias, + label: `${modelDisplayName(alias, cfg)} (${providerDisplayName(cfg.provider)})`, + })); +} + +export interface ModelSelectorOptions { + readonly models: Record; + readonly currentValue: string; + readonly selectedValue?: string; + readonly currentThinking: boolean; + readonly colors: ColorPalette; + readonly onSelect: (selection: ModelSelection) => void; + readonly onCancel: () => void; +} + +function createModelChoices(models: Record): readonly ModelChoice[] { + return Object.entries(models).map(([alias, cfg]) => ({ + alias, + model: cfg, + label: `${modelDisplayName(alias, cfg)} (${providerDisplayName(cfg.provider)})`, + })); +} + +function thinkingAvailability(model: ModelAlias): ThinkingAvailability { + const caps = model.capabilities ?? []; + if (caps.includes('always_thinking')) return 'always-on'; + if (caps.includes('thinking')) return 'toggle'; + return 'unsupported'; +} + +function effectiveThinking(model: ModelAlias, thinkingDraft: boolean): boolean { + const availability = thinkingAvailability(model); + if (availability === 'always-on') return true; + if (availability === 'unsupported') return false; + return thinkingDraft; +} + +export class ModelSelectorComponent extends Container implements Focusable { + focused = false; + private readonly opts: ModelSelectorOptions; + private readonly choices: readonly ModelChoice[]; + private selectedIndex: number; + private thinkingDraft: boolean; + + constructor(opts: ModelSelectorOptions) { + super(); + this.opts = opts; + this.choices = createModelChoices(opts.models); + const selectedValue = opts.selectedValue ?? opts.currentValue; + const selectedIdx = this.choices.findIndex((choice) => choice.alias === selectedValue); + this.selectedIndex = Math.max(selectedIdx, 0); + this.thinkingDraft = opts.currentThinking; + } + + handleInput(data: string): void { + if (matchesKey(data, Key.escape)) { + this.opts.onCancel(); + return; + } + if (matchesKey(data, Key.up)) { + this.selectedIndex = Math.max(0, this.selectedIndex - 1); + return; + } + if (matchesKey(data, Key.down)) { + this.selectedIndex = Math.max(0, Math.min(this.choices.length - 1, this.selectedIndex + 1)); + return; + } + const selected = this.selectedChoice(); + if (selected !== undefined && thinkingAvailability(selected.model) === 'toggle') { + if (matchesKey(data, Key.left)) { + this.thinkingDraft = true; + return; + } + if (matchesKey(data, Key.right)) { + this.thinkingDraft = false; + return; + } + } + if (matchesKey(data, Key.enter)) { + if (selected === undefined) return; + this.opts.onSelect({ + alias: selected.alias, + thinking: effectiveThinking(selected.model, this.thinkingDraft), + }); + } + } + + override render(width: number): string[] { + const { colors } = this.opts; + const lines: string[] = [ + chalk.hex(colors.primary)('─'.repeat(width)), + chalk.hex(colors.primary).bold(' Select a model'), + chalk.hex(colors.textMuted)(' ↑↓ model · ←→ thinking · Enter apply · Esc cancel'), + '', + ]; + + for (let i = 0; i < this.choices.length; i++) { + const choice = this.choices[i]!; + const isSelected = i === this.selectedIndex; + const isCurrent = choice.alias === this.opts.currentValue; + const pointer = isSelected ? '❯' : ' '; + const labelStyle = isSelected ? chalk.hex(colors.primary).bold : chalk.hex(colors.text); + let line = chalk.hex(isSelected ? colors.primary : colors.textDim)(` ${pointer} `); + line += labelStyle(choice.label); + if (isCurrent) { + line += ' ' + chalk.hex(colors.success)('← current'); + } + lines.push(line); + } + + lines.push(''); + lines.push(chalk.hex(colors.textMuted)(' Thinking')); + const selected = this.selectedChoice(); + if (selected !== undefined) { + lines.push(this.renderThinkingControl(selected.model)); + } + lines.push(''); + lines.push(chalk.hex(colors.primary)('─'.repeat(width))); + return lines.map((line) => truncateToWidth(line, width)); + } + + private selectedChoice(): ModelChoice | undefined { + return this.choices[this.selectedIndex]; + } + + private renderThinkingControl(model: ModelAlias): string { + const { colors } = this.opts; + const segment = (label: string, active: boolean): string => + active + ? chalk.hex(colors.primary).bold(`[ ${label} ]`) + : chalk.hex(colors.text)(` ${label} `); + + const availability = thinkingAvailability(model); + if (availability === 'always-on') { + return ` ${segment('Always on', true)}`; + } + if (availability === 'unsupported') { + return ` ${segment('Off', true)} ${chalk.hex(colors.textMuted)('unsupported')}`; + } + return ` ${segment('On', this.thinkingDraft)} ${segment('Off', !this.thinkingDraft)}`; + } +} diff --git a/apps/kimi-code/src/tui/components/dialogs/permission-selector.ts b/apps/kimi-code/src/tui/components/dialogs/permission-selector.ts new file mode 100644 index 000000000..6445206d3 --- /dev/null +++ b/apps/kimi-code/src/tui/components/dialogs/permission-selector.ts @@ -0,0 +1,52 @@ +import type { PermissionMode } from '@moonshot-ai/kimi-code-sdk'; + +import { ChoicePickerComponent, type ChoiceOption } from './choice-picker'; + +import type { ColorPalette } from '#/tui/theme/colors'; + +const PERMISSION_OPTIONS: readonly ChoiceOption[] = [ + { + value: 'manual', + label: 'Manual', + description: + 'Ask before commands, edits, and other risky actions. Read/search tools run directly; session approval rules are respected.', + }, + { + value: 'auto', + label: 'Auto', + description: + 'Run fully non-interactively. Tool actions are approved automatically, and agent questions are skipped so it can decide on its own.', + }, + { + value: 'yolo', + label: 'YOLO', + description: + 'Automatically approve tool actions and plan transitions. The agent can still ask you explicit questions when your input is needed.', + }, +]; + +function isPermissionModeChoice(value: string): value is PermissionMode { + return value === 'manual' || value === 'auto' || value === 'yolo'; +} + +export interface PermissionSelectorOptions { + readonly currentValue: PermissionMode; + readonly colors: ColorPalette; + readonly onSelect: (mode: PermissionMode) => void; + readonly onCancel: () => void; +} + +export class PermissionSelectorComponent extends ChoicePickerComponent { + constructor(opts: PermissionSelectorOptions) { + super({ + title: 'Select permission mode', + options: [...PERMISSION_OPTIONS], + currentValue: opts.currentValue, + colors: opts.colors, + onSelect: (value) => { + if (isPermissionModeChoice(value)) opts.onSelect(value); + }, + onCancel: opts.onCancel, + }); + } +} diff --git a/apps/kimi-code/src/tui/components/dialogs/platform-selector.ts b/apps/kimi-code/src/tui/components/dialogs/platform-selector.ts new file mode 100644 index 000000000..d01b26e50 --- /dev/null +++ b/apps/kimi-code/src/tui/components/dialogs/platform-selector.ts @@ -0,0 +1,27 @@ +import { ChoicePickerComponent, type ChoiceOption } from './choice-picker'; + +import type { ColorPalette } from '#/tui/theme/colors'; + +const PLATFORM_OPTIONS: readonly ChoiceOption[] = [ + { value: 'kimi-code', label: 'Kimi Code' }, + { value: 'moonshot-cn', label: 'Moonshot AI Open Platform (moonshot.cn)' }, + { value: 'moonshot-ai', label: 'Moonshot AI Open Platform (moonshot.ai)' }, +]; + +export interface PlatformSelectorOptions { + readonly colors: ColorPalette; + readonly onSelect: (platformId: string) => void; + readonly onCancel: () => void; +} + +export class PlatformSelectorComponent extends ChoicePickerComponent { + constructor(opts: PlatformSelectorOptions) { + super({ + title: 'Select a platform', + options: [...PLATFORM_OPTIONS], + colors: opts.colors, + onSelect: opts.onSelect, + onCancel: opts.onCancel, + }); + } +} diff --git a/apps/kimi-code/src/tui/components/dialogs/question-dialog.ts b/apps/kimi-code/src/tui/components/dialogs/question-dialog.ts new file mode 100644 index 000000000..534e2c2f2 --- /dev/null +++ b/apps/kimi-code/src/tui/components/dialogs/question-dialog.ts @@ -0,0 +1,755 @@ +/** + * QuestionDialog — pi-tui version of the structured question prompt. + * + * Each question collects an answer locally, and a final Submit tab + * reviews everything before the answers are emitted upstream. + */ + +import { + Container, + Input, + matchesKey, + Key, + decodeKittyPrintable, + type Focusable, + truncateToWidth, + visibleWidth, +} from '@earendil-works/pi-tui'; +import chalk from 'chalk'; + +import type { + PendingQuestion, + QuestionPanelResponse, + QuestionSubmissionMethod, +} from '#/tui/reverse-rpc/types'; +import type { ColorPalette } from '#/tui/theme/colors'; + +const NUMBER_KEYS = ['1', '2', '3', '4', '5', '6', '7', '8', '9']; +const MAX_BODY_LINES = 12; +const DEFAULT_OTHER_LABEL = 'Other'; +const NOT_ANSWERED_LABEL = 'Not answered'; +const REVIEW_TITLE = 'Review your answer before submit'; +const SUBMIT_PROMPT = 'Ready to submit your answers?'; +const UNANSWERED_WARNING = 'Some questions are still unanswered.'; +const SUBMIT_ACTIONS = ['Submit', 'Cancel'] as const; + +interface DisplayOption { + readonly label: string; + readonly description?: string | undefined; + readonly kind: 'preset' | 'other'; +} + +export class QuestionDialogComponent extends Container implements Focusable { + focused = false; + + private readonly request: PendingQuestion; + private readonly colors: ColorPalette; + private readonly onAnswer: (response: QuestionPanelResponse) => void; + private readonly maxVisibleOptions: number; + private readonly otherInput = new Input(); + + private currentTab = 0; + private submitActionIdx = 0; + private editingOther = false; + private reviewMessage: string | undefined; + private lastAnswerMethod: QuestionSubmissionMethod | undefined; + + /** Per-question cursor position. */ + private readonly cursors: number[]; + /** Per-question single-select choice. */ + private readonly singleSelections: (number | undefined)[]; + /** Per-question multi-select choices. */ + private readonly multiSelections: Set[]; + /** Per-question free-text drafts for the synthetic Other option. */ + private readonly otherDrafts: string[]; + /** Per-question committed Other values. */ + private readonly committedOtherValues: (string | undefined)[]; + /** Per-question derived answers used by tabs + review. */ + private readonly answers: (string | undefined)[]; + + private readonly onToggleToolOutput: (() => void) | undefined; + private readonly onTogglePlanExpand: (() => void) | undefined; + + constructor( + request: PendingQuestion, + onAnswer: (response: QuestionPanelResponse) => void, + colors: ColorPalette, + maxVisibleOptions = 6, + onToggleToolOutput?: () => void, + onTogglePlanExpand?: () => void, + ) { + super(); + this.request = request; + this.onAnswer = onAnswer; + this.colors = colors; + this.maxVisibleOptions = maxVisibleOptions; + this.onToggleToolOutput = onToggleToolOutput; + this.onTogglePlanExpand = onTogglePlanExpand; + this.otherInput.onSubmit = (value) => { + this.commitOtherInput(value, 'enter'); + }; + + const total = request.data.questions.length; + this.cursors = Array.from({ length: total }, (): number => 0); + this.singleSelections = Array.from({ length: total }, (): number | undefined => undefined); + this.multiSelections = Array.from({ length: total }, () => new Set()); + this.otherDrafts = Array.from({ length: total }, (): string => ''); + this.committedOtherValues = Array.from({ length: total }, (): string | undefined => undefined); + this.answers = Array.from({ length: total }, (): string | undefined => undefined); + } + + // ── Input ───────────────────────────────────────────────────────── + + handleInput(data: string): void { + if (matchesKey(data, Key.escape)) { + this.onAnswer({ answers: [] }); + return; + } + + if (matchesKey(data, Key.ctrl('c')) || matchesKey(data, Key.ctrl('d'))) { + this.onAnswer({ answers: [] }); + return; + } + + if (matchesKey(data, Key.ctrl('o'))) { + this.onToggleToolOutput?.(); + return; + } + + if (matchesKey(data, Key.ctrl('e'))) { + this.onTogglePlanExpand?.(); + return; + } + + if (this.isEditingOther()) { + this.handleOtherInput(data); + return; + } + + if (this.isSubmitTab()) { + this.handleSubmitInput(data); + return; + } + + const questionIdx = this.currentQuestionIndex(); + if (questionIdx === undefined) return; + const question = this.request.data.questions[questionIdx]; + if (question === undefined) return; + + const optionCount = this.displayOptions(questionIdx).length; + if (optionCount === 0) return; + + if (matchesKey(data, Key.up)) { + this.moveQuestionCursor(-1); + return; + } + if (matchesKey(data, Key.down)) { + this.moveQuestionCursor(1); + return; + } + + if (matchesKey(data, Key.left)) { + this.gotoTab(this.currentTab - 1); + return; + } + if (matchesKey(data, Key.right) || matchesKey(data, Key.tab)) { + this.gotoTab(this.currentTab + 1); + return; + } + + if (matchesKey(data, Key.enter)) { + this.activateQuestionOption(this.currentCursor(), 'enter'); + return; + } + + const printable = decodeKittyPrintable(data) ?? data; + const numIdx = NUMBER_KEYS.indexOf(printable); + if (numIdx >= 0 && numIdx < optionCount) { + this.cursors[questionIdx] = numIdx; + this.activateQuestionOption(numIdx, 'number_key'); + return; + } + + if ((printable === ' ' || matchesKey(data, Key.space)) && question.multi_select) { + this.activateQuestionOption(this.currentCursor(), 'space'); + } + } + + private handleOtherInput(data: string): void { + const questionIdx = this.currentQuestionIndex(); + if (questionIdx === undefined) return; + + if (matchesKey(data, Key.tab)) { + this.syncOtherDraft(questionIdx); + this.editingOther = false; + this.gotoTab(this.currentTab + 1); + return; + } + if (matchesKey(data, Key.up)) { + this.syncOtherDraft(questionIdx); + this.editingOther = false; + this.moveQuestionCursor(-1); + return; + } + if (matchesKey(data, Key.down)) { + this.syncOtherDraft(questionIdx); + this.editingOther = false; + this.moveQuestionCursor(1); + return; + } + + this.otherInput.handleInput(data); + this.syncOtherDraft(questionIdx); + this.reviewMessage = undefined; + } + + private handleSubmitInput(data: string): void { + if (matchesKey(data, Key.up)) { + this.submitActionIdx = + (this.submitActionIdx - 1 + SUBMIT_ACTIONS.length) % SUBMIT_ACTIONS.length; + this.reviewMessage = undefined; + return; + } + if (matchesKey(data, Key.down)) { + this.submitActionIdx = (this.submitActionIdx + 1) % SUBMIT_ACTIONS.length; + this.reviewMessage = undefined; + return; + } + + if (matchesKey(data, Key.left)) { + this.gotoTab(this.currentTab - 1); + return; + } + if (matchesKey(data, Key.right) || matchesKey(data, Key.tab)) { + this.gotoTab(this.currentTab + 1); + return; + } + + if (matchesKey(data, Key.enter)) { + this.executeSubmitAction(this.submitActionIdx, 'enter'); + return; + } + + const printable = decodeKittyPrintable(data) ?? data; + if (printable === '1') { + this.submitActionIdx = 0; + this.executeSubmitAction(0, 'number_key'); + return; + } + if (printable === '2') { + this.submitActionIdx = 1; + this.executeSubmitAction(1, 'number_key'); + } + } + + // ── State mutation ──────────────────────────────────────────────── + + private gotoTab(target: number): void { + const total = this.totalTabs(); + if (total <= 0) return; + + const wrapped = ((target % total) + total) % total; + if (wrapped === this.currentTab) return; + + this.currentTab = wrapped; + this.editingOther = false; + this.reviewMessage = undefined; + if (this.isSubmitTab()) this.submitActionIdx = 0; + } + + private moveQuestionCursor(delta: number): void { + const questionIdx = this.currentQuestionIndex(); + if (questionIdx === undefined) return; + + const total = this.displayOptions(questionIdx).length; + if (total <= 0) return; + + this.cursors[questionIdx] = (this.currentCursor() + delta + total) % total; + this.reviewMessage = undefined; + } + + private activateQuestionOption(optionIdx: number, method: QuestionSubmissionMethod): void { + const questionIdx = this.currentQuestionIndex(); + if (questionIdx === undefined) return; + + const question = this.request.data.questions[questionIdx]; + if (question === undefined) return; + + this.cursors[questionIdx] = optionIdx; + this.editingOther = false; + this.reviewMessage = undefined; + + if (this.isOtherOption(questionIdx, optionIdx)) { + this.enterOtherInput(questionIdx); + return; + } + + if (question.multi_select) { + const set = this.multiSelections[questionIdx]; + if (set === undefined) return; + if (set.has(optionIdx)) set.delete(optionIdx); + else set.add(optionIdx); + this.lastAnswerMethod = method; + this.updateAnswer(questionIdx); + return; + } + + this.singleSelections[questionIdx] = optionIdx; + this.committedOtherValues[questionIdx] = undefined; + this.lastAnswerMethod = method; + this.updateAnswer(questionIdx); + this.advanceAfterSingleSelect(questionIdx); + } + + private enterOtherInput(questionIdx: number): void { + this.cursors[questionIdx] = this.otherOptionIndex(questionIdx); + this.editingOther = true; + this.otherInput.setValue(this.otherDraftValue(questionIdx)); + this.reviewMessage = undefined; + } + + private commitOtherInput(rawValue: string | undefined, method: QuestionSubmissionMethod): void { + const questionIdx = this.currentQuestionIndex(); + if (questionIdx === undefined) return; + + const question = this.request.data.questions[questionIdx]; + if (question === undefined) return; + + const value = (rawValue ?? this.otherInput.getValue()).trim(); + if (value.length === 0) return; + + this.otherInput.setValue(value); + this.otherDrafts[questionIdx] = value; + this.committedOtherValues[questionIdx] = value; + + if (question.multi_select) { + this.multiSelections[questionIdx]?.add(this.otherOptionIndex(questionIdx)); + } else { + this.singleSelections[questionIdx] = this.otherOptionIndex(questionIdx); + } + + this.lastAnswerMethod = method; + this.updateAnswer(questionIdx); + this.editingOther = false; + this.reviewMessage = undefined; + + if (!question.multi_select) this.advanceAfterSingleSelect(questionIdx); + } + + private advanceAfterSingleSelect(questionIdx: number): void { + const next = this.findNextUnansweredAfter(questionIdx); + this.currentTab = next ?? this.submitTabIndex(); + this.reviewMessage = undefined; + if (this.isSubmitTab()) this.submitActionIdx = 0; + } + + private findNextUnansweredAfter(fromIdx: number): number | null { + const total = this.request.data.questions.length; + for (let idx = fromIdx + 1; idx < total; idx++) { + if (!this.isAnswered(idx)) return idx; + } + return null; + } + + private updateAnswer(questionIdx: number): void { + const question = this.request.data.questions[questionIdx]; + if (question === undefined) return; + + if (question.multi_select) { + const labels: string[] = []; + const set = this.multiSelections[questionIdx] ?? new Set(); + const otherIdx = this.otherOptionIndex(questionIdx); + for (let i = 0; i < question.options.length; i++) { + if (!set.has(i)) continue; + const label = question.options[i]?.label; + if (label !== undefined && label.length > 0) labels.push(label); + } + const otherText = this.committedOtherValues[questionIdx]; + if (set.has(otherIdx) && otherText !== undefined && otherText.length > 0) { + labels.push(otherText); + } + this.answers[questionIdx] = labels.length > 0 ? labels.join(', ') : undefined; + return; + } + + const selection = this.singleSelections[questionIdx]; + if (selection === undefined) { + this.answers[questionIdx] = undefined; + return; + } + + if (this.isOtherOption(questionIdx, selection)) { + const otherText = this.committedOtherValues[questionIdx]; + this.answers[questionIdx] = + otherText !== undefined && otherText.length > 0 ? otherText : undefined; + return; + } + + const label = question.options[selection]?.label; + this.answers[questionIdx] = label !== undefined && label.length > 0 ? label : undefined; + } + + private executeSubmitAction(actionIdx: number, method: QuestionSubmissionMethod): void { + if (actionIdx === 1) { + this.onAnswer({ answers: [] }); + return; + } + + this.reviewMessage = undefined; + this.emitAnswers(method); + } + + private emitAnswers(method: QuestionSubmissionMethod): void { + const out: string[] = []; + for (let i = 0; i < this.answers.length; i++) { + const answer = this.answers[i]; + if (answer !== undefined && answer.length > 0) out[i] = answer; + } + this.onAnswer({ answers: out, method: this.lastAnswerMethod ?? method }); + } + + // ── Render ──────────────────────────────────────────────────────── + + override render(width: number): string[] { + this.otherInput.focused = this.focused && this.isEditingOther(); + return this.isSubmitTab() ? this.renderSubmitTab(width) : this.renderQuestionTab(width); + } + + private renderQuestionTab(width: number): string[] { + const questionIdx = this.currentQuestionIndex(); + if (questionIdx === undefined) return this.renderSubmitTab(width); + + const question = this.request.data.questions[questionIdx]; + if (question === undefined) return []; + + const colors = this.colors; + const accent = chalk.hex(colors.primary); + const dim = chalk.hex(colors.textDim); + const success = chalk.hex(colors.success); + + const renderWidth = Math.max(1, width); + const lines: string[] = [accent('─'.repeat(renderWidth)), accent.bold(' question'), '']; + this.pushTabs(lines); + lines.push(''); + + lines.push(accent(` ? ${question.question}`)); + if (this.isEditingOther()) { + lines.push(dim(' Type your answer, then press Enter to save.')); + } + + if (question.body !== undefined && question.body.trim().length > 0) { + lines.push(''); + const bodyLines = question.body.trim().split('\n'); + const visibleBodyLines = bodyLines.slice(0, MAX_BODY_LINES); + for (const bodyLine of visibleBodyLines) { + lines.push(dim(` ${bodyLine}`)); + } + if (bodyLines.length > visibleBodyLines.length) { + lines.push(dim(` ... ${String(bodyLines.length - visibleBodyLines.length)} more lines`)); + } + } + + lines.push(''); + + const options = this.displayOptions(questionIdx); + const cursor = this.currentCursor(); + const visibleStart = this.computeVisibleStart(cursor, options.length); + const visibleEnd = Math.min(options.length, visibleStart + this.maxVisibleOptions); + const multiSet = this.multiSelections[questionIdx] ?? new Set(); + const singleSelection = this.singleSelections[questionIdx]; + + for (let i = visibleStart; i < visibleEnd; i++) { + const option = options[i]; + if (option === undefined) continue; + const num = i + 1; + const isCursor = i === cursor; + const isOther = option.kind === 'other'; + const isSelected = question.multi_select ? multiSet.has(i) : singleSelection === i; + + if (this.isEditingOther() && isCursor && isOther) { + lines.push(this.renderEditingOtherLine(renderWidth, questionIdx, option, num, isSelected)); + continue; + } + + const label = this.renderOptionLabel(questionIdx, option, isCursor); + + let line: string; + if (question.multi_select) { + const checked = isSelected ? '✓' : ' '; + const body = `[${checked}] ${label}`; + if (isSelected && isCursor) line = success.bold(` ${body}`); + else if (isSelected) line = success(` ${body}`); + else if (isCursor) line = accent(` ${body}`); + else line = dim(` ${body}`); + } else if (isSelected && this.isAnswered(questionIdx)) { + line = isCursor + ? success.bold(` → [${String(num)}] ${label}`) + : success(` [${String(num)}] ${label}`); + } else if (isCursor) { + line = accent(` → [${String(num)}] ${label}`); + } else { + line = dim(` [${String(num)}] ${label}`); + } + lines.push(line); + + if ( + option.description !== undefined && + option.description.length > 0 && + !(this.isEditingOther() && isCursor && isOther) + ) { + lines.push(dim(` ${option.description}`)); + } + } + + if (visibleEnd < options.length || visibleStart > 0) { + lines.push( + dim( + ` showing ${String(visibleStart + 1)}-${String(visibleEnd)} of ${String(options.length)}`, + ), + ); + } + + lines.push(''); + lines.push(this.buildQuestionHint(dim, questionIdx)); + lines.push(accent('─'.repeat(renderWidth))); + + return lines.map((line) => truncateToWidth(line, width)); + } + + private renderSubmitTab(width: number): string[] { + const colors = this.colors; + const accent = chalk.hex(colors.primary); + const dim = chalk.hex(colors.textDim); + const text = chalk.hex(colors.text); + const warning = chalk.hex(colors.warning); + + const renderWidth = Math.max(1, width); + const lines: string[] = [accent('─'.repeat(renderWidth)), accent.bold(' question'), '']; + this.pushTabs(lines); + lines.push(''); + lines.push(text.bold(` ${REVIEW_TITLE}`)); + const reviewWarning = + this.reviewMessage ?? (this.hasUnansweredQuestions() ? UNANSWERED_WARNING : undefined); + if (reviewWarning !== undefined) { + lines.push(warning(` ${reviewWarning}`)); + } + lines.push(''); + + for (let i = 0; i < this.request.data.questions.length; i++) { + const question = this.request.data.questions[i]; + if (question === undefined) continue; + const answer = this.answers[i]; + lines.push(` ${dim('Q')} ${question.question}`); + if (answer !== undefined && answer.length > 0) { + lines.push(` ${accent('→')} ${text(answer)}`); + } else { + lines.push(` ${dim('→')} ${dim(NOT_ANSWERED_LABEL)}`); + } + } + + lines.push(''); + lines.push(text(` ${SUBMIT_PROMPT}`)); + lines.push(''); + + for (let i = 0; i < SUBMIT_ACTIONS.length; i++) { + const label = SUBMIT_ACTIONS[i]; + if (label === undefined) continue; + const num = i + 1; + if (i === this.submitActionIdx) { + lines.push(accent(` → [${String(num)}] ${label}`)); + } else { + lines.push(dim(` [${String(num)}] ${label}`)); + } + } + + lines.push(''); + lines.push(this.buildSubmitHint(dim)); + lines.push(accent('─'.repeat(renderWidth))); + + return lines.map((line) => truncateToWidth(line, width)); + } + + private pushTabs(lines: string[]): void { + const dim = chalk.hex(this.colors.textDim); + const active = chalk.bgHex(this.colors.primary).hex(this.colors.text).bold; + + const tabs: string[] = []; + for (let i = 0; i < this.request.data.questions.length; i++) { + const question = this.request.data.questions[i]; + if (question === undefined) continue; + const label = + question.header !== undefined && question.header.length > 0 + ? question.header + : `Q${String(i + 1)}`; + if (i === this.currentTab) tabs.push(active(` ${label} `)); + else if (this.isAnswered(i)) tabs.push(chalk.hex(this.colors.success)(`(✓) ${label}`)); + else tabs.push(dim(`(○) ${label}`)); + } + + const submitLabel = 'Submit'; + if (this.isSubmitTab()) tabs.push(active(` ${submitLabel} `)); + else tabs.push(dim(` ${submitLabel} `)); + + lines.push(` ${tabs.join(' ')}`); + } + + private buildQuestionHint(dim: (s: string) => string, questionIdx: number): string { + if (this.isEditingOther()) { + const parts: string[] = [ + 'type answer', + '↵ save', + ...(this.totalTabs() > 1 ? ['tab switch'] : []), + 'esc dismiss', + ]; + return dim(` ${parts.join(' ')}`); + } + + const optionCount = Math.min(this.displayOptions(questionIdx).length, NUMBER_KEYS.length); + const numberHint = optionCount <= 1 ? '1' : `1-${String(optionCount)}`; + const question = this.request.data.questions[questionIdx]; + if (question === undefined) return dim(' esc dismiss'); + + const parts: string[] = [ + '▲/▼ select', + `${numberHint} / ↵ ${question.multi_select ? 'toggle' : 'choose'}`, + ]; + if (this.totalTabs() > 1) parts.push('←/→/tab switch'); + parts.push('esc dismiss'); + return dim(` ${parts.join(' ')}`); + } + + private buildSubmitHint(dim: (s: string) => string): string { + const parts: string[] = ['▲/▼ select', '1/2 choose', '↵ confirm']; + if (this.totalTabs() > 1) parts.push('←/→/tab switch'); + parts.push('esc dismiss'); + return dim(` ${parts.join(' ')}`); + } + + private computeVisibleStart(cursor: number, total: number): number { + if (total <= this.maxVisibleOptions) return 0; + const half = Math.floor(this.maxVisibleOptions / 2); + const max = Math.max(0, total - this.maxVisibleOptions); + return Math.max(0, Math.min(cursor - half, max)); + } + + // ── Helpers ─────────────────────────────────────────────────────── + + private totalTabs(): number { + return this.request.data.questions.length + 1; + } + + private submitTabIndex(): number { + return this.request.data.questions.length; + } + + private isSubmitTab(): boolean { + return this.currentTab === this.submitTabIndex(); + } + + private isEditingOther(): boolean { + return this.editingOther && !this.isSubmitTab(); + } + + private currentQuestionIndex(): number | undefined { + return this.isSubmitTab() ? undefined : this.currentTab; + } + + private currentCursor(): number { + const questionIdx = this.currentQuestionIndex(); + if (questionIdx === undefined) return 0; + return this.cursors[questionIdx] ?? 0; + } + + private displayOptions(questionIdx: number): DisplayOption[] { + const question = this.request.data.questions[questionIdx]; + if (question === undefined) return []; + + return [ + ...question.options.map((option) => ({ + label: option.label, + description: option.description, + kind: 'preset' as const, + })), + { + label: question.other_label?.length ? question.other_label : DEFAULT_OTHER_LABEL, + description: question.other_description?.length ? question.other_description : undefined, + kind: 'other' as const, + }, + ]; + } + + private otherOptionIndex(questionIdx: number): number { + return this.request.data.questions[questionIdx]?.options.length ?? 0; + } + + private isOtherOption(questionIdx: number, optionIdx: number): boolean { + return optionIdx === this.otherOptionIndex(questionIdx); + } + + private renderOptionLabel(questionIdx: number, option: DisplayOption, isCursor: boolean): string { + if (option.kind !== 'other') return option.label; + + const value = this.otherDraftValue(questionIdx); + if (this.isEditingOther() && isCursor) { + return `${option.label}: ${value ?? ''}█`; + } + if (value !== undefined && value.length > 0) return `${option.label}: ${value}`; + return option.label; + } + + private renderEditingOtherLine( + width: number, + questionIdx: number, + option: DisplayOption, + num: number, + isSelected: boolean, + ): string { + const question = this.request.data.questions[questionIdx]; + if (question === undefined) return option.label; + + let prefix: string; + if (question.multi_select) { + const checked = isSelected ? '✓' : ' '; + const body = ` [${checked}] ${option.label}: `; + prefix = isSelected + ? chalk.hex(this.colors.success).bold(body) + : chalk.hex(this.colors.primary)(body); + } else { + const body = ` → [${String(num)}] ${option.label}: `; + prefix = + isSelected && this.isAnswered(questionIdx) + ? chalk.hex(this.colors.success).bold(body) + : chalk.hex(this.colors.primary)(body); + } + + const inputWidth = Math.max(4, width - visibleWidth(prefix) + 2); + const inputLine = this.otherInput.render(inputWidth)[0] ?? '> '; + const inlineInput = inputLine.startsWith('> ') ? inputLine.slice(2) : inputLine; + return prefix + inlineInput; + } + + private otherDraftValue(questionIdx: number): string { + return (this.otherDrafts[questionIdx] ?? this.committedOtherValues[questionIdx]) ?? ''; + } + + private syncOtherDraft(questionIdx: number): void { + this.otherDrafts[questionIdx] = this.otherInput.getValue(); + } + + private isAnswered(questionIdx: number): boolean { + const answer = this.answers[questionIdx]; + return answer !== undefined && answer.length > 0; + } + + private hasUnansweredQuestions(): boolean { + for (let i = 0; i < this.request.data.questions.length; i++) { + if (!this.isAnswered(i)) return true; + } + return false; + } + + override invalidate(): void { + super.invalidate(); + this.otherInput.invalidate(); + } +} diff --git a/apps/kimi-code/src/tui/components/dialogs/session-picker.ts b/apps/kimi-code/src/tui/components/dialogs/session-picker.ts new file mode 100644 index 000000000..da1a139e6 --- /dev/null +++ b/apps/kimi-code/src/tui/components/dialogs/session-picker.ts @@ -0,0 +1,268 @@ +/** + * SessionPicker — pi-tui version of the session selection dialog. + */ + +import { + Container, + matchesKey, + Key, + truncateToWidth, + visibleWidth, + type Focusable, +} from '@earendil-works/pi-tui'; +import chalk from 'chalk'; + +import { formatSessionLabel } from '#/migration/index'; +import type { ColorPalette } from '#/tui/theme/colors'; + +export interface SessionRow { + readonly id: string; + readonly title: string | null; + readonly last_prompt?: string | null; + readonly work_dir: string; + readonly updated_at: number; + readonly metadata?: Readonly> | undefined; +} + +const ELLIPSIS = '…'; +const CURRENT_BADGE = '(current)'; + +function formatRelativeTime(ts: number): string { + // SessionSummary timestamps come from filesystem stat `*timeMs`, + // so they use the same millisecond unit as `Date.now()`. + if (!Number.isFinite(ts) || ts <= 0) return ''; + const diffSec = Math.floor(Math.max(0, Date.now() - ts) / 1000); + if (diffSec < 60) return 'just now'; + const minutes = Math.floor(diffSec / 60); + if (minutes < 60) return `${String(minutes)}m ago`; + const hours = Math.floor(minutes / 60); + if (hours < 24) return `${String(hours)}h ago`; + const days = Math.floor(hours / 24); + return `${String(days)}d ago`; +} + +function homeAlias(path: string): string { + const home = process.env['HOME'] ?? ''; + if (home && path.startsWith(home)) return '~' + path.slice(home.length); + return path; +} + +// Truncates from the LEFT (keeps the tail), prefixing an ellipsis when clipped. +// Paths typically carry the relevant info near the end, so we drop the prefix. +function truncatePathLeft(path: string, maxWidth: number): string { + if (maxWidth <= 0) return ''; + if (visibleWidth(path) <= maxWidth) return path; + if (maxWidth === 1) return ELLIPSIS; + // Walk graphemes from the end accumulating width, keep the longest tail + // whose width + ellipsis fits. + const segmenter = new Intl.Segmenter(undefined, { granularity: 'grapheme' }); + const segments = [...segmenter.segment(path)].map((s) => s.segment); + let used = 0; + const budget = maxWidth - 1; // reserve 1 column for ellipsis + let i = segments.length - 1; + while (i >= 0) { + const seg = segments[i]; + if (seg === undefined) break; + const w = visibleWidth(seg); + if (used + w > budget) break; + used += w; + i--; + } + return ELLIPSIS + segments.slice(i + 1).join(''); +} + +function singleLine(text: string): string { + return text.replaceAll(/\s+/g, ' ').trim(); +} + +export class SessionPickerComponent extends Container implements Focusable { + private sessions: SessionRow[]; + private currentSessionId: string; + private colors: ColorPalette; + private onSelect: (sessionId: string) => void; + private onCancel: () => void; + private maxVisibleSessions: number; + private loading: boolean; + + focused = false; + private selectedIndex = 0; + + constructor(opts: { + sessions: SessionRow[]; + loading: boolean; + currentSessionId: string; + colors: ColorPalette; + onSelect: (sessionId: string) => void; + onCancel: () => void; + maxVisibleSessions?: number; + }) { + super(); + this.sessions = opts.sessions; + this.loading = opts.loading; + this.currentSessionId = opts.currentSessionId; + this.colors = opts.colors; + this.onSelect = opts.onSelect; + this.onCancel = opts.onCancel; + this.maxVisibleSessions = opts.maxVisibleSessions ?? 4; + } + + handleInput(data: string): void { + if (matchesKey(data, Key.escape)) { + this.onCancel(); + return; + } + if (matchesKey(data, Key.enter) && this.sessions.length > 0) { + const session = this.sessions[this.selectedIndex]; + if (session) this.onSelect(session.id); + return; + } + if (matchesKey(data, Key.up)) { + this.selectedIndex = Math.max(0, this.selectedIndex - 1); + return; + } + if (matchesKey(data, Key.down)) { + this.selectedIndex = Math.min(this.sessions.length - 1, this.selectedIndex + 1); + } + } + + override render(width: number): string[] { + const colors = this.colors; + const lines: string[] = [chalk.hex(colors.primary)('─'.repeat(width))]; + + if (this.loading) { + lines.push(chalk.hex(colors.primary).bold(truncateToWidth('Sessions', width, ELLIPSIS))); + lines.push( + chalk.hex(colors.textMuted)(truncateToWidth('Loading sessions...', width, ELLIPSIS)), + ); + lines.push(chalk.hex(colors.primary)('─'.repeat(width))); + return lines; + } + + if (this.sessions.length === 0) { + lines.push(chalk.hex(colors.primary).bold(truncateToWidth('Sessions', width, ELLIPSIS))); + lines.push( + chalk.hex(colors.textMuted)( + truncateToWidth('No sessions found. Press Escape to close.', width, ELLIPSIS), + ), + ); + lines.push(chalk.hex(colors.primary)('─'.repeat(width))); + return lines; + } + + const headerLabel = 'Sessions '; + const headerHint = '(↑↓ navigate, Enter select, Esc cancel)'; + const labelWidth = visibleWidth(headerLabel); + const hintBudget = Math.max(0, width - labelWidth); + const shownHint = truncateToWidth(headerHint, hintBudget, ELLIPSIS); + lines.push( + chalk.hex(colors.primary).bold(headerLabel) + chalk.hex(colors.textMuted)(shownHint), + ); + lines.push(''); + + const visibleStart = Math.max( + 0, + Math.min( + this.selectedIndex - Math.floor(this.maxVisibleSessions / 2), + Math.max(0, this.sessions.length - this.maxVisibleSessions), + ), + ); + const visibleSessions = this.sessions.slice( + visibleStart, + visibleStart + this.maxVisibleSessions, + ); + + for (const [vi, session] of visibleSessions.entries()) { + const index = visibleStart + vi; + const isSelected = index === this.selectedIndex; + const isCurrent = session.id === this.currentSessionId; + const card = this.renderSessionCard(width, session, isSelected, isCurrent); + lines.push(...card); + if (vi < visibleSessions.length - 1) lines.push(''); + } + + if (this.sessions.length > visibleSessions.length) { + lines.push(''); + const footer = `Showing ${String(visibleStart + 1)}-${String(visibleStart + visibleSessions.length)} of ${String(this.sessions.length)} sessions`; + lines.push(chalk.hex(colors.textMuted)(truncateToWidth(footer, width, ELLIPSIS))); + } + + lines.push(chalk.hex(colors.primary)('─'.repeat(width))); + return lines; + } + + private renderSessionCard( + width: number, + session: SessionRow, + isSelected: boolean, + isCurrent: boolean, + ): string[] { + const colors = this.colors; + const pointer = isSelected ? '❯' : ' '; + const indent = ' '; + const indentWidth = visibleWidth(indent); + const titleColor = isSelected ? colors.primary : colors.text; + const titleStyle = isSelected ? chalk.hex(titleColor).bold : chalk.hex(titleColor); + + const time = formatRelativeTime(session.updated_at); + const badge = isCurrent ? CURRENT_BADGE : ''; + const rawTitle = (session.title ?? session.id).trim() || session.id; + const titleSource = formatSessionLabel({ title: rawTitle, metadata: session.metadata }); + + // Inline trailing parts after the title: " <time> (current)". + const trailingParts = [time, badge].filter((p) => p.length > 0); + const trailingText = trailingParts.length > 0 ? ' ' + trailingParts.join(' ') : ''; + const trailingWidth = visibleWidth(trailingText); + const headerPrefixWidth = visibleWidth(pointer) + 1; // pointer + space + const titleBudget = Math.max(8, width - headerPrefixWidth - trailingWidth); + const shownTitle = truncateToWidth(singleLine(titleSource), titleBudget, ELLIPSIS); + + let header = chalk.hex(isSelected ? colors.primary : colors.textDim)(pointer + ' '); + header += titleStyle(shownTitle); + if (time.length > 0) header += ' ' + chalk.hex(colors.textDim)(time); + if (badge.length > 0) header += ' ' + chalk.hex(colors.success)(badge); + const card: string[] = [header]; + + // Session id is rendered in full (no truncation). The directory wraps to + // its own line if it would push past the terminal edge. + const fullId = session.id; + const idWidth = visibleWidth(fullId); + const metaGap = ' '; + const metaGapWidth = visibleWidth(metaGap); + const idLineWidth = indentWidth + idWidth; + const aliasedDir = homeAlias(session.work_dir); + const dirWidth = visibleWidth(aliasedDir); + + if (idLineWidth + metaGapWidth + dirWidth <= width) { + card.push( + indent + + chalk.hex(colors.textMuted)(fullId) + + chalk.hex(colors.textDim)(metaGap) + + chalk.hex(colors.textMuted)(aliasedDir), + ); + } else { + // Not enough room for both on one line — keep the id intact and put the + // directory on the next line (left-truncated only if it still doesn't fit). + card.push( + indent + + chalk.hex(colors.textMuted)( + truncateToWidth(fullId, Math.max(idWidth, width - indentWidth), ELLIPSIS), + ), + ); + const dirBudget = Math.max(8, width - indentWidth); + const dir = truncatePathLeft(aliasedDir, dirBudget); + card.push(indent + chalk.hex(colors.textMuted)(dir)); + } + + const rawPrompt = session.last_prompt?.trim(); + if (rawPrompt && rawPrompt.length > 0) { + const promptMarker = '› '; + const promptMarkerWidth = visibleWidth(promptMarker); + const promptBudget = Math.max(8, width - indentWidth - promptMarkerWidth); + const promptText = truncateToWidth(singleLine(rawPrompt), promptBudget, ELLIPSIS); + const promptLine = indent + chalk.hex(colors.textDim)(promptMarker + promptText); + card.push(promptLine); + } + + return card; + } +} diff --git a/apps/kimi-code/src/tui/components/dialogs/settings-selector.ts b/apps/kimi-code/src/tui/components/dialogs/settings-selector.ts new file mode 100644 index 000000000..fb224345b --- /dev/null +++ b/apps/kimi-code/src/tui/components/dialogs/settings-selector.ts @@ -0,0 +1,63 @@ +import { ChoicePickerComponent, type ChoiceOption } from './choice-picker'; + +import type { ColorPalette } from '#/tui/theme/colors'; + +export type SettingsSelection = 'model' | 'theme' | 'editor' | 'permission' | 'usage'; + +const SETTINGS_OPTIONS: readonly ChoiceOption[] = [ + { + value: 'model', + label: 'Model', + description: 'Switch the active model and thinking mode.', + }, + { + value: 'permission', + label: 'Permission', + description: 'Choose how tool actions are approved.', + }, + { + value: 'theme', + label: 'Theme', + description: 'Change the terminal UI theme.', + }, + { + value: 'editor', + label: 'Editor', + description: 'Set the external editor command.', + }, + { + value: 'usage', + label: 'Usage', + description: 'Show session tokens, context window, and plan quotas.', + }, +]; + +function isSettingsSelection(value: string): value is SettingsSelection { + return ( + value === 'model' || + value === 'theme' || + value === 'editor' || + value === 'permission' || + value === 'usage' + ); +} + +export interface SettingsSelectorOptions { + readonly colors: ColorPalette; + readonly onSelect: (value: SettingsSelection) => void; + readonly onCancel: () => void; +} + +export class SettingsSelectorComponent extends ChoicePickerComponent { + constructor(opts: SettingsSelectorOptions) { + super({ + title: 'Settings', + options: [...SETTINGS_OPTIONS], + colors: opts.colors, + onSelect: (value) => { + if (isSettingsSelection(value)) opts.onSelect(value); + }, + onCancel: opts.onCancel, + }); + } +} diff --git a/apps/kimi-code/src/tui/components/dialogs/task-output-viewer.ts b/apps/kimi-code/src/tui/components/dialogs/task-output-viewer.ts new file mode 100644 index 000000000..0f1fa04b7 --- /dev/null +++ b/apps/kimi-code/src/tui/components/dialogs/task-output-viewer.ts @@ -0,0 +1,261 @@ +/** + * TaskOutputViewer — full-screen pi-tui rendered output viewer for + * a single background task. Replaces the previous "shell out to less" + * approach so the experience stays inside the TUI: same colors, same + * fonts, same redraw cycle, no alt-screen flip-flop. + * + * Mounted by `kimi-tui.ts` via nested container swap on top of the + * TasksBrowserApp. Snapshot view (no live tail) — content is fetched + * once when the viewer opens. + */ + +import { + Container, + Key, + matchesKey, + type Terminal, + truncateToWidth, + visibleWidth, + type Focusable, +} from '@earendil-works/pi-tui'; +import type { BackgroundTaskInfo, BackgroundTaskStatus } from '@moonshot-ai/kimi-code-sdk'; +import chalk from 'chalk'; + +import type { ColorPalette } from '@/tui/theme/colors'; +import { printableChar } from '@/tui/utils/printable-key'; + +const ELLIPSIS = '…'; + +export interface TaskOutputViewerProps { + readonly taskId: string; + readonly info: BackgroundTaskInfo | undefined; + readonly output: string; + readonly colors: ColorPalette; + readonly onClose: () => void; +} + +const STATUS_LABEL: Record<BackgroundTaskStatus, string> = { + running: 'running', + awaiting_approval: 'awaiting', + completed: 'completed', + failed: 'failed', + killed: 'killed', + lost: 'lost', +}; + +function statusColor(colors: ColorPalette, status: BackgroundTaskStatus): string { + switch (status) { + case 'running': + return colors.success; + case 'awaiting_approval': + return colors.warning; + case 'completed': + return colors.textMuted; + case 'failed': + case 'killed': + case 'lost': + return colors.error; + } +} + +function padToWidth(line: string, width: number): string { + const w = visibleWidth(line); + if (w === width) return line; + if (w > width) return truncateToWidth(line, width, ELLIPSIS); + return line + ' '.repeat(width - w); +} + +function fitExactly(line: string, width: number): string { + let s = line; + if (visibleWidth(s) > width) s = truncateToWidth(s, width, ELLIPSIS); + return padToWidth(s, width); +} + +export class TaskOutputViewer extends Container implements Focusable { + focused = false; + + private props: TaskOutputViewerProps; + private readonly terminal: Terminal; + /** Output split on '\n'. Replaced on `setProps` when `output` changes. */ + private lines: string[]; + /** Index of the topmost visible line. */ + private scrollTop = 0; + + constructor(props: TaskOutputViewerProps, terminal: Terminal) { + super(); + this.props = props; + this.terminal = terminal; + this.lines = this.splitOutput(props.output); + } + + /** + * Update viewer props. When `output` grows (the watched task wrote + * new content), follow the tail like `less +F` if the user is parked + * at the bottom; otherwise keep the user's current scroll position + * so they can read history without being yanked around. + */ + setProps(next: TaskOutputViewerProps): void { + const previousOutput = this.props.output; + const wasAtBottom = this.scrollTop >= this.maxScroll(); + this.props = next; + if (next.output !== previousOutput) { + this.lines = this.splitOutput(next.output); + if (wasAtBottom) this.scrollTop = this.maxScroll(); + else this.scrollTop = Math.min(this.scrollTop, this.maxScroll()); + } + this.invalidate(); + } + + private splitOutput(output: string): string[] { + return (output.length > 0 ? output : '[no output captured]').split('\n'); + } + + // ── input ────────────────────────────────────────────────────────── + + handleInput(data: string): void { + const visible = this.viewableRows(); + const k = printableChar(data); + + if (matchesKey(data, Key.escape) || k === 'q' || k === 'Q') { + this.props.onClose(); + return; + } + if (matchesKey(data, Key.up) || k === 'k') { + this.scrollBy(-1); + return; + } + if (matchesKey(data, Key.down) || k === 'j') { + this.scrollBy(1); + return; + } + if (matchesKey(data, Key.pageUp) || k === ' ' || data === '\u0002' /* C-b */) { + this.scrollBy(-Math.max(1, visible - 1)); + return; + } + if (matchesKey(data, Key.pageDown) || data === '\u0006' /* C-f */) { + this.scrollBy(Math.max(1, visible - 1)); + return; + } + if (matchesKey(data, Key.home) || k === 'g') { + this.scrollTo(0); + return; + } + if (matchesKey(data, Key.end) || k === 'G') { + this.scrollTo(this.maxScroll()); + return; + } + } + + private scrollBy(delta: number): void { + this.scrollTo(this.scrollTop + delta); + } + + private scrollTo(target: number): void { + this.scrollTop = Math.max(0, Math.min(target, this.maxScroll())); + this.invalidate(); + } + + private maxScroll(): number { + return Math.max(0, this.lines.length - this.viewableRows()); + } + + /** + * Number of content rows visible inside the body frame: total terminal + * rows minus header(1) + footer(1) + top border(1) + bottom border(1). + */ + private viewableRows(): number { + return Math.max(1, this.terminal.rows - 4); + } + + // ── render ───────────────────────────────────────────────────────── + + override render(width: number): string[] { + const rows = Math.max(3, this.terminal.rows); + const bodyHeight = rows - 2; + + const header = this.renderHeader(width); + const body = this.renderBody(width, bodyHeight); + const footer = this.renderFooter(width, bodyHeight); + + const out: string[] = [header]; + for (const line of body) out.push(line); + out.push(footer); + return out; + } + + private renderHeader(width: number): string { + const colors = this.props.colors; + const title = chalk.hex(colors.primary).bold(' Task output '); + const id = chalk.hex(colors.text).bold(this.props.taskId); + const info = this.props.info; + const segments: string[] = []; + if (info !== undefined) { + segments.push(chalk.hex(statusColor(colors, info.status))(STATUS_LABEL[info.status])); + if (info.exitCode !== null && info.exitCode !== undefined) { + segments.push(chalk.hex(colors.textMuted)(`exit ${String(info.exitCode)}`)); + } + if (info.description && info.description.length > 0) { + segments.push(chalk.hex(colors.textMuted)(info.description)); + } + } + const composed = title + id + (segments.length > 0 ? ' ' + segments.join(' ') : ''); + return fitExactly(composed, width); + } + + private renderBody(width: number, bodyHeight: number): string[] { + const colors = this.props.colors; + const stroke = colors.primary; + + // Reserve 1 col for left/right border each, 1 col for left padding. + const innerWidth = Math.max(1, width - 4); + + // Re-clamp scroll in case the terminal got resized smaller. + const max = this.maxScroll(); + if (this.scrollTop > max) this.scrollTop = max; + if (this.scrollTop < 0) this.scrollTop = 0; + + const viewRows = bodyHeight - 2; // inside top + bottom border + const top = chalk.hex(stroke)('┌' + '─'.repeat(Math.max(0, width - 2)) + '┐'); + const bottom = chalk.hex(stroke)('└' + '─'.repeat(Math.max(0, width - 2)) + '┘'); + + const out: string[] = [top]; + for (let i = 0; i < viewRows; i++) { + const lineIndex = this.scrollTop + i; + const raw = this.lines[lineIndex] ?? ''; + const inner = fitExactly(chalk.hex(colors.text)(raw), innerWidth); + out.push(chalk.hex(stroke)('│ ') + inner + chalk.hex(stroke)(' │')); + } + out.push(bottom); + return out; + } + + private renderFooter(width: number, bodyHeight: number): string { + const colors = this.props.colors; + const key = (text: string): string => chalk.hex(colors.primary).bold(text); + const dim = (text: string): string => chalk.hex(colors.textMuted)(text); + + const total = this.lines.length; + const viewRows = Math.max(1, bodyHeight - 2); + const maxScroll = Math.max(0, total - viewRows); + const percent = + maxScroll === 0 ? 100 : Math.round((this.scrollTop / maxScroll) * 100); + const lineFrom = this.scrollTop + 1; + const lineTo = Math.min(total, this.scrollTop + viewRows); + + const position = chalk.hex(colors.textMuted)( + ` ${String(lineFrom)}-${String(lineTo)} / ${String(total)} (${String(percent)}%) `, + ); + const keys = + `${key('↑↓')} ${dim('line')} ` + + `${key('PgUp/PgDn')} ${dim('page')} ` + + `${key('g/G')} ${dim('top/bot')} ` + + `${key('Q/Esc')} ${dim('back')}`; + const left = ` ${keys}`; + const leftW = visibleWidth(left); + const rightW = visibleWidth(position); + if (leftW + 2 + rightW <= width) { + return left + ' '.repeat(width - leftW - rightW) + position; + } + return fitExactly(left, width); + } +} diff --git a/apps/kimi-code/src/tui/components/dialogs/tasks-browser.ts b/apps/kimi-code/src/tui/components/dialogs/tasks-browser.ts new file mode 100644 index 000000000..8634e045d --- /dev/null +++ b/apps/kimi-code/src/tui/components/dialogs/tasks-browser.ts @@ -0,0 +1,603 @@ +/** + * TasksBrowserApp — full-screen alt-screen takeover for browsing + * background tasks. Three-pane layout (left task list, right top + * detail, right bottom preview output) framed by a header row and + * footer key hint. + * + * Mounted by `kimi-tui.ts` via container swap rather than `showOverlay` + * — the main TUI's children are saved, cleared, and this component is + * added as the sole child so it covers the entire screen. The + * controller restores the children when the user exits. + * + * Data (tasks list, tail output) flows in via `setProps`; user actions + * fire the `on*` callbacks back to the controller. + */ + +import { + Container, + Key, + matchesKey, + type Terminal, + truncateToWidth, + visibleWidth, + type Focusable, +} from '@earendil-works/pi-tui'; +import type { BackgroundTaskInfo, BackgroundTaskStatus } from '@moonshot-ai/kimi-code-sdk'; +import chalk from 'chalk'; + +import type { ColorPalette } from '@/tui/theme/colors'; +import { printableChar } from '@/tui/utils/printable-key'; + +const ELLIPSIS = '…'; + +export type TasksFilter = 'all' | 'active'; + +export interface TasksBrowserProps { + readonly tasks: readonly BackgroundTaskInfo[]; + readonly filter: TasksFilter; + readonly selectedTaskId: string | undefined; + readonly tailOutput: string | undefined; + readonly tailLoading: boolean; + readonly flashMessage: string | undefined; + readonly colors: ColorPalette; + readonly onSelect: (taskId: string) => void; + readonly onToggleFilter: () => void; + readonly onRefresh: () => void; + readonly onCancel: () => void; + /** Fired when the user confirms a stop request via the inline `y` prompt. */ + readonly onStopConfirmed: (taskId: string) => void; + /** Fired when the user presses Enter or O on a selected task. */ + readonly onOpenOutput: (taskId: string) => void; + /** Fired when stop is requested on a task that cannot be stopped. */ + readonly onStopIgnored?: (taskId: string, reason: 'terminal') => void; +} + +const STATUS_LABEL: Record<BackgroundTaskStatus, string> = { + running: 'running', + awaiting_approval: 'awaiting', + completed: 'completed', + failed: 'failed', + killed: 'killed', + lost: 'lost', +}; + +/** Auto-cancel the inline stop confirmation after this many ms. */ +const STOP_CONFIRM_TIMEOUT_MS = 5_000; + +/** Minimum dimensions before we just print a "too small" message. */ +const MIN_WIDTH = 48; +const MIN_HEIGHT = 10; + +/** Hard caps so a tiny / huge terminal still gets a sensible left-column width. */ +const LIST_COL_MIN = 28; +const LIST_COL_MAX = 44; +const LIST_COL_RATIO = 0.32; + +function statusColor(colors: ColorPalette, status: BackgroundTaskStatus): string { + switch (status) { + case 'running': + return colors.success; + case 'awaiting_approval': + return colors.warning; + case 'completed': + return colors.textMuted; + case 'failed': + case 'killed': + case 'lost': + return colors.error; + } +} + +function isTerminal(status: BackgroundTaskStatus): boolean { + return ( + status === 'completed' || status === 'failed' || status === 'killed' || status === 'lost' + ); +} + +function formatRelativeTime(ts: number | null | undefined): string { + if (ts === null || ts === undefined || !Number.isFinite(ts) || ts <= 0) return ''; + const diffSec = Math.floor(Math.max(0, Date.now() - ts) / 1000); + if (diffSec < 60) return 'just now'; + const minutes = Math.floor(diffSec / 60); + if (minutes < 60) return `${String(minutes)}m ago`; + const hours = Math.floor(minutes / 60); + if (hours < 24) return `${String(hours)}h ago`; + const days = Math.floor(hours / 24); + return `${String(days)}d ago`; +} + +function singleLine(text: string): string { + return text.replaceAll(/\s+/g, ' ').trim(); +} + +function padToWidth(line: string, width: number): string { + const w = visibleWidth(line); + if (w === width) return line; + if (w > width) return truncateToWidth(line, width, ELLIPSIS); + return line + ' '.repeat(width - w); +} + +/** Fit `line` into exactly `width` columns, even after CJK-edge truncation. */ +function fitExactly(line: string, width: number): string { + let s = line; + if (visibleWidth(s) > width) s = truncateToWidth(s, width, ELLIPSIS); + return padToWidth(s, width); +} + +function visibleTasks( + tasks: readonly BackgroundTaskInfo[], + filter: TasksFilter, +): BackgroundTaskInfo[] { + if (filter === 'all') return [...tasks]; + return tasks.filter((t) => !isTerminal(t.status)); +} + +function compareTasks(a: BackgroundTaskInfo, b: BackgroundTaskInfo): number { + const aTerminal = isTerminal(a.status); + const bTerminal = isTerminal(b.status); + if (aTerminal !== bTerminal) return aTerminal ? 1 : -1; + if (!aTerminal) return a.startedAt - b.startedAt; + return (b.endedAt ?? b.startedAt) - (a.endedAt ?? a.startedAt); +} + +interface StatusCounts { + running: number; + awaiting: number; + completed: number; + terminalFailed: number; +} + +function countByStatus(tasks: readonly BackgroundTaskInfo[]): StatusCounts { + const counts: StatusCounts = { running: 0, awaiting: 0, completed: 0, terminalFailed: 0 }; + for (const t of tasks) { + switch (t.status) { + case 'running': + counts.running += 1; + break; + case 'awaiting_approval': + counts.awaiting += 1; + break; + case 'completed': + counts.completed += 1; + break; + case 'failed': + case 'killed': + case 'lost': + counts.terminalFailed += 1; + break; + } + } + return counts; +} + +export class TasksBrowserApp extends Container implements Focusable { + focused = false; + + private props: TasksBrowserProps; + private readonly terminal: Terminal; + private sortedVisible: BackgroundTaskInfo[]; + private selectedIndex = 0; + private listScroll = 0; + private pendingStopTaskId: string | undefined = undefined; + private pendingStopTimer: NodeJS.Timeout | undefined = undefined; + + constructor(props: TasksBrowserProps, terminal: Terminal) { + super(); + this.props = props; + this.terminal = terminal; + this.sortedVisible = visibleTasks(props.tasks, props.filter).toSorted(compareTasks); + this.syncSelectionFromProps(); + } + + setProps(next: TasksBrowserProps): void { + this.props = next; + this.sortedVisible = visibleTasks(next.tasks, next.filter).toSorted(compareTasks); + this.syncSelectionFromProps(); + if (this.pendingStopTaskId !== undefined) { + const task = next.tasks.find((t) => t.taskId === this.pendingStopTaskId); + if (task === undefined || isTerminal(task.status)) this.clearPendingStop(); + } + this.invalidate(); + } + + private syncSelectionFromProps(): void { + if (this.sortedVisible.length === 0) { + this.selectedIndex = 0; + this.listScroll = 0; + return; + } + if (this.props.selectedTaskId !== undefined) { + const idx = this.sortedVisible.findIndex((t) => t.taskId === this.props.selectedTaskId); + if (idx !== -1) { + this.selectedIndex = idx; + return; + } + } + if (this.selectedIndex >= this.sortedVisible.length) { + this.selectedIndex = this.sortedVisible.length - 1; + } + } + + private clearPendingStop(): void { + this.pendingStopTaskId = undefined; + if (this.pendingStopTimer !== undefined) { + clearTimeout(this.pendingStopTimer); + this.pendingStopTimer = undefined; + } + } + + private emitSelect(): void { + const task = this.sortedVisible[this.selectedIndex]; + if (task) this.props.onSelect(task.taskId); + } + + handleInput(data: string): void { + const k = printableChar(data); + + if (this.pendingStopTaskId !== undefined) { + if (k === 'y' || k === 'Y') { + const taskId = this.pendingStopTaskId; + this.clearPendingStop(); + this.props.onStopConfirmed(taskId); + this.invalidate(); + return; + } + this.clearPendingStop(); + this.invalidate(); + return; + } + + if (matchesKey(data, Key.escape) || k === 'q' || k === 'Q') { + this.props.onCancel(); + return; + } + if (matchesKey(data, Key.up) || k === 'k') { + if (this.sortedVisible.length === 0) return; + this.selectedIndex = Math.max(0, this.selectedIndex - 1); + this.emitSelect(); + this.invalidate(); + return; + } + if (matchesKey(data, Key.down) || k === 'j') { + if (this.sortedVisible.length === 0) return; + this.selectedIndex = Math.min(this.sortedVisible.length - 1, this.selectedIndex + 1); + this.emitSelect(); + this.invalidate(); + return; + } + if (matchesKey(data, Key.tab) || k === '\t') { + this.props.onToggleFilter(); + return; + } + if (k === 'r' || k === 'R') { + this.props.onRefresh(); + return; + } + if (k === 's' || k === 'S') { + const task = this.sortedVisible[this.selectedIndex]; + if (task === undefined) return; + if (isTerminal(task.status)) { + this.props.onStopIgnored?.(task.taskId, 'terminal'); + return; + } + this.pendingStopTaskId = task.taskId; + this.pendingStopTimer = setTimeout(() => { + this.clearPendingStop(); + this.invalidate(); + }, STOP_CONFIRM_TIMEOUT_MS); + this.invalidate(); + return; + } + if (k === 'o' || k === 'O' || matchesKey(data, Key.enter)) { + const task = this.sortedVisible[this.selectedIndex]; + if (task) this.props.onOpenOutput(task.taskId); + return; + } + } + + /** + * Render the entire screen as `terminal.rows` lines of `width` cols. + * Layout: header(1) + body(rows-2) + footer(1). + */ + override render(width: number): string[] { + const rows = Math.max(1, this.terminal.rows); + if (width < MIN_WIDTH || rows < MIN_HEIGHT) { + return this.renderTooSmall(width, rows); + } + + const header = this.renderHeader(width); + const footer = this.renderFooter(width); + const bodyHeight = rows - 2; + + const listWidth = Math.max( + LIST_COL_MIN, + Math.min(LIST_COL_MAX, Math.floor(width * LIST_COL_RATIO)), + ); + const rightWidth = width - listWidth; + + const listFrame = this.renderListFrame(listWidth, bodyHeight); + const rightFrames = this.renderRightStack(rightWidth, bodyHeight); + + const lines: string[] = [header]; + for (let i = 0; i < bodyHeight; i++) { + lines.push((listFrame[i] ?? ' '.repeat(listWidth)) + (rightFrames[i] ?? ' '.repeat(rightWidth))); + } + lines.push(footer); + return lines; + } + + // ── header / footer ────────────────────────────────────────────────── + + private renderHeader(width: number): string { + const colors = this.props.colors; + const title = chalk.hex(colors.primary).bold(' TASK BROWSER '); + const filterText = chalk.hex(colors.textMuted)( + ` filter=${this.props.filter === 'all' ? 'ALL' : 'ACTIVE'} `, + ); + const counts = countByStatus(this.props.tasks); + const countSegments: string[] = []; + if (counts.running > 0) + countSegments.push(chalk.hex(colors.success)(` ${String(counts.running)} running `)); + if (counts.awaiting > 0) + countSegments.push(chalk.hex(colors.warning)(` ${String(counts.awaiting)} awaiting `)); + if (counts.completed > 0) + countSegments.push(chalk.hex(colors.textDim)(` ${String(counts.completed)} completed `)); + if (counts.terminalFailed > 0) + countSegments.push( + chalk.hex(colors.error)(` ${String(counts.terminalFailed)} interrupted `), + ); + const totals = chalk.hex(colors.textMuted)(` ${String(this.props.tasks.length)} total `); + + const composed = title + filterText + countSegments.join('') + totals; + return fitExactly(composed, width); + } + + private renderFooter(width: number): string { + const colors = this.props.colors; + const key = (text: string): string => chalk.hex(colors.primary).bold(text); + const dim = (text: string): string => chalk.hex(colors.textMuted)(text); + + if (this.pendingStopTaskId !== undefined) { + const warn = (text: string): string => chalk.hex(colors.warning).bold(text); + const line = + ` ${warn('Stop')} ${chalk.hex(colors.text)(this.pendingStopTaskId)}? ` + + `${key('Y')} ${dim('confirm')} ${key('N')} ${dim('cancel')} `; + return fitExactly(line, width); + } + + const parts = [ + ` ${key('↑↓')} ${dim('select')}`, + `${key('Enter/O')} ${dim('output')}`, + `${key('S')} ${dim('stop')}`, + `${key('R')} ${dim('refresh')}`, + `${key('Tab')} ${dim('filter')}`, + `${key('Q/Esc')} ${dim('exit')} `, + ]; + const left = parts.join(' '); + const flash = this.props.flashMessage; + if (flash !== undefined && flash.length > 0) { + const flashStyled = chalk.hex(colors.warning)(` ${flash} `); + const total = visibleWidth(left) + visibleWidth(flashStyled); + if (total <= width) { + return left + ' '.repeat(width - total) + flashStyled; + } + } + return fitExactly(left, width); + } + + // ── frame primitive ────────────────────────────────────────────────── + + /** + * Render a framed box: `┌─ Title ─┐` top, `│ <content> │` sides, `└─┘` + * bottom. Result is exactly `width × height` cells. `content` is a + * pre-rendered array of inner-width-sized lines; extra rows are padded. + */ + private renderFrame( + title: string, + content: readonly string[], + width: number, + height: number, + ): string[] { + if (height < 2 || width < 4) { + const out: string[] = []; + for (let i = 0; i < height; i++) out.push(' '.repeat(width)); + return out; + } + const stroke = this.props.colors.primary; + const innerWidth = width - 2; + const innerHeight = height - 2; + + const titleStyled = chalk.hex(this.props.colors.textStrong).bold(title); + const titleWidth = visibleWidth(titleStyled); + const titleSegment = `─ ${titleStyled} `; + const titleSegmentWidth = visibleWidth(titleSegment); + const remainingDashes = Math.max(0, innerWidth - titleSegmentWidth); + const topMid = + titleWidth > 0 && titleSegmentWidth <= innerWidth + ? chalk.hex(stroke)('─ ') + + titleStyled + + ' ' + + chalk.hex(stroke)('─'.repeat(remainingDashes)) + : chalk.hex(stroke)('─'.repeat(innerWidth)); + const top = chalk.hex(stroke)('┌') + topMid + chalk.hex(stroke)('┐'); + const bottom = chalk.hex(stroke)('└' + '─'.repeat(innerWidth) + '┘'); + + const lines: string[] = [top]; + for (let i = 0; i < innerHeight; i++) { + const inner = content[i] ?? ''; + lines.push(chalk.hex(stroke)('│') + fitExactly(inner, innerWidth) + chalk.hex(stroke)('│')); + } + lines.push(bottom); + return lines; + } + + // ── left: task list frame ──────────────────────────────────────────── + + private renderListFrame(width: number, height: number): string[] { + const title = `Tasks [${this.props.filter}]`; + const innerHeight = Math.max(0, height - 2); + + if (this.sortedVisible.length === 0) { + const empty = + this.props.filter === 'active' + ? 'No active tasks. Tab = show all.' + : 'No background tasks in this session.'; + const lines: string[] = [chalk.hex(this.props.colors.textMuted)(empty)]; + while (lines.length < innerHeight) lines.push(''); + return this.renderFrame(title, lines, width, height); + } + + this.adjustScroll(innerHeight); + const start = this.listScroll; + const window = this.sortedVisible.slice(start, start + innerHeight); + + const innerWidth = width - 2; + const lines: string[] = []; + for (const [vi, task] of window.entries()) { + const index = start + vi; + lines.push(this.renderListRow(task, index === this.selectedIndex, innerWidth)); + } + while (lines.length < innerHeight) lines.push(''); + + return this.renderFrame(title, lines, width, height); + } + + private renderListRow(task: BackgroundTaskInfo, selected: boolean, innerWidth: number): string { + const colors = this.props.colors; + const pointer = selected ? '> ' : ' '; + const pointerStyled = chalk.hex(selected ? colors.primary : colors.textDim)(pointer); + + const idColor = selected ? colors.primary : task.taskId.startsWith('agent-') + ? colors.success + : colors.accent; + const idText = selected + ? chalk.hex(idColor).bold(task.taskId) + : chalk.hex(idColor)(task.taskId); + const idPad = ' '.repeat(Math.max(0, 17 - task.taskId.length)); + + const status = STATUS_LABEL[task.status]; + const statusBadge = chalk.hex(statusColor(colors, task.status))(status); + + const prefix = `${pointerStyled}${idText}${idPad} ${statusBadge}`; + const prefixWidth = visibleWidth(prefix); + const descBudget = Math.max(0, innerWidth - prefixWidth - 1); + if (descBudget < 4) return fitExactly(prefix, innerWidth); + + const description = + singleLine(task.description) || singleLine(task.command) || '(no description)'; + const desc = truncateToWidth(description, descBudget, ELLIPSIS); + return fitExactly(`${prefix} ${chalk.hex(colors.text)(desc)}`, innerWidth); + } + + private adjustScroll(visibleRows: number): void { + if (visibleRows <= 0) { + this.listScroll = 0; + return; + } + if (this.selectedIndex < this.listScroll) { + this.listScroll = this.selectedIndex; + } else if (this.selectedIndex >= this.listScroll + visibleRows) { + this.listScroll = this.selectedIndex - visibleRows + 1; + } + const maxScroll = Math.max(0, this.sortedVisible.length - visibleRows); + if (this.listScroll < 0) this.listScroll = 0; + if (this.listScroll > maxScroll) this.listScroll = maxScroll; + } + + // ── right: detail + preview stack ──────────────────────────────────── + + private renderRightStack(width: number, height: number): string[] { + // Detail gets ~8 rows (or 40% of body, whichever is larger). Preview + // takes the rest. Both rendered as separate frames stacked vertically. + const detailHeight = Math.max(8, Math.min(Math.floor(height * 0.4), height - 5)); + const previewHeight = height - detailHeight; + return [ + ...this.renderDetailFrame(width, detailHeight), + ...this.renderPreviewFrame(width, previewHeight), + ]; + } + + private renderDetailFrame(width: number, height: number): string[] { + const colors = this.props.colors; + const innerHeight = Math.max(0, height - 2); + const task = this.sortedVisible[this.selectedIndex]; + if (task === undefined) { + const empty = chalk.hex(colors.textMuted)('Select a task from the list.'); + const lines: string[] = [empty]; + while (lines.length < innerHeight) lines.push(''); + return this.renderFrame('Detail', lines, width, height); + } + + const label = (text: string): string => chalk.hex(colors.textMuted)(text.padEnd(14)); + const value = (text: string): string => chalk.hex(colors.text)(text); + + const lines: string[] = [ + `${label('Task ID:')}${value(task.taskId)}`, + `${label('Status:')}${chalk.hex(statusColor(colors, task.status))(STATUS_LABEL[task.status])}`, + `${label('Description:')}${value(singleLine(task.description) || '—')}`, + ]; + if (task.command && task.command !== task.description) { + lines.push(`${label('Command:')}${value(singleLine(task.command))}`); + } + const timing = + task.status === 'running' || task.status === 'awaiting_approval' + ? `running ${formatRelativeTime(task.startedAt)}` + : task.endedAt !== null && task.endedAt !== undefined + ? `finished ${formatRelativeTime(task.endedAt)}` + : ''; + if (timing.length > 0) lines.push(`${label('Time:')}${chalk.hex(colors.textMuted)(timing)}`); + if (task.pid > 0) lines.push(`${label('Pid:')}${chalk.hex(colors.textMuted)(String(task.pid))}`); + if (task.exitCode !== null && task.exitCode !== undefined) { + lines.push(`${label('Exit code:')}${chalk.hex(colors.textMuted)(String(task.exitCode))}`); + } + if (task.stopReason !== undefined && task.stopReason.length > 0) { + lines.push(`${label('Stop reason:')}${chalk.hex(colors.textMuted)(task.stopReason)}`); + } + if (task.timedOut === true) { + lines.push(`${label('Timed out:')}${chalk.hex(colors.warning)('yes')}`); + } + if (task.approvalReason !== undefined && task.approvalReason.length > 0) { + lines.push( + `${label('Awaiting:')}${chalk.hex(colors.warning)(singleLine(task.approvalReason))}`, + ); + } + + while (lines.length < innerHeight) lines.push(''); + return this.renderFrame('Detail', lines, width, height); + } + + private renderPreviewFrame(width: number, height: number): string[] { + const colors = this.props.colors; + const innerHeight = Math.max(0, height - 2); + const task = this.sortedVisible[this.selectedIndex]; + if (task === undefined) { + const lines: string[] = [chalk.hex(colors.textMuted)('No task selected.')]; + while (lines.length < innerHeight) lines.push(''); + return this.renderFrame('Preview Output', lines, width, height); + } + + let body: string; + if (this.props.tailLoading) body = '[loading…]'; + else if (this.props.tailOutput === undefined || this.props.tailOutput.length === 0) + body = '[no output captured]'; + else body = this.props.tailOutput; + + const rawLines = body.split('\n'); + const tailLines = rawLines.slice(-innerHeight); + const styled = tailLines.map((line) => chalk.hex(colors.textDim)(line)); + while (styled.length < innerHeight) styled.push(''); + return this.renderFrame('Preview Output', styled, width, height); + } + + // ── too-small fallback ────────────────────────────────────────────── + + private renderTooSmall(width: number, rows: number): string[] { + const lines: string[] = []; + const msg = chalk.hex(this.props.colors.error)( + `Terminal too small (need ≥ ${String(MIN_WIDTH)} × ${String(MIN_HEIGHT)})`, + ); + lines.push(fitExactly(msg, width)); + for (let i = 1; i < rows; i++) lines.push(' '.repeat(width)); + return lines; + } +} diff --git a/apps/kimi-code/src/tui/components/dialogs/theme-selector.ts b/apps/kimi-code/src/tui/components/dialogs/theme-selector.ts new file mode 100644 index 000000000..8d6381c61 --- /dev/null +++ b/apps/kimi-code/src/tui/components/dialogs/theme-selector.ts @@ -0,0 +1,36 @@ +import { ChoicePickerComponent, type ChoiceOption } from './choice-picker'; + +import type { ColorPalette } from '#/tui/theme/colors'; +import type { Theme } from '#/tui/theme/index'; + +const THEME_OPTIONS: readonly ChoiceOption[] = [ + { value: 'auto', label: 'Auto (match terminal)' }, + { value: 'dark', label: 'Dark' }, + { value: 'light', label: 'Light' }, +]; + +function isThemeChoice(value: string): value is Theme { + return value === 'auto' || value === 'dark' || value === 'light'; +} + +export interface ThemeSelectorOptions { + readonly currentValue: Theme; + readonly colors: ColorPalette; + readonly onSelect: (theme: Theme) => void; + readonly onCancel: () => void; +} + +export class ThemeSelectorComponent extends ChoicePickerComponent { + constructor(opts: ThemeSelectorOptions) { + super({ + title: 'Select theme', + options: [...THEME_OPTIONS], + currentValue: opts.currentValue, + colors: opts.colors, + onSelect: (value) => { + if (isThemeChoice(value)) opts.onSelect(value); + }, + onCancel: opts.onCancel, + }); + } +} diff --git a/apps/kimi-code/src/tui/components/editor/custom-editor.ts b/apps/kimi-code/src/tui/components/editor/custom-editor.ts new file mode 100644 index 000000000..8450d37e4 --- /dev/null +++ b/apps/kimi-code/src/tui/components/editor/custom-editor.ts @@ -0,0 +1,362 @@ +/** + * Custom editor extending pi-tui Editor with app-level keybindings. + */ + +import { Editor, isKeyRelease, matchesKey, Key, type TUI } from '@earendil-works/pi-tui'; +import chalk from 'chalk'; + +import type { ColorPalette } from '#/tui/theme/colors'; +import { createEditorTheme } from '#/tui/theme/pi-tui-theme'; + +// oxlint-disable-next-line no-control-regex -- ESC (\x1b) is required to match ANSI SGR escape sequences +const ANSI_SGR = /\u001B\[[0-9;]*m/g; + +// Kitty keyboard protocol CSI-u sequence: ESC [ keycode ; modifier[:eventType] u. +// We intentionally match only the simple two-field form — enough to rewrite +// `ctrl+<LETTER>` with caps_lock into `ctrl+<letter>` without caps_lock. +// oxlint-disable-next-line no-control-regex -- ESC (\x1b) is required to match CSI +const KITTY_CSI_U = /^\u001B\[(\d+);(\d+)((?::\d+)*)u$/; +// Kitty modifier bit layout: shift=1, alt=2, ctrl=4, super=8, hyper=16, +// meta=32, caps_lock=64, num_lock=128. Reported value is `mask + 1`. +const CAPS_LOCK_BIT = 64; +const CTRL_BIT = 4; +const SHIFT_BIT = 1; + +interface AutocompleteInternals { + cancelAutocomplete(): void; + readonly autocompleteAbort?: AbortController; + readonly autocompleteDebounceTimer?: ReturnType<typeof setTimeout>; +} + +/** + * Workaround for a pi-tui bug that surfaces when Kitty keyboard protocol + * is active AND caps_lock is on. In that state terminals emit, e.g., + * `ESC[68;69u` for ctrl+d (codepoint=68=`D`, modifier=ctrl|caps_lock). + * pi-tui's `matchesKittySequence` masks `caps_lock` out of the *modifier* + * but leaves the *codepoint* capitalised, so `matchesKey(data, "ctrl+d")` + * (which expects codepoint=100=`d`) fails and every ctrl-shortcut is + * silently dropped. + * + * We rewrite the sequence back to its unlocked form before dispatching, + * but only when ctrl is held and shift is not — i.e. exactly the + * `ctrl+<letter>` case. Plain uppercase (caps_lock only, no ctrl) and + * explicit ctrl+shift+<letter> are left alone. + */ +export function normalizeCapsLockedCtrl(data: string): string { + const m = data.match(KITTY_CSI_U); + if (m === null) return data; + const codepoint = Number(m[1]); + const modifierPlus1 = Number(m[2]); + const tail = m[3] ?? ''; + if (!Number.isFinite(codepoint) || !Number.isFinite(modifierPlus1)) return data; + const modifier = modifierPlus1 - 1; + if ((modifier & CAPS_LOCK_BIT) === 0) return data; + if ((modifier & CTRL_BIT) === 0) return data; + if ((modifier & SHIFT_BIT) !== 0) return data; + if (codepoint < 65 || codepoint > 90) return data; + const loweredCodepoint = codepoint + 32; + const strippedModifier = (modifier & ~CAPS_LOCK_BIT) + 1; + return `\u001B[${String(loweredCodepoint)};${String(strippedModifier)}${tail}u`; +} + +/** Convert a visible-char index (ANSI-stripped) back to an index into the raw ANSI-bearing string. */ +function mapVisibleIdxToRaw(line: string, visibleIdx: number): number { + let visibleCount = 0; + let i = 0; + const re = new RegExp(ANSI_SGR.source, 'y'); + while (i < line.length && visibleCount < visibleIdx) { + re.lastIndex = i; + const m = re.exec(line); + if (m !== null && m.index === i) { + i += m[0].length; + } else { + visibleCount++; + i++; + } + } + return i; +} + +function stripSgr(s: string): string { + return s.replace(ANSI_SGR, ''); +} + +function isNewlineShortcut(data: string): boolean { + return data === '\n' || data === '\u001B\r' || data === '\u001B[13;2~'; +} + +export class CustomEditor extends Editor { + public onEscape?: () => void; + public onCtrlD?: () => void; + public onCtrlC?: () => void; + public onToggleToolExpand?: () => void; + // Returns true when a plan card actually handled the toggle. When it + // returns false (no plan in the transcript) the keystroke falls through + // to pi-tui's default ctrl+e binding (move cursor to end of line). + public onTogglePlanExpand?: () => boolean; + public onOpenExternalEditor?: () => void; + public onCtrlS?: () => void; + public onUndo?: () => void; + public onInsertNewline?: () => void; + public onTextPaste?: () => void; + /** + * Called when ↑ is pressed in an empty editor. Return `true` to consume + * the key (e.g. recalled a queued message); return `false` to fall + * through so pi-tui's built-in history navigation runs. + */ + public onUpArrowEmpty?: () => boolean; + public onShiftTab?: () => void; + /** + * Called when the user triggers "paste image" (Ctrl-V on Unix, + * Alt-V on Windows — Ctrl-V is terminal-reserved there). Return + * `true` to consume the key (image was read and handled); return + * `false` to let the key fall through to the normal paste path. + * The callback may be async; pi-tui awaits it before dispatching + * the next keystroke. + */ + public onPasteImage?: () => Promise<boolean>; + + /** + * `colors` is the live `ColorPalette` reference — the host mutates it + * in place on theme switch (`Object.assign(state.theme.colors, ...)`), so + * reading `this.colors.<token>` at render time always sees the + * current theme without any setter plumbing. The `EditorTheme` that + * pi-tui's `Editor` requires is derived from the same palette, and + * `paddingX: 2` reserves the two leading columns where `render()` + * paints the terminal-style `> ` prompt — both are implementation + * details, not caller knobs. + */ + constructor( + tui: TUI, + private readonly colors: ColorPalette, + ) { + // paddingX: 4 reserves column 0 for the left vertical border (│), + // column 1 as a single space between border and prompt, column 2 for + // the `>` prompt token, and column 3 as the space between prompt and + // content. The right side mirrors with 3 padding columns and the right + // border at the last column. + super(tui, createEditorTheme(colors), { paddingX: 4 }); + } + + private hasAutocompleteActivity(): boolean { + const autocomplete = this as unknown as AutocompleteInternals; + return ( + this.isShowingAutocomplete() || + autocomplete.autocompleteAbort !== undefined || + autocomplete.autocompleteDebounceTimer !== undefined + ); + } + + private cancelAutocompleteActivity(): void { + // pi-tui exposes `isShowingAutocomplete()` but keeps cancellation private. + // Kimi needs Esc to win over app-level cancel while the slash menu request is active. + (this as unknown as AutocompleteInternals).cancelAutocomplete(); + } + + override render(width: number): string[] { + const lines = super.render(width); + if (lines.length < 3) return lines; + const firstContentIdx = 1; + const text = this.getText().trimStart(); + if (text.startsWith('/')) { + // Paint only the FIRST editor content line; multi-line slash commands + // are not a thing in practice. + const original = lines[firstContentIdx]; + if (original !== undefined) { + const highlighted = highlightFirstSlashToken(original, this.colors.primary); + if (highlighted !== undefined) { + lines[firstContentIdx] = highlighted; + } + } + } + const firstContent = lines[firstContentIdx]; + if (firstContent !== undefined) { + const withPrompt = injectPromptSymbol(firstContent); + if (withPrompt !== undefined) { + lines[firstContentIdx] = withPrompt; + } + } + // `this.borderColor` is pi-tui's per-render paint function. The host may + // overwrite it (e.g. plan-mode / slash-context highlight via + // `editor.borderColor = chalk.hex(primary)`), so we route corners and + // side bars through the same hook to stay in sync. + return wrapWithSideBorders(lines, (s) => this.borderColor(s)); + } + + override handleInput(data: string): void { + const normalized = normalizeCapsLockedCtrl(data); + if (isKeyRelease(normalized)) { + return; + } + // Paste image binding — platform-aware: + // Windows terminals reserve Ctrl-V for their own paste handling + // (e.g. Windows Terminal's Ctrl+V shortcut), so we listen for + // Alt-V there. Everywhere else Ctrl-V pastes. When the host + // reports no image available, we fall through to pi-tui's + // normal paste path so text from the clipboard still works. + const pasteKey = process.platform === 'win32' ? 'alt+v' : Key.ctrl('v'); + if (matchesKey(normalized, pasteKey) && this.onPasteImage !== undefined) { + const handler = this.onPasteImage; + void handler().then((handled) => { + if (!handled) { + this.onTextPaste?.(); + // No image on the clipboard — forward the original keystroke + // through the base handler so a textual clipboard still works. + super.handleInput.call(this, normalized); + } + }); + return; + } + + if (matchesKey(normalized, Key.ctrl('d'))) { + if (this.getText().length === 0) { + this.onCtrlD?.(); + return; + } + } + + if (matchesKey(normalized, Key.ctrl('c'))) { + this.onCtrlC?.(); + return; + } + + if (matchesKey(normalized, Key.ctrl('g'))) { + this.onOpenExternalEditor?.(); + return; + } + + if (matchesKey(normalized, Key.ctrl('o'))) { + this.onToggleToolExpand?.(); + return; + } + + if (matchesKey(normalized, Key.ctrl('e'))) { + if (this.onTogglePlanExpand?.() === true) return; + // No plan to toggle — fall through to pi-tui's end-of-line. + } + + if (matchesKey(normalized, Key.ctrl('s'))) { + this.onCtrlS?.(); + return; + } + + if (matchesKey(normalized, 'shift+tab')) { + this.onShiftTab?.(); + return; + } + + if (matchesKey(normalized, Key.ctrl('-'))) { + this.onUndo?.(); + } + + if (isNewlineShortcut(normalized)) { + this.onInsertNewline?.(); + } + + if (matchesKey(normalized, Key.up)) { + if (this.getText().length === 0 && this.onUpArrowEmpty) { + if (this.onUpArrowEmpty()) return; + // fall through to super so Editor's built-in history navigation runs + } + } + + if (matchesKey(normalized, Key.escape)) { + if (this.hasAutocompleteActivity()) { + this.cancelAutocompleteActivity(); + return; + } + this.onEscape?.(); + return; + } + + super.handleInput(normalized); + } +} + +/** + * Return a copy of `line` with the first `/token` coloured using `hex`. + * `line` may already contain SGR escapes (cursor inverse, etc.); we + * locate `/` via visible-index math so ANSI pass-through survives. + * Returns `undefined` if no token is found. + */ +export function highlightFirstSlashToken(line: string, hex: string): string | undefined { + const visible = stripSgr(line); + const slashIdx = visible.indexOf('/'); + if (slashIdx < 0) return undefined; + // Guard: only paint when `/` is the first non-whitespace character + // on the line (avoids colouring a mid-sentence slash). + for (let i = 0; i < slashIdx; i++) { + if (visible[i] !== ' ' && visible[i] !== '\t') return undefined; + } + // Token ends at the next whitespace (or the visible end). + let endVisible = slashIdx + 1; + while (endVisible < visible.length) { + const ch = visible[endVisible]; + if (ch === ' ' || ch === '\t') break; + endVisible++; + } + const visibleToken = visible.slice(slashIdx, endVisible); + if (visibleToken.slice(1).includes('/')) return undefined; + const rawStart = mapVisibleIdxToRaw(line, slashIdx); + const rawEnd = mapVisibleIdxToRaw(line, endVisible); + const before = line.slice(0, rawStart); + const token = line.slice(rawStart, rawEnd); + const after = line.slice(rawEnd); + return before + chalk.hex(hex).bold(token) + after; +} + +/** + * Overlay a terminal-style `> ` prompt symbol on the first content line. + * Column 0 is reserved for the left vertical border (overlaid later by + * wrapWithSideBorders); column 1 is a single-space gap, so the `>` token + * lives at column 2 with column 3 separating it from content. + * Relies on the editor being configured with `paddingX >= 4` so the line + * starts with at least four literal spaces. Emits no SGR so the terminal's + * default foreground colour renders the symbol. Returns `undefined` if the + * line is too short or doesn't begin with the expected padding. + */ +export function injectPromptSymbol(line: string): string | undefined { + if (line.length < 4) return undefined; + for (let i = 0; i < 4; i++) { + if (line[i] !== ' ') return undefined; + } + return ' > ' + line.slice(4); +} + +/** + * Post-process pi-tui's editor output to draw a full box around it. + * + * pi-tui only renders horizontal top/bottom borders; we wrap them with + * `╭╮╰╯` corners and add vertical `│` bars on each row's outer columns. + * Horizontal-border rows (those whose first visible char is `─`, including + * scroll indicators like `── ↑ N more ──`) are stripped of their existing + * SGR and repainted as a single box-drawn span. Content rows keep their + * inner SGR intact; only column 0 and the last column are overlaid, and + * only if they're literal spaces — that protects the cursor-overflow + * case where the rightmost column is an SGR-tagged inverse cursor. + */ +export function wrapWithSideBorders( + lines: string[], + paint: (s: string) => string, +): string[] { + let seenTop = false; + return lines.map((line) => { + const plain = stripSgr(line); + if (plain.length > 0 && plain[0] === '─') { + const leftCorner = seenTop ? '╰' : '╭'; + const rightCorner = seenTop ? '╯' : '╮'; + seenTop = true; + if (plain.length === 1) return paint(leftCorner); + const middle = plain.slice(1, -1); + return paint(leftCorner + middle + rightCorner); + } + if (line.length === 0) return line; + const firstCh = line[0]; + const lastCh = line.at(-1); + const head = firstCh === ' ' ? paint('│') : (firstCh ?? ''); + const tail = + line.length > 1 && lastCh === ' ' ? paint('│') : (lastCh ?? ''); + if (line.length === 1) return head; + return head + line.slice(1, -1) + tail; + }); +} diff --git a/apps/kimi-code/src/tui/components/editor/file-mention-provider.ts b/apps/kimi-code/src/tui/components/editor/file-mention-provider.ts new file mode 100644 index 000000000..d2a7d39c1 --- /dev/null +++ b/apps/kimi-code/src/tui/components/editor/file-mention-provider.ts @@ -0,0 +1,272 @@ +/** + * `@file` autocomplete provider for the input box. + * + * pi-tui's `CombinedAutocompleteProvider` handles the mechanical parts + * (extract `@…` prefix, insert completion with the right quoting). This + * wrapper adds kimi-specific ranking + filtering so the default "empty + * `@`" list surfaces files the user actually wants, not alphabetical + * noise from `.agents/skills/*` et al. + * + * Sort order — empty query: + * 1. recently edited (from `git log --name-only`) + * 2. recent fs mtime + * 3. basename alphabetical + * (first 15, not 50 — pi-tui's menu height is ~6-10 lines anyway) + * + * Sort order — non-empty query (strict to fuzzy): + * cat 0: basename starts-with query + * cat 1: basename contains query + * cat 2: fuzzyMatch succeeds on full path + * tie-break within each cat: recency rank → mtime → basename length + * (first 50) + * + * Filter — dot directories are hidden by default. User can opt in by starting the query + * with `.` (e.g. `@.github/`), since those paths rarely need + * completion. + * + * When `fd` is available the inner pi-tui provider owns the `@` branch + * verbatim — its fd invocation respects `.gitignore` and is strictly + * better than anything we can cheaply reproduce in TS. We only kick in + * when `fd` is missing AND we're in a git repo. + */ + +import { basename } from 'node:path'; + +import { + CombinedAutocompleteProvider, + fuzzyFilter, + fuzzyMatch, + type AutocompleteItem, + type AutocompleteProvider, + type AutocompleteSuggestions, + type SlashCommand, +} from '@earendil-works/pi-tui'; + +import type { GitLsFilesCache, GitSnapshot } from '#/utils/git/git-ls-files'; + +const MAX_SUGGESTIONS_WHEN_QUERY = 50; +const MAX_SUGGESTIONS_WHEN_EMPTY = 15; + +// Mirrors pi-tui's PATH_DELIMITERS. Keeping a local copy so @-detection +// stays aligned even if pi-tui extends its set. +const PATH_DELIMITERS = new Set([' ', '\t', '"', "'", '=']); + +export class FileMentionProvider implements AutocompleteProvider { + private readonly inner: CombinedAutocompleteProvider; + + constructor( + slashCommands: SlashCommand[], + workDir: string, + private readonly fdPath: string | null, + private readonly gitCache: GitLsFilesCache, + ) { + this.inner = new CombinedAutocompleteProvider(slashCommands, workDir, fdPath); + } + + async getSuggestions( + lines: string[], + cursorLine: number, + cursorCol: number, + options: { signal: AbortSignal; force?: boolean }, + ): Promise<AutocompleteSuggestions | null> { + const textBeforeCursor = (lines[cursorLine] ?? '').slice(0, cursorCol); + const atPrefix = extractAtPrefix(textBeforeCursor); + + // Non-`@` branch (slash commands, `/path`, quoted paths) — pi-tui + // already owns the edge cases. No intercept. + if (atPrefix === null) { + return this.inner.getSuggestions(lines, cursorLine, cursorCol, options); + } + + // `fd` available → inner's fuzzy search is strictly better than our + // git fallback (fd respects .gitignore AND covers unstaged paths + // without a second spawn). Accept its output as-is. + if (this.fdPath !== null) { + return this.inner.getSuggestions(lines, cursorLine, cursorCol, options); + } + + const snapshot = this.gitCache.getSnapshot(); + if (snapshot === null || snapshot.files.length === 0) { + return this.inner.getSuggestions(lines, cursorLine, cursorCol, options); + } + + const query = atPrefix.slice(1); // strip leading '@' + const includeDotDirs = query.startsWith('.'); + const candidates = includeDotDirs + ? snapshot.files + : snapshot.files.filter((p) => !containsDotSegment(p)); + + const items = + query.length === 0 + ? rankForEmptyQuery(candidates, snapshot) + : rankForQuery(candidates, query, snapshot); + + if (items.length === 0) { + // Git cache had nothing useful — fall through to readdir (user + // may be typing a path that exists but isn't tracked, e.g. a + // freshly created file not yet in the 2s cache). + return this.inner.getSuggestions(lines, cursorLine, cursorCol, options); + } + return { items, prefix: atPrefix }; + } + + applyCompletion( + lines: string[], + cursorLine: number, + cursorCol: number, + item: AutocompleteItem, + prefix: string, + ): { lines: string[]; cursorLine: number; cursorCol: number } { + // Reuse pi-tui's insertion logic — it handles `@` prefix, quoted + // paths, directory trailing slash. Our item shape matches what + // pi-tui produces. + return this.inner.applyCompletion(lines, cursorLine, cursorCol, item, prefix); + } +} + +/** + * Return the `@…` token ending at the cursor, or `null` if we're not in + * an `@` mention. Mirrors pi-tui's `extractAtPrefix` — the token + * boundary is the last PATH_DELIMITER before the cursor, and the token + * must start with `@`. + */ +function extractAtPrefix(text: string): string | null { + let tokenStart = 0; + for (let i = text.length - 1; i >= 0; i -= 1) { + if (PATH_DELIMITERS.has(text[i] ?? '')) { + tokenStart = i + 1; + break; + } + } + if (text[tokenStart] !== '@') return null; + return text.slice(tokenStart); +} + +/** True when any path segment starts with a dot (e.g. `.github/x.yml`). */ +function containsDotSegment(path: string): boolean { + for (const segment of path.split('/')) { + if (segment.startsWith('.')) return true; + } + return false; +} + +/** + * Empty-query ranking: stratified by signal strength. + * + * Layer 1: files touched in the last RECENT_COMMIT_DEPTH commits, + * ordered by how recently. Strongest signal — if the user + * just worked on it, they probably want to mention it. + * Layer 2: files with the newest fs mtime (covers uncommitted edits + * and files edited but not yet added to git). + * Layer 3: everything else, alphabetical by basename so + * README/package.json-style top-level files bubble up + * relative to deeply-nested alphabetical paths. + * + * Cap at MAX_SUGGESTIONS_WHEN_EMPTY. Layers fill in order; dedup by + * path so a recently-edited file isn't also listed in layer 2. + */ +function rankForEmptyQuery(files: readonly string[], snapshot: GitSnapshot): AutocompleteItem[] { + const picked = new Set<string>(); + const result: string[] = []; + const cap = MAX_SUGGESTIONS_WHEN_EMPTY; + const inFiles = new Set(files); + + // Layer 1 — git log recency. + const byRecency = [...snapshot.recencyOrder.entries()] + .filter(([path]) => inFiles.has(path)) + .toSorted((a, b) => a[1] - b[1]); + for (const [path] of byRecency) { + if (result.length >= cap) break; + if (picked.has(path)) continue; + picked.add(path); + result.push(path); + } + + // Layer 2 — fs mtime. + if (result.length < cap) { + const byMtime = files + .filter((p) => !picked.has(p) && snapshot.mtimeByPath.has(p)) + .toSorted((a, b) => (snapshot.mtimeByPath.get(b) ?? 0) - (snapshot.mtimeByPath.get(a) ?? 0)); + for (const path of byMtime) { + if (result.length >= cap) break; + picked.add(path); + result.push(path); + } + } + + // Layer 3 — alphabetical by basename. + if (result.length < cap) { + const rest = files + .filter((p) => !picked.has(p)) + .toSorted((a, b) => basename(a).localeCompare(basename(b)) || a.localeCompare(b)); + for (const path of rest) { + if (result.length >= cap) break; + result.push(path); + } + } + + return result.map(toItem); +} + +/** + * Non-empty-query ranking: three strictness tiers, with recency / + * mtime as tie-breakers inside each tier so "the readme you just + * edited" beats "a readme deep in a vendor dir". + */ +function rankForQuery( + files: readonly string[], + query: string, + snapshot: GitSnapshot, +): AutocompleteItem[] { + const lowerQuery = query.toLowerCase(); + const scored: Array<{ path: string; cat: number; fuzzyScore: number }> = []; + for (const path of files) { + const base = basename(path).toLowerCase(); + if (base.startsWith(lowerQuery)) { + scored.push({ path, cat: 0, fuzzyScore: 0 }); + continue; + } + if (base.includes(lowerQuery)) { + scored.push({ path, cat: 1, fuzzyScore: 0 }); + continue; + } + const fuzzy = fuzzyMatch(query, path); + if (fuzzy.matches) { + scored.push({ path, cat: 2, fuzzyScore: fuzzy.score }); + } + } + + if (scored.length === 0) { + // pi-tui's fuzzyFilter is slightly different (token-splitting); + // try it as a last-resort safety net. + return fuzzyFilter([...files], query, (p) => p) + .slice(0, MAX_SUGGESTIONS_WHEN_QUERY) + .map(toItem); + } + + scored.sort((a, b) => { + if (a.cat !== b.cat) return a.cat - b.cat; + if (a.cat === 2 && a.fuzzyScore !== b.fuzzyScore) return a.fuzzyScore - b.fuzzyScore; + const ra = snapshot.recencyOrder.get(a.path); + const rb = snapshot.recencyOrder.get(b.path); + if (ra !== undefined && rb !== undefined && ra !== rb) return ra - rb; + if (ra !== undefined && rb === undefined) return -1; + if (ra === undefined && rb !== undefined) return 1; + const ma = snapshot.mtimeByPath.get(a.path) ?? 0; + const mb = snapshot.mtimeByPath.get(b.path) ?? 0; + if (ma !== mb) return mb - ma; + const baseLenDiff = basename(a.path).length - basename(b.path).length; + if (baseLenDiff !== 0) return baseLenDiff; + return a.path.localeCompare(b.path); + }); + + return scored.slice(0, MAX_SUGGESTIONS_WHEN_QUERY).map((entry) => toItem(entry.path)); +} + +function toItem(path: string): AutocompleteItem { + return { + value: `@${path}`, + label: basename(path), + description: path, + }; +} diff --git a/apps/kimi-code/src/tui/components/index.ts b/apps/kimi-code/src/tui/components/index.ts new file mode 100644 index 000000000..bcff5b2ac --- /dev/null +++ b/apps/kimi-code/src/tui/components/index.ts @@ -0,0 +1,35 @@ +export * from './chrome/device-code-box'; +export * from './chrome/footer'; +export * from './chrome/moon-loader'; +export * from './chrome/todo-panel'; +export * from './chrome/welcome'; +export * from './dialogs/approval-panel'; +export * from './dialogs/choice-picker'; +export * from './dialogs/compaction'; +export * from './dialogs/editor-selector'; +export * from './dialogs/help-panel'; +export * from './dialogs/model-selector'; +export * from './dialogs/permission-selector'; +export * from './dialogs/question-dialog'; +export * from './dialogs/session-picker'; +export * from './dialogs/settings-selector'; +export * from './dialogs/theme-selector'; +export * from './editor/custom-editor'; +export * from './editor/file-mention-provider'; +export * from './media/code-highlight'; +export * from './media/diff-preview'; +export * from './media/image-thumbnail'; +export * from './messages/agent-group'; +export * from './messages/assistant-message'; +export * from './messages/background-agent-status'; +export * from './messages/plan-box'; +export * from './messages/read-group'; +export * from './messages/shell-execution'; +export * from './messages/skill-activation'; +export * from './messages/status-message'; +export * from './messages/thinking'; +export * from './messages/tool-call'; +export * from './messages/usage-panel'; +export * from './messages/user-message'; +export * from './panes/activity-pane'; +export * from './panes/queue-pane'; diff --git a/apps/kimi-code/src/tui/components/media/code-highlight.ts b/apps/kimi-code/src/tui/components/media/code-highlight.ts new file mode 100644 index 000000000..bfef3d91b --- /dev/null +++ b/apps/kimi-code/src/tui/components/media/code-highlight.ts @@ -0,0 +1,52 @@ +/** + * Shared syntax-highlighting helpers for code previews + * (tool-call Write/Edit, approval-panel Write content, etc.). + */ + +import { extname } from 'node:path'; + +import { highlight, supportsLanguage } from 'cli-highlight'; + +const EXT_LANG_MAP: Record<string, string> = { + ts: 'typescript', + tsx: 'typescript', + js: 'javascript', + jsx: 'javascript', + py: 'python', + rb: 'ruby', + rs: 'rust', + go: 'go', + java: 'java', + sh: 'bash', + bash: 'bash', + zsh: 'bash', + json: 'json', + yaml: 'yaml', + yml: 'yaml', + toml: 'toml', + md: 'markdown', + css: 'css', + html: 'html', + sql: 'sql', + c: 'c', + cpp: 'cpp', + h: 'c', + hpp: 'cpp', +}; + +export function langFromPath(filePath: string): string | undefined { + const ext = extname(filePath).slice(1).toLowerCase(); + if (ext.length === 0) return undefined; + const lang = EXT_LANG_MAP[ext] ?? ext; + return supportsLanguage(lang) ? lang : undefined; +} + +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'); + } catch { + return code.split('\n'); + } +} diff --git a/apps/kimi-code/src/tui/components/media/diff-preview.ts b/apps/kimi-code/src/tui/components/media/diff-preview.ts new file mode 100644 index 000000000..5a978723d --- /dev/null +++ b/apps/kimi-code/src/tui/components/media/diff-preview.ts @@ -0,0 +1,310 @@ +/** + * Diff preview rendering as plain ANSI strings. + * + * Reuses the diff algorithm from approval/DiffPreview.tsx, but outputs + * formatted text lines instead of React elements. + */ + +import chalk from 'chalk'; + +import type { ColorPalette } from '#/tui/theme/colors'; + +export type DiffLineKind = 'context' | 'add' | 'delete'; + +interface DiffStyles { + add: (s: string) => string; + del: (s: string) => string; + addBold: (s: string) => string; + delBold: (s: string) => string; + gutter: (s: string) => string; + meta: (s: string) => string; +} + +function makeDiffStyles(colors: ColorPalette): DiffStyles { + return { + add: (s) => chalk.hex(colors.diffAdded)(s), + del: (s) => chalk.hex(colors.diffRemoved)(s), + addBold: (s) => chalk.bold.hex(colors.diffAddedStrong)(s), + delBold: (s) => chalk.bold.hex(colors.diffRemovedStrong)(s), + gutter: (s) => chalk.hex(colors.diffGutter)(s), + meta: (s) => chalk.hex(colors.diffMeta)(s), + }; +} + +export interface DiffLine { + kind: DiffLineKind; + lineNum: number; + code: string; +} + +export function computeDiffLines( + oldText: string, + newText: string, + oldStart: number = 1, + newStart: number = 1, + isIncomplete: boolean = false, +): DiffLine[] { + const oldLines = oldText ? oldText.split('\n') : []; + const newLines = newText ? newText.split('\n') : []; + const m = oldLines.length; + const n = newLines.length; + + const dp: number[][] = Array.from({ length: m + 1 }, () => + Array.from({ length: n + 1 }, () => 0), + ); + for (let i = 1; i <= m; i++) { + for (let j = 1; j <= n; j++) { + if (oldLines[i - 1] === newLines[j - 1]) { + dp[i]![j] = dp[i - 1]![j - 1]! + 1; + } else { + dp[i]![j] = Math.max(dp[i - 1]![j]!, dp[i]![j - 1]!); + } + } + } + + const reversed: DiffLine[] = []; + let i = m; + let j = n; + while (i > 0 || j > 0) { + if (i > 0 && j > 0 && oldLines[i - 1] === newLines[j - 1]) { + reversed.push({ kind: 'context', lineNum: newStart + j - 1, code: newLines[j - 1]! }); + i--; + j--; + } else if (j > 0 && (i === 0 || dp[i]![j - 1]! >= dp[i - 1]![j]!)) { + reversed.push({ kind: 'add', lineNum: newStart + j - 1, code: newLines[j - 1]! }); + j--; + } else { + reversed.push({ kind: 'delete', lineNum: oldStart + i - 1, code: oldLines[i - 1]! }); + i--; + } + } + + const result: DiffLine[] = []; + for (let k = reversed.length - 1; k >= 0; k--) { + result.push(reversed[k]!); + } + + // While the text is still streaming, suppress trailing delete lines. + // They are likely artefacts of newText not having arrived yet rather + // than genuine deletions. + if (isIncomplete && result.length > 0) { + let lastNonDelete = result.length - 1; + while (lastNonDelete >= 0 && result[lastNonDelete]!.kind === 'delete') { + lastNonDelete--; + } + if (lastNonDelete >= 0) { + result.length = lastNonDelete + 1; + } else { + // Every line would be shown as deleted; suppress them all so the + // UI doesn't flash a wall of red before newText starts arriving. + result.length = 0; + } + } + + return result; +} + +export function renderDiffLines( + oldText: string, + newText: string, + path: string, + colors: ColorPalette, + isIncomplete: boolean = false, + oldStart?: number, + newStart?: number, + maxLines?: number, +): string[] { + const s = makeDiffStyles(colors); + const diffLines = computeDiffLines(oldText, newText, oldStart ?? 1, newStart ?? 1, isIncomplete); + const changedLines = diffLines.filter((l) => l.kind !== 'context'); + const added = changedLines.filter((l) => l.kind === 'add').length; + const removed = changedLines.filter((l) => l.kind === 'delete').length; + + const output: string[] = []; + + let header = ''; + if (added > 0) header += s.addBold(`+${String(added)} `); + if (removed > 0) header += s.delBold(`-${String(removed)} `); + header += path; + output.push(header); + + const shown = + maxLines !== undefined && maxLines >= 0 && changedLines.length > maxLines + ? changedLines.slice(0, maxLines) + : changedLines; + + for (const line of shown) { + const marker = line.kind === 'add' ? '+' : '-'; + const color = line.kind === 'add' ? s.add : s.del; + output.push(s.gutter(String(line.lineNum).padStart(4) + ' ') + color(marker + ' ' + line.code)); + } + + const hidden = changedLines.length - shown.length; + if (hidden > 0) { + output.push( + s.meta( + ` … ${String(hidden)} more change${hidden > 1 ? 's' : ''} hidden (ctrl+o to expand)`, + ), + ); + } + + return output; +} + +export interface ClusteredDiffOptions { + readonly contextLines?: number; + readonly maxLines?: number; + readonly isIncomplete?: boolean; + readonly expandKeyHint?: string; +} + +interface Cluster { + readonly start: number; + readonly end: number; +} + +function buildClusters( + diffLines: DiffLine[], + contextLines: number, +): { clusters: Cluster[]; changedCount: number; addedCount: number; removedCount: number } { + const changeIndices: number[] = []; + let added = 0; + let removed = 0; + for (const [i, line] of diffLines.entries()) { + if (line.kind === 'add') { + added++; + changeIndices.push(i); + } else if (line.kind === 'delete') { + removed++; + changeIndices.push(i); + } + } + + const clusters: Cluster[] = []; + if (changeIndices.length === 0) { + return { clusters, changedCount: 0, addedCount: added, removedCount: removed }; + } + + const mergeGap = 2 * contextLines; + let groupStart = changeIndices[0]!; + let groupEnd = changeIndices[0]!; + for (let i = 1; i < changeIndices.length; i++) { + const idx = changeIndices[i]!; + if (idx - groupEnd <= mergeGap) { + groupEnd = idx; + } else { + clusters.push({ + start: Math.max(0, groupStart - contextLines), + end: Math.min(diffLines.length - 1, groupEnd + contextLines), + }); + groupStart = idx; + groupEnd = idx; + } + } + clusters.push({ + start: Math.max(0, groupStart - contextLines), + end: Math.min(diffLines.length - 1, groupEnd + contextLines), + }); + + return { + clusters, + changedCount: changeIndices.length, + addedCount: added, + removedCount: removed, + }; +} + +function formatDiffRow(line: DiffLine, s: DiffStyles): string { + const gutter = s.gutter(String(line.lineNum).padStart(4) + ' '); + if (line.kind === 'add') return gutter + s.add('+ ' + line.code); + if (line.kind === 'delete') return gutter + s.del('- ' + line.code); + return gutter + ' ' + line.code; +} + +/** + * Render a diff with surrounding context, eliding unchanged middle + * regions between change clusters with a `… N unchanged lines …` + * separator. When `maxLines` is set, the body is capped at a cluster + * boundary and a `ctrl+o to expand` footer is appended. + * + * Used by Edit's call preview where we want to show *what changed* + * with enough context to read the change, but not the whole file. + */ +export function renderDiffLinesClustered( + oldText: string, + newText: string, + path: string, + colors: ColorPalette, + opts: ClusteredDiffOptions = {}, +): string[] { + const s = makeDiffStyles(colors); + const contextLines = opts.contextLines ?? 3; + const maxLines = opts.maxLines; + const diffLines = computeDiffLines(oldText, newText, 1, 1, opts.isIncomplete ?? false); + const { clusters, changedCount, addedCount, removedCount } = buildClusters( + diffLines, + contextLines, + ); + + const output: string[] = []; + let header = ''; + if (addedCount > 0) header += s.addBold(`+${String(addedCount)} `); + if (removedCount > 0) header += s.delBold(`-${String(removedCount)} `); + header += path; + output.push(header); + + if (clusters.length === 0) return output; + + const cap = maxLines !== undefined && maxLines >= 0 ? maxLines : Number.POSITIVE_INFINITY; + let body = 0; + let prevEnd = -1; + let truncated = false; + let shownChanges = 0; + + outer: for (const cluster of clusters) { + if (body >= cap) { + truncated = true; + break; + } + if (prevEnd >= 0) { + const gap = cluster.start - prevEnd - 1; + if (gap > 0) { + if (body + 1 > cap) { + truncated = true; + break; + } + output.push(s.meta(` … ${String(gap)} unchanged line${gap > 1 ? 's' : ''} …`)); + body++; + } + } + // Emit cluster rows one at a time; allow mid-cluster truncation so + // a single huge cluster (e.g. the whole file replaced inline) still + // shows the leading lines instead of degenerating to "N changes + // hidden" with no body at all. + for (let i = cluster.start; i <= cluster.end; i++) { + if (body >= cap) { + truncated = true; + break outer; + } + const line = diffLines[i]!; + output.push(formatDiffRow(line, s)); + body++; + if (line.kind !== 'context') shownChanges++; + prevEnd = i; + } + } + + if (truncated) { + const hidden = changedCount - shownChanges; + if (hidden > 0) { + const hint = opts.expandKeyHint ?? 'ctrl+o'; + output.push( + s.meta( + ` … ${String(hidden)} more change${hidden > 1 ? 's' : ''} hidden (${hint} to expand)`, + ), + ); + } + } + + return output; +} diff --git a/apps/kimi-code/src/tui/components/media/image-thumbnail.ts b/apps/kimi-code/src/tui/components/media/image-thumbnail.ts new file mode 100644 index 000000000..86253582f --- /dev/null +++ b/apps/kimi-code/src/tui/components/media/image-thumbnail.ts @@ -0,0 +1,55 @@ +/** + * Transcript-side rendering of a pasted image. + * + * On terminals that speak the Kitty graphics protocol or iTerm2 inline + * image protocol (detected by pi-tui's `getCapabilities()`), we show + * the actual image. Everywhere else we fall back to a one-line text + * marker matching the placeholder the user sees in the input box — + * this keeps the transcript readable on Terminal.app / Linux default + * terminals / `script` recordings without extra chrome. + * + * Height is capped at ~12 rows so a single screenshot can't monopolize + * the viewport; pi-tui handles proportional scaling internally. + */ + +import { Container, Image, Text, type ImageTheme, getCapabilities } from '@earendil-works/pi-tui'; +import chalk from 'chalk'; + +import type { ColorPalette } from '#/tui/theme/colors'; +import type { ImageAttachment } from '#/tui/utils/image-attachment-store'; + +const MAX_IMAGE_ROWS = 12; +const MAX_IMAGE_WIDTH = 40; + +export class ImageThumbnail extends Container { + constructor(attachment: ImageAttachment, colors: ColorPalette) { + super(); + + const caps = getCapabilities(); + const supportsInline = caps.images === 'kitty' || caps.images === 'iterm2'; + + if (!supportsInline) { + // Non-graphic terminal — show the placeholder text in dim cyan so + // it's clearly an attachment reference but doesn't shout. + this.addChild(new Text(chalk.hex(colors.accent)(attachment.placeholder), 0, 0)); + return; + } + + const theme: ImageTheme = { + fallbackColor: (s: string) => chalk.hex(colors.textDim)(s), + }; + const base64 = Buffer.from(attachment.bytes).toString('base64'); + const image = new Image( + base64, + attachment.mime, + theme, + { + maxHeightCells: MAX_IMAGE_ROWS, + maxWidthCells: MAX_IMAGE_WIDTH, + filename: attachment.placeholder, + }, + { widthPx: attachment.width, heightPx: attachment.height }, + ); + this.addChild(image); + } +} diff --git a/apps/kimi-code/src/tui/components/messages/agent-group.ts b/apps/kimi-code/src/tui/components/messages/agent-group.ts new file mode 100644 index 000000000..593ae9746 --- /dev/null +++ b/apps/kimi-code/src/tui/components/messages/agent-group.ts @@ -0,0 +1,240 @@ +/** + * AgentGroupComponent renders 2+ Agent tool calls from the same step as one group. + * + * Design: + * - State container: each child Agent keeps its real state in its + * `ToolCallComponent` (subagent meta, phase, sub-tool calls, tokens, text). + * AgentGroup only stores references and does not copy state. Event handlers + * still route through `state.pendingToolComponents.get(parent_tool_call_id)`. + * - Subscription: `attach` registers a snapshot listener on each child so the + * group can refresh when child state changes. + * - Throttling: normal changes are coalesced into one render every 200ms. + * Phase transitions (spawning -> running -> done/failed) flush immediately. + * - Mounting: `KimiTUI` attaches the group to the transcript at the + * right time; the group handles `invalidate` plus `ui.requestRender`. + * - Ungrouping is not implemented. Once formed, a group stays grouped. + */ + +import type { TUI } from '@earendil-works/pi-tui'; +import { Container, Spacer, Text } from '@earendil-works/pi-tui'; +import chalk from 'chalk'; + +import { STATUS_BULLET } from '#/tui/constant/symbols'; +import type { ColorPalette } from '#/tui/theme/colors'; + +import type { ToolCallComponent, ToolCallSubagentSnapshot } from './tool-call'; + +const THROTTLE_MS = 200; + +interface AgentEntry { + readonly toolCallId: string; + readonly tc: ToolCallComponent; +} + +export class AgentGroupComponent extends Container { + private readonly entries: AgentEntry[] = []; + private readonly headerText: Text; + private readonly bodyContainer: Container; + private throttleTimer: ReturnType<typeof setTimeout> | null = null; + private lastFlushPhases = new Map<string, ToolCallSubagentSnapshot['phase']>(); + + constructor( + private readonly colors: ColorPalette, + private readonly ui: TUI | undefined, + ) { + super(); + this.addChild(new Spacer(1)); + this.headerText = new Text('', 0, 0); + this.addChild(this.headerText); + this.bodyContainer = new Container(); + this.addChild(this.bodyContainer); + } + + size(): number { + return this.entries.length; + } + + /** + * Borrows a standalone `ToolCallComponent` into the group as a hidden state + * container. Snapshot changes trigger throttled refreshes. Re-attaching the + * same toolCallId is a no-op. + */ + attach(toolCallId: string, tc: ToolCallComponent): void { + if (this.entries.some((e) => e.toolCallId === toolCallId)) return; + this.entries.push({ toolCallId, tc }); + tc.setSnapshotListener(() => { + this.scheduleRender(); + }); + this.flushRender(); + } + + /** + * Schedules a repaint. Real phase transitions force an immediate refresh; + * other changes such as latestActivity, tokens, or toolCount are throttled. + */ + private scheduleRender(): void { + if (this.detectPhaseTransition()) { + this.flushRender(); + return; + } + if (this.throttleTimer !== null) return; + this.throttleTimer = setTimeout(() => { + this.throttleTimer = null; + this.flushRender(); + }, THROTTLE_MS); + } + + /** + * Compares each child's current phase with the phase captured at the last + * flush. Any change is treated as a phase transition. + */ + private detectPhaseTransition(): boolean { + let changed = false; + for (const e of this.entries) { + const phase = e.tc.getSubagentSnapshot().phase; + if (this.lastFlushPhases.get(e.toolCallId) !== phase) { + changed = true; + break; + } + } + return changed; + } + + private flushRender(): void { + if (this.throttleTimer !== null) { + clearTimeout(this.throttleTimer); + this.throttleTimer = null; + } + + const snapshots = this.entries.map((e) => e.tc.getSubagentSnapshot()); + this.headerText.setText(this.buildHeader(snapshots)); + this.bodyContainer.clear(); + snapshots.forEach((snap, idx) => { + const isLast = idx === snapshots.length - 1; + this.appendLines(snap, isLast); + }); + + this.lastFlushPhases.clear(); + this.entries.forEach((entry, i) => { + const snap = snapshots[i]; + if (snap !== undefined) this.lastFlushPhases.set(entry.toolCallId, snap.phase); + }); + + this.invalidate(); + this.ui?.requestRender(); + } + + private buildHeader(snapshots: readonly ToolCallSubagentSnapshot[]): string { + const colors = this.colors; + const total = snapshots.length; + const done = snapshots.filter((s) => s.phase === 'done').length; + const failed = snapshots.filter((s) => s.phase === 'failed').length; + const finished = done + failed; + const allDone = finished === total; + const bullet = allDone + ? chalk.hex(colors.success)(STATUS_BULLET) + : chalk.hex(colors.roleAssistant)(STATUS_BULLET); + + if (allDone) { + const types = new Set(snapshots.map((s) => s.agentName).filter((n) => n !== undefined)); + const headerLabel = + types.size === 1 + ? `${String(total)} ${[...types][0]} agents finished` + : `${String(total)} agents finished`; + const totalTools = snapshots.reduce((acc, s) => acc + s.toolCount, 0); + const totalTokens = snapshots.reduce((acc, s) => acc + s.tokens, 0); + const tail = formatHeaderTail(totalTools, totalTokens); + return `${bullet}${chalk.hex(colors.primary).bold(headerLabel)}${tail}`; + } + + let headerText = `Running ${String(total)} agents`; + // Mixed status gets a breakdown so the current state is clear. + if (finished > 0) { + const running = total - finished; + const parts: string[] = []; + if (done > 0) parts.push(`${String(done)} done`); + if (failed > 0) parts.push(`${String(failed)} failed`); + if (running > 0) parts.push(`${String(running)} running`); + headerText = `Running ${String(total)} agents (${parts.join(', ')})`; + } + return `${bullet}${chalk.hex(colors.primary).bold(headerText)}`; + } + + private appendLines(snap: ToolCallSubagentSnapshot, isLast: boolean): void { + const colors = this.colors; + const dim = chalk.dim; + + // First-level branch line. + const branch1 = isLast ? '└─' : '├─'; + const agentType = snap.agentName ?? 'agent'; + const desc = snap.toolCallDescription || '(no description)'; + const tail = formatLineTail(snap, colors); + const namePart = chalk.hex(colors.primary)(agentType); + const descPart = dim(`· ${desc}`); + const stats = formatStats(snap); + const line1 = ` ${branch1} ${namePart} ${descPart}${stats}${tail}`; + this.bodyContainer.addChild(new Text(line1, 0, 0)); + + // Second-level line: latest activity, or Error for failures. + const branch2 = isLast ? ' ' : '│ '; + if (snap.phase === 'failed') { + // Show one error line; error messages can be long. + const errLine = (snap.errorText ?? 'Failed').split('\n').at(0) ?? 'Failed'; + const errStr = chalk.hex(colors.error)(`Error: ${errLine}`); + this.bodyContainer.addChild(new Text(` ${branch2} ${errStr}`, 0, 0)); + return; + } + if (snap.phase === 'done' || snap.phase === 'backgrounded') { + // Terminal states omit the second line. + return; + } + // Running or not-yet-started agents show latest activity, with a fallback. + const activity = snap.latestActivity ?? 'Initializing…'; + this.bodyContainer.addChild(new Text(` ${branch2} ${dim(activity)}`, 0, 0)); + } + + /** Releases throttle timers so destroyed components cannot refresh later. */ + dispose(): void { + if (this.throttleTimer !== null) { + clearTimeout(this.throttleTimer); + this.throttleTimer = null; + } + for (const e of this.entries) { + e.tc.setSnapshotListener(undefined); + } + } +} + +function formatStats(snap: ToolCallSubagentSnapshot): string { + const dim = chalk.dim; + const tools = ` · ${String(snap.toolCount)} tool${snap.toolCount === 1 ? '' : 's'}`; + const tokens = snap.tokens > 0 ? ` · ${formatTokens(snap.tokens)}` : ''; + return dim(`${tools}${tokens}`); +} + +function formatLineTail(snap: ToolCallSubagentSnapshot, colors: ColorPalette): string { + if (snap.phase === 'done') { + return chalk.dim(' · ') + chalk.hex(colors.success)('✓ Completed'); + } + if (snap.phase === 'failed') { + return chalk.dim(' · ') + chalk.hex(colors.error)('✗ Failed'); + } + if (snap.phase === 'backgrounded') { + return chalk.dim(' · ◐ backgrounded'); + } + return ''; +} + +function formatHeaderTail(toolCount: number, tokens: number): string { + const dim = chalk.dim; + const parts: string[] = []; + if (toolCount > 0) parts.push(`${String(toolCount)} tool${toolCount === 1 ? '' : 's'}`); + if (tokens > 0) parts.push(formatTokens(tokens)); + return parts.length > 0 ? dim(` · ${parts.join(' · ')}`) : ''; +} + +function formatTokens(n: number): string { + if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M tok`; + if (n >= 1_000) return `${(n / 1_000).toFixed(1)}k tok`; + return `${String(n)} tok`; +} diff --git a/apps/kimi-code/src/tui/components/messages/assistant-message.ts b/apps/kimi-code/src/tui/components/messages/assistant-message.ts new file mode 100644 index 000000000..1be89b2ca --- /dev/null +++ b/apps/kimi-code/src/tui/components/messages/assistant-message.ts @@ -0,0 +1,63 @@ +/** + * Renders an assistant message using pi-tui Markdown. + * + * Displays a white bullet prefix with markdown content indented + * to align after the bullet. + */ + +import type { Component, MarkdownTheme } from '@earendil-works/pi-tui'; +import { Container, Markdown, visibleWidth } from '@earendil-works/pi-tui'; +import chalk from 'chalk'; + +import { MESSAGE_INDENT } from '#/tui/constant/rendering'; +import { STATUS_BULLET } from '#/tui/constant/symbols'; +import type { ColorPalette } from '#/tui/theme/colors'; + +export class AssistantMessageComponent implements Component { + private contentContainer: Container; + private markdownTheme: MarkdownTheme; + private bulletColor: string; + private lastText = ''; + private showBullet: boolean; + + constructor(markdownTheme: MarkdownTheme, colors: ColorPalette, showBullet: boolean = true) { + this.markdownTheme = markdownTheme; + this.bulletColor = colors.roleAssistant; + this.showBullet = showBullet; + this.contentContainer = new Container(); + } + + setShowBullet(show: boolean): void { + this.showBullet = show; + } + + updateContent(text: string): void { + const displayText = text; + if (displayText === this.lastText) return; + this.lastText = displayText; + this.contentContainer.clear(); + if (displayText.trim().length > 0) { + this.contentContainer.addChild(new Markdown(displayText.trim(), 0, 0, this.markdownTheme)); + } + } + + invalidate(): void { + this.contentContainer.invalidate?.(); + } + + render(width: number): string[] { + if (this.lastText.trim().length === 0) return []; + + const prefix = this.showBullet ? STATUS_BULLET : MESSAGE_INDENT; + const contentWidth = Math.max(1, width - visibleWidth(prefix)); + const contentLines = this.contentContainer.render(contentWidth); + + const lines: string[] = ['']; + for (let i = 0; i < contentLines.length; i++) { + const p = + i === 0 && this.showBullet ? chalk.hex(this.bulletColor)(STATUS_BULLET) : MESSAGE_INDENT; + lines.push(p + contentLines[i]); + } + return lines; + } +} diff --git a/apps/kimi-code/src/tui/components/messages/background-agent-status.ts b/apps/kimi-code/src/tui/components/messages/background-agent-status.ts new file mode 100644 index 000000000..c1086f805 --- /dev/null +++ b/apps/kimi-code/src/tui/components/messages/background-agent-status.ts @@ -0,0 +1,42 @@ +import type { Component } from '@earendil-works/pi-tui'; +import { Text } from '@earendil-works/pi-tui'; +import chalk from 'chalk'; + +import { MESSAGE_INDENT } from '#/tui/constant/rendering'; +import { FAILURE_MARK, STATUS_BULLET } from '#/tui/constant/symbols'; +import type { ColorPalette } from '#/tui/theme/colors'; +import type { BackgroundAgentStatusData } from '#/tui/types'; + +export class BackgroundAgentStatusComponent implements Component { + constructor( + private readonly data: BackgroundAgentStatusData, + private readonly colors: ColorPalette, + ) {} + + invalidate(): void {} + + render(width: number): string[] { + const tone = + this.data.phase === 'started' + ? this.colors.primary + : this.data.phase === 'completed' + ? this.colors.success + : this.colors.error; + + const bullet = + this.data.phase === 'failed' ? chalk.hex(tone)(FAILURE_MARK) : chalk.hex(tone)(STATUS_BULLET); + const text = + chalk.hex(tone)(this.data.headline) + + (this.data.detail !== undefined && this.data.detail.length > 0 + ? chalk.hex(this.colors.textDim)(` (${this.data.detail})`) + : ''); + + const textComponent = new Text(text, 0, 0); + const contentWidth = Math.max(1, width - MESSAGE_INDENT.length); + const contentLines = textComponent.render(contentWidth); + return [ + '', + ...contentLines.map((line, index) => (index === 0 ? bullet : MESSAGE_INDENT) + line), + ]; + } +} diff --git a/apps/kimi-code/src/tui/components/messages/mcp-status-panel.ts b/apps/kimi-code/src/tui/components/messages/mcp-status-panel.ts new file mode 100644 index 000000000..9b7905eb0 --- /dev/null +++ b/apps/kimi-code/src/tui/components/messages/mcp-status-panel.ts @@ -0,0 +1,143 @@ +import type { McpServerInfo } from '@moonshot-ai/kimi-code-sdk'; +import chalk from 'chalk'; + +import type { ColorPalette } from '#/tui/theme/colors'; + +export interface McpStatusReportOptions { + readonly colors: ColorPalette; + readonly servers: readonly McpServerInfo[]; +} + +const STATUS_PRIORITY: Record<McpServerInfo['status'], number> = { + failed: 0, + 'needs-auth': 1, + pending: 2, + connected: 3, + disabled: 4, +}; + +const STATUS_LABEL: Record<McpServerInfo['status'], string> = { + connected: 'connected', + pending: 'pending', + 'needs-auth': 'needs auth', + failed: 'failed', + disabled: 'disabled', +}; + +const SUMMARY_ORDER: readonly McpServerInfo['status'][] = [ + 'connected', + 'pending', + 'needs-auth', + 'failed', + 'disabled', +]; + +function statusPainter( + status: McpServerInfo['status'], + colors: ColorPalette, +): (text: string) => string { + switch (status) { + case 'connected': + return chalk.hex(colors.success); + case 'failed': + return chalk.hex(colors.error); + case 'needs-auth': + case 'pending': + return chalk.hex(colors.warning); + case 'disabled': + return chalk.hex(colors.textDim); + } +} + +function formatToolCount(server: McpServerInfo): string { + if (server.status === 'disabled') return '—'; + return `${server.toolCount} tool${server.toolCount === 1 ? '' : 's'}`; +} + +function formatToolsAvailable(count: number): string { + return `${count} tool${count === 1 ? '' : 's'} available`; +} + +function sortedServers(servers: readonly McpServerInfo[]): McpServerInfo[] { + return servers.toSorted( + (a, b) => + STATUS_PRIORITY[a.status] - STATUS_PRIORITY[b.status] || a.name.localeCompare(b.name), + ); +} + +function buildSummary(servers: readonly McpServerInfo[]): string { + const counts: Partial<Record<McpServerInfo['status'], number>> = {}; + let toolsAvailable = 0; + for (const server of servers) { + counts[server.status] = (counts[server.status] ?? 0) + 1; + if (server.status === 'connected') toolsAvailable += server.toolCount; + } + const parts: string[] = []; + for (const status of SUMMARY_ORDER) { + const n = counts[status]; + if (n === undefined || n === 0) continue; + parts.push(`${n} ${STATUS_LABEL[status]}`); + } + parts.push(formatToolsAvailable(toolsAvailable)); + return parts.join(' · '); +} + +export function buildMcpStatusReportLines(options: McpStatusReportOptions): string[] { + const servers = sortedServers(options.servers); + const colors = options.colors; + const accent = chalk.hex(colors.primary).bold; + const muted = chalk.hex(colors.textDim); + const value = chalk.hex(colors.text); + const error = chalk.hex(colors.error); + + const lines: string[] = [accent('Servers')]; + + if (servers.length === 0) { + lines.push(muted(' No MCP servers configured. Run /mcp-config to add one.')); + return lines; + } + + const nameWidth = Math.max('Name'.length, ...servers.map((server) => server.name.length)); + const statusWidth = Math.max( + 'Status'.length, + ...servers.map((server) => STATUS_LABEL[server.status].length), + ); + const transportWidth = Math.max( + 'Transport'.length, + ...servers.map((server) => server.transport.length), + ); + + lines.push( + ` ${muted('Name'.padEnd(nameWidth))} ${muted('Status'.padEnd(statusWidth))} ${muted( + 'Transport'.padEnd(transportWidth), + )} ${muted('Tools')}`, + ); + + for (const server of servers) { + const status = statusPainter( + server.status, + colors, + )(STATUS_LABEL[server.status].padEnd(statusWidth)); + lines.push( + ` ${value(server.name.padEnd(nameWidth))} ${status} ${muted( + server.transport.padEnd(transportWidth), + )} ${value(formatToolCount(server))}`, + ); + + if ( + server.status === 'failed' && + server.error !== undefined && + server.error.trim().length > 0 + ) { + lines.push(` ${muted('error:')} ${error(server.error.trim())}`); + } + if (server.status === 'needs-auth') { + lines.push(` ${muted('action:')} ${value(`run /mcp-config login ${server.name}`)}`); + } + } + + lines.push(''); + lines.push(` ${value(buildSummary(servers))}`); + + return lines; +} diff --git a/apps/kimi-code/src/tui/components/messages/plan-box.ts b/apps/kimi-code/src/tui/components/messages/plan-box.ts new file mode 100644 index 000000000..8f16e0580 --- /dev/null +++ b/apps/kimi-code/src/tui/components/messages/plan-box.ts @@ -0,0 +1,124 @@ +/** + * PlanBoxComponent — renders an ExitPlanMode plan inside a full box + * border, width-aware. The plan text is parsed as Markdown so headings, + * lists, bold, inline code etc. render the same way assistant messages do. + */ + +import path from 'node:path'; +import { pathToFileURL } from 'node:url'; + +import type { Component, MarkdownTheme } from '@earendil-works/pi-tui'; +import { Markdown, visibleWidth } from '@earendil-works/pi-tui'; +import chalk from 'chalk'; + +const LEFT_MARGIN = 2; // two-space indent matching other tool call children +const SIDE_PADDING = 1; // space between the │ and the content on each side +const TITLE_PREFIX = ' plan: '; +const TITLE_SUFFIX = ' '; + +export interface PlanBoxOptions { + maxContentLines?: number; + expanded?: boolean; +} + +export class PlanBoxComponent implements Component { + private readonly markdown: Markdown; + private readonly maxContentLines: number | undefined; + private readonly expanded: boolean; + private cachedWidth: number | undefined; + private cachedLines: string[] | undefined; + + constructor( + plan: string, + markdownTheme: MarkdownTheme, + private readonly borderHex: string, + private readonly planPath?: string, + opts?: PlanBoxOptions, + ) { + // Build the Markdown instance once — pi-tui's Markdown caches its own + // parse + wrap output keyed on (text, width), so reusing the same + // instance means repeated render() calls from the parent Container + // hit the cache instead of re-parsing on every frame. + this.markdown = new Markdown(plan.trim(), 0, 0, markdownTheme); + this.maxContentLines = opts?.maxContentLines; + this.expanded = opts?.expanded ?? false; + } + + invalidate(): void { + this.cachedWidth = undefined; + this.cachedLines = undefined; + this.markdown.invalidate?.(); + } + + render(width: number): string[] { + if (this.cachedLines !== undefined && this.cachedWidth === width) { + return this.cachedLines; + } + + // Box layout: " ┌──...──┐" + // " │ <content> │" + // " └──...──┘" + // width = LEFT_MARGIN + 1 + horzLen + 1 ⇒ horzLen = width - 4 + // content width = horzLen - 2 * SIDE_PADDING = width - 6 + const horzLen = Math.max(2, width - LEFT_MARGIN - 2); + const contentWidth = Math.max(1, horzLen - 2 * SIDE_PADDING); + + const paint = (s: string): string => chalk.hex(this.borderHex)(s); + const indent = ' '.repeat(LEFT_MARGIN); + + const title = this.buildTitle(horzLen); + const trailingDashLen = Math.max(0, horzLen - visibleWidth(title)); + const top = + indent + paint('┌') + paint(title) + paint('─'.repeat(trailingDashLen)) + paint('┐'); + const bottom = indent + paint('└' + '─'.repeat(horzLen) + '┘'); + + const rawLines = this.markdown.render(contentWidth); + const { shown, hiddenCount } = this.capContentLines(rawLines); + + const lines: string[] = [top]; + for (const raw of shown) { + const pad = Math.max(0, contentWidth - visibleWidth(raw)); + lines.push(indent + paint('│') + ' ' + raw + ' '.repeat(pad) + ' ' + paint('│')); + } + if (hiddenCount > 0) { + const footer = chalk.dim( + `... (${String(hiddenCount)} more line${hiddenCount === 1 ? '' : 's'}, ctrl+e to expand)`, + ); + const pad = Math.max(0, contentWidth - visibleWidth(footer)); + lines.push(indent + paint('│') + ' ' + footer + ' '.repeat(pad) + ' ' + paint('│')); + } + lines.push(bottom); + + this.cachedWidth = width; + this.cachedLines = lines; + return lines; + } + + private capContentLines(rawLines: string[]): { shown: string[]; hiddenCount: number } { + const cap = this.maxContentLines; + if (this.expanded || cap === undefined || rawLines.length <= cap) { + return { shown: rawLines, hiddenCount: 0 }; + } + const shownCount = Math.max(0, cap - 1); + return { shown: rawLines.slice(0, shownCount), hiddenCount: rawLines.length - shownCount }; + } + + private buildTitle(horzLen: number): string { + const fallback = ' plan '; + const planPath = this.planPath; + if (planPath === undefined || planPath.length === 0) return fallback; + const basename = path.basename(planPath); + if (basename.length === 0) return fallback; + const wrapperLen = TITLE_PREFIX.length + TITLE_SUFFIX.length; + const budget = horzLen - 1; + if (wrapperLen + basename.length > budget) return fallback; + const linked = path.isAbsolute(planPath) + ? toTerminalHyperlink(basename, pathToFileURL(planPath).href) + : basename; + return TITLE_PREFIX + linked + TITLE_SUFFIX; + } +} + +function toTerminalHyperlink(text: string, url: string): string { + return `\u001B]8;;${url}\u0007${text}\u001B]8;;\u0007`; +} diff --git a/apps/kimi-code/src/tui/components/messages/read-group.ts b/apps/kimi-code/src/tui/components/messages/read-group.ts new file mode 100644 index 000000000..1d48f3c37 --- /dev/null +++ b/apps/kimi-code/src/tui/components/messages/read-group.ts @@ -0,0 +1,188 @@ +/** + * ReadGroupComponent renders 2+ Read tool calls from the same step as one group. + * + * It follows the same structure as `AgentGroupComponent`, with a smaller + * surface: + * - one summary header and a tree body listing each file path and status; + * - permanently grouped, while the body remains visible; + * - 200ms throttling, matching AgentGroup; + * - state stays in each `ToolCallComponent`; the group only reads snapshots. + * + * Header forms: + * pending > 0: Reading {N} files + * all done: Read {N} files · {L} lines + * some failed: append · {F} failed + * all failed: Read {N} files · failed + * + * Body lines follow AgentGroup's branch style: + * src/main.ts · 51 lines + * src/cli.ts · reading + * src/missing.ts · failed + */ + +import type { TUI } from '@earendil-works/pi-tui'; +import { Container, Spacer, Text } from '@earendil-works/pi-tui'; +import chalk from 'chalk'; + +import { STATUS_BULLET } from '#/tui/constant/symbols'; +import type { ColorPalette } from '#/tui/theme/colors'; + +import type { ToolCallComponent, ToolCallReadSnapshot } from './tool-call'; + +const THROTTLE_MS = 200; + +interface ReadEntry { + readonly toolCallId: string; + readonly tc: ToolCallComponent; +} + +export class ReadGroupComponent extends Container { + private readonly entries: ReadEntry[] = []; + private readonly headerText: Text; + private readonly bodyContainer: Container; + private throttleTimer: ReturnType<typeof setTimeout> | null = null; + private lastFlushPhases = new Map<string, ToolCallReadSnapshot['phase']>(); + + constructor( + private readonly colors: ColorPalette, + private readonly ui: TUI | undefined, + ) { + super(); + this.addChild(new Spacer(1)); + this.headerText = new Text('', 0, 0); + this.addChild(this.headerText); + this.bodyContainer = new Container(); + this.addChild(this.bodyContainer); + } + + size(): number { + return this.entries.length; + } + + /** + * Borrows a standalone `ToolCallComponent` into the group as a hidden state + * container. Snapshot changes trigger throttled refreshes. Re-attaching the + * same toolCallId is a no-op. + */ + attach(toolCallId: string, tc: ToolCallComponent): void { + if (this.entries.some((e) => e.toolCallId === toolCallId)) return; + this.entries.push({ toolCallId, tc }); + tc.setSnapshotListener(() => { + this.scheduleRender(); + }); + this.flushRender(); + } + + /** + * The pending -> done/failed transition is the important visible change, so + * it refreshes immediately. Other changes are throttled. + */ + private scheduleRender(): void { + if (this.detectPhaseTransition()) { + this.flushRender(); + return; + } + if (this.throttleTimer !== null) return; + this.throttleTimer = setTimeout(() => { + this.throttleTimer = null; + this.flushRender(); + }, THROTTLE_MS); + } + + private detectPhaseTransition(): boolean { + for (const e of this.entries) { + const phase = e.tc.getReadSnapshot().phase; + if (this.lastFlushPhases.get(e.toolCallId) !== phase) return true; + } + return false; + } + + private flushRender(): void { + if (this.throttleTimer !== null) { + clearTimeout(this.throttleTimer); + this.throttleTimer = null; + } + + const snapshots = this.entries.map((e) => e.tc.getReadSnapshot()); + let pending = 0; + let failed = 0; + let totalLines = 0; + for (const snap of snapshots) { + if (snap.phase === 'pending') pending += 1; + else if (snap.phase === 'failed') failed += 1; + else totalLines += snap.lines; + } + this.headerText.setText(this.buildHeader(snapshots.length, pending, failed, totalLines)); + + this.bodyContainer.clear(); + const visibleSnapshots = snapshots.filter( + (snap) => snap.filePath !== undefined && snap.filePath.length > 0, + ); + visibleSnapshots.forEach((snap, idx) => { + const isLast = idx === visibleSnapshots.length - 1; + this.bodyContainer.addChild(new Text(this.buildBodyLine(snap, isLast), 0, 0)); + }); + + this.lastFlushPhases.clear(); + this.entries.forEach((entry, i) => { + const snap = snapshots[i]; + if (snap !== undefined) this.lastFlushPhases.set(entry.toolCallId, snap.phase); + }); + + this.invalidate(); + this.ui?.requestRender(); + } + + private buildHeader(total: number, pending: number, failed: number, totalLines: number): string { + const colors = this.colors; + const dim = chalk.dim; + + if (pending > 0) { + const bullet = chalk.hex(colors.roleAssistant)(STATUS_BULLET); + const label = chalk.hex(colors.primary).bold(`Reading ${String(total)} files…`); + return `${bullet}${label}`; + } + + // All reads have finished, either successfully or with failures. + if (failed === total) { + const bullet = chalk.hex(colors.error)('✗ '); + const label = chalk.hex(colors.error).bold(`Read ${String(total)} files`); + return `${bullet}${label}${chalk.hex(colors.error)(' · failed')}`; + } + + const bullet = chalk.hex(colors.success)(STATUS_BULLET); + const label = chalk.hex(colors.primary).bold(`Read ${String(total)} files`); + const linesPart = dim(` · ${String(totalLines)} ${totalLines === 1 ? 'line' : 'lines'}`); + const failPart = failed > 0 ? chalk.hex(colors.error)(` · ${String(failed)} failed`) : ''; + return `${bullet}${label}${linesPart}${failPart}`; + } + + private buildBodyLine(snap: ToolCallReadSnapshot, isLast: boolean): string { + const colors = this.colors; + const dim = chalk.dim; + const branch = isLast ? '└─' : '├─'; + const path = snap.filePath ?? ''; + const pathPart = chalk.hex(colors.text)(path); + + let tail: string; + if (snap.phase === 'pending') { + tail = dim(' · reading…'); + } else if (snap.phase === 'failed') { + tail = chalk.hex(colors.error)(' · failed'); + } else { + tail = dim(` · ${String(snap.lines)} ${snap.lines === 1 ? 'line' : 'lines'}`); + } + return ` ${branch} ${pathPart}${tail}`; + } + + /** Releases throttle timers so destroyed components cannot refresh later. */ + dispose(): void { + if (this.throttleTimer !== null) { + clearTimeout(this.throttleTimer); + this.throttleTimer = null; + } + for (const e of this.entries) { + e.tc.setSnapshotListener(undefined); + } + } +} diff --git a/apps/kimi-code/src/tui/components/messages/shell-execution.ts b/apps/kimi-code/src/tui/components/messages/shell-execution.ts new file mode 100644 index 000000000..45279fd94 --- /dev/null +++ b/apps/kimi-code/src/tui/components/messages/shell-execution.ts @@ -0,0 +1,88 @@ +import type { Component } from '@earendil-works/pi-tui'; +import { Container, Text } from '@earendil-works/pi-tui'; +import chalk from 'chalk'; + +import { COMMAND_PREVIEW_LINES } from '#/tui/constant/rendering'; +import type { ColorPalette } from '#/tui/theme/colors'; +import type { ToolCallBlockData, ToolResultBlockData } from '#/tui/types'; + +import type { ResultRenderer } from './tool-renderers/types'; +import { PREVIEW_LINES } from './tool-renderers/types'; + +export interface ShellExecutionOptions { + readonly command?: string; + readonly result?: ToolResultBlockData; + readonly colors: ColorPalette; + readonly expanded?: boolean; + readonly showCommand?: boolean; + readonly commandPreviewLines?: number; + readonly resultPreviewLines?: number; +} + +export class ShellExecutionComponent extends Container { + constructor(options: ShellExecutionOptions) { + super(); + + if (options.showCommand === true) { + this.addCommandPreview( + options.command ?? '', + options.commandPreviewLines ?? COMMAND_PREVIEW_LINES, + ); + } + + if (options.result !== undefined) { + this.addResultPreview( + options.result, + options.colors, + options.expanded ?? false, + options.resultPreviewLines ?? PREVIEW_LINES, + ); + } + } + + private addCommandPreview(command: string, previewLines: number): void { + if (command.length === 0) return; + const lines = command.split('\n').slice(0, previewLines); + for (const [i, line] of lines.entries()) { + const prefix = i === 0 ? '$ ' : ' '; + this.addChild(new Text(chalk.dim(prefix + line), 2, 0)); + } + } + + private addResultPreview( + result: ToolResultBlockData, + colors: ColorPalette, + expanded: boolean, + previewLines: number, + ): void { + if (!result.output) return; + const tint = result.is_error ? chalk.hex(colors.error) : chalk.dim; + if (expanded) { + this.addChild(new Text(tint(result.output), 2, 0)); + return; + } + + const lines = result.output.split('\n'); + const shown = lines.slice(0, previewLines); + const remaining = lines.length - shown.length; + this.addChild(new Text(tint(shown.join('\n')), 2, 0)); + if (remaining > 0) { + this.addChild( + new Text(chalk.dim(`... (${String(remaining)} more lines, ctrl+o to expand)`), 2, 0), + ); + } + } +} + +export const shellExecutionResultRenderer: ResultRenderer = ( + toolCall: ToolCallBlockData, + result: ToolResultBlockData, + ctx, +): Component[] => [ + new ShellExecutionComponent({ + command: typeof toolCall.args['command'] === 'string' ? toolCall.args['command'] : '', + result, + colors: ctx.colors, + expanded: ctx.expanded, + }), +]; diff --git a/apps/kimi-code/src/tui/components/messages/skill-activation.ts b/apps/kimi-code/src/tui/components/messages/skill-activation.ts new file mode 100644 index 000000000..7f9e05c9f --- /dev/null +++ b/apps/kimi-code/src/tui/components/messages/skill-activation.ts @@ -0,0 +1,36 @@ +/** + * Skill activation card. + * + * When the user runs `/skill:foo bar`, the TUI renders a compact card instead + * of expanding the SKILL.md body into the user bubble: + * + * ▶ Activated skill: foo + * bar + * + * The args line is optional. Core expands the skill body into the LLM context; + * the TUI only consumes the `skill.activated` event and user_message origin + * metadata. + */ + +import { Container, Text, Spacer } from '@earendil-works/pi-tui'; +import chalk from 'chalk'; + +import type { ColorPalette } from '#/tui/theme/colors'; + +const ARGS_PREVIEW_MAX = 200; + +export class SkillActivationComponent extends Container { + constructor(name: string, args: string | undefined, colors: ColorPalette) { + super(); + this.addChild(new Spacer(1)); + const head = + chalk.hex(colors.primary).bold('▶ Activated skill: ') + chalk.hex(colors.roleUser).bold(name); + this.addChild(new Text(head, 0, 0)); + const trimmed = args?.trim() ?? ''; + if (trimmed.length > 0) { + const preview = + trimmed.length > ARGS_PREVIEW_MAX ? trimmed.slice(0, ARGS_PREVIEW_MAX) + '…' : trimmed; + this.addChild(new Text(' ' + chalk.hex(colors.textDim)(preview), 0, 0)); + } + } +} diff --git a/apps/kimi-code/src/tui/components/messages/status-message.ts b/apps/kimi-code/src/tui/components/messages/status-message.ts new file mode 100644 index 000000000..be2292358 --- /dev/null +++ b/apps/kimi-code/src/tui/components/messages/status-message.ts @@ -0,0 +1,23 @@ +import { Container, Spacer, Text } from '@earendil-works/pi-tui'; +import chalk from 'chalk'; + +import type { ColorPalette } from '../../theme/colors'; + +export class StatusMessageComponent extends Container { + constructor(content: string, colors: ColorPalette, color?: string) { + super(); + const text = color === undefined ? chalk.hex(colors.textDim)(content) : chalk.hex(color)(content); + this.addChild(new Text(` ${text}`, 0, 0)); + } +} + +export class NoticeMessageComponent extends Container { + constructor(title: string, detail: string | undefined, colors: ColorPalette) { + super(); + this.addChild(new Spacer(1)); + this.addChild(new Text(` ${chalk.hex(colors.textStrong)(title)}`, 0, 0)); + if (detail !== undefined && detail.length > 0) { + this.addChild(new Text(` ${chalk.hex(colors.textDim)(detail)}`, 0, 0)); + } + } +} diff --git a/apps/kimi-code/src/tui/components/messages/status-panel.ts b/apps/kimi-code/src/tui/components/messages/status-panel.ts new file mode 100644 index 000000000..c60e2f546 --- /dev/null +++ b/apps/kimi-code/src/tui/components/messages/status-panel.ts @@ -0,0 +1,148 @@ +/** + * Status report line builder for `/status`. + * + * It mirrors `/usage` visual language but keeps runtime status formatting + * separate from the TUI orchestration layer. + */ + +import type { ModelAlias, PermissionMode, SessionStatus } from '@moonshot-ai/kimi-code-sdk'; +import chalk from 'chalk'; + +import { PRODUCT_NAME } from '#/constant/app'; +import type { ColorPalette } from '#/tui/theme/colors'; +import { + formatTokenCount, + ratioSeverity, + renderProgressBar, + safeUsageRatio, +} from '#/utils/usage/usage-format'; + +import { buildManagedUsageReportLines, type ManagedUsageReport } from './usage-panel'; + +interface FieldRow { + readonly label: string; + readonly value: string; + readonly severity?: 'error'; +} + +export interface StatusReportOptions { + readonly colors: ColorPalette; + readonly version: string; + readonly model: string; + readonly workDir: string; + readonly sessionId: string; + readonly sessionTitle: string | null; + readonly thinking: boolean; + readonly permissionMode: PermissionMode; + readonly planMode: boolean; + readonly contextUsage: number; + readonly contextTokens: number; + readonly maxContextTokens: number; + readonly availableModels: Record<string, ModelAlias>; + readonly status?: SessionStatus; + readonly statusError?: string; + readonly managedUsage?: ManagedUsageReport; + readonly managedUsageError?: string; +} + +type Colorize = (text: string) => string; + +function displayModelName(alias: string, models: Record<string, ModelAlias>): string { + const model = models[alias]; + return model?.displayName ?? model?.model ?? alias; +} + +function formatModelStatus(options: StatusReportOptions): string { + const model = options.status?.model ?? options.model; + if (model.trim().length === 0) return 'not set'; + + const thinking = (options.status?.thinkingLevel ?? (options.thinking ? 'on' : 'off')) === 'off' + ? 'off' + : 'on'; + return `${displayModelName(model, options.availableModels)} (thinking ${thinking})`; +} + +function addFieldRows( + lines: string[], + rows: readonly FieldRow[], + muted: Colorize, + value: Colorize, + errorStyle: Colorize, +): void { + const labelWidth = Math.max(10, ...rows.map((row) => row.label.length)); + for (const row of rows) { + const colorize = row.severity === 'error' ? errorStyle : value; + lines.push(` ${muted(row.label.padEnd(labelWidth, ' '))} ${colorize(row.value)}`); + } +} + +function contextValues(options: StatusReportOptions): { + ratio: number; + tokens: number; + maxTokens: number; +} { + return { + ratio: options.status?.contextUsage ?? options.contextUsage, + tokens: options.status?.contextTokens ?? options.contextTokens, + maxTokens: options.status?.maxContextTokens ?? options.maxContextTokens, + }; +} + +export function buildStatusReportLines(options: StatusReportOptions): string[] { + const colors = options.colors; + const accent = chalk.hex(colors.primary).bold; + const value = chalk.hex(colors.text); + const muted = chalk.hex(colors.textDim); + const errorStyle = chalk.hex(colors.error); + const severityHex = (sev: 'ok' | 'warn' | 'danger'): string => + sev === 'danger' ? colors.error : sev === 'warn' ? colors.warning : colors.success; + + const permission = options.status?.permission ?? options.permissionMode; + const planMode = options.status?.planMode ?? options.planMode; + const sessionId = options.sessionId.trim().length > 0 ? options.sessionId : 'none'; + const rows: FieldRow[] = [ + { label: 'Model', value: formatModelStatus(options) }, + { label: 'Directory', value: options.workDir }, + { label: 'Permissions', value: permission }, + { label: 'Plan mode', value: planMode ? 'on' : 'off' }, + { label: 'Session', value: sessionId }, + ]; + const title = options.sessionTitle?.trim(); + if (title !== undefined && title.length > 0) rows.push({ label: 'Title', value: title }); + if (options.statusError !== undefined) { + rows.push({ label: 'Warning', value: options.statusError, severity: 'error' }); + } + + const lines: string[] = [ + `${accent(`>_ ${PRODUCT_NAME}`)} ${muted(`(v${options.version})`)}`, + '', + ]; + addFieldRows(lines, rows, muted, value, errorStyle); + + const { ratio, tokens, maxTokens } = contextValues(options); + lines.push(''); + lines.push(accent('Context window')); + if (maxTokens > 0) { + const safeRatio = safeUsageRatio(ratio); + const bar = renderProgressBar(safeRatio, 20); + const barColoured = chalk.hex(severityHex(ratioSeverity(safeRatio)))(bar); + lines.push( + ` ${barColoured} ${value(`${(safeRatio * 100).toFixed(1)}%`.padStart(6, ' '))} ` + + muted(`(${formatTokenCount(tokens)} / ${formatTokenCount(maxTokens)})`), + ); + } else { + lines.push(` ${muted('No context window data available.')}`); + } + + const managedSection = buildManagedUsageReportLines({ + colors, + managedUsage: options.managedUsage, + managedUsageError: options.managedUsageError, + }); + if (managedSection.length > 0) { + lines.push(''); + lines.push(...managedSection); + } + + return lines; +} diff --git a/apps/kimi-code/src/tui/components/messages/thinking.ts b/apps/kimi-code/src/tui/components/messages/thinking.ts new file mode 100644 index 000000000..632cef307 --- /dev/null +++ b/apps/kimi-code/src/tui/components/messages/thinking.ts @@ -0,0 +1,122 @@ +/** + * Renders thinking content in the transcript. + * Supports live in-place updates while thinking streams, then finalizes + * without replacing the component. + * Supports expand/collapse via Ctrl+O (shared with tool output). + */ + +import type { Component, TUI } from '@earendil-works/pi-tui'; +import { Text } from '@earendil-works/pi-tui'; +import chalk from 'chalk'; + +import { + BRAILLE_SPINNER_FRAMES, + BRAILLE_SPINNER_INTERVAL_MS, + MESSAGE_INDENT, + RESULT_PREVIEW_LINES, +} from '#/tui/constant/rendering'; +import { STATUS_BULLET } from '#/tui/constant/symbols'; +import type { ColorPalette } from '#/tui/theme/colors'; + +export type ThinkingRenderMode = 'live' | 'finalized'; + +export class ThinkingComponent implements Component { + private text: string; + private color: string; + private showMarker: boolean; + private mode: ThinkingRenderMode; + private expanded = false; + private readonly ui: TUI | undefined; + private spinnerFrame = 0; + private spinnerInterval: ReturnType<typeof setInterval> | undefined; + + constructor( + text: string, + colors: ColorPalette, + showMarker: boolean = true, + mode: ThinkingRenderMode = 'finalized', + ui?: TUI, + ) { + this.text = text; + this.color = colors.roleThinking; + this.showMarker = showMarker; + this.mode = mode; + this.ui = ui; + if (mode === 'live') { + this.startSpinner(); + } + } + + invalidate(): void {} + + setText(text: string): void { + this.text = text; + } + + finalize(): void { + this.mode = 'finalized'; + this.stopSpinner(); + } + + dispose(): void { + this.stopSpinner(); + } + + setExpanded(expanded: boolean): void { + if (this.expanded === expanded) return; + this.expanded = expanded; + } + + render(width: number): string[] { + const contentWidth = Math.max(1, width - MESSAGE_INDENT.length); + const textComponent = new Text(chalk.hex(this.color).italic(this.text), 0, 0); + const contentLines = this.text.length > 0 ? textComponent.render(contentWidth) : ['']; + + if (this.mode === 'live') { + const visibleLines = + contentLines.length > RESULT_PREVIEW_LINES + ? contentLines.slice(contentLines.length - RESULT_PREVIEW_LINES) + : contentLines; + const spinner = chalk.hex(this.color)( + `${BRAILLE_SPINNER_FRAMES[this.spinnerFrame] ?? BRAILLE_SPINNER_FRAMES[0]} `, + ); + return [ + '', + spinner + chalk.hex(this.color)('thinking...'), + ...visibleLines.map((line) => MESSAGE_INDENT + line), + ]; + } + + const rendered: string[] = ['']; + for (let i = 0; i < contentLines.length; i++) { + const p = i === 0 && this.showMarker ? chalk.hex(this.color)(STATUS_BULLET) : MESSAGE_INDENT; + rendered.push(p + contentLines[i]); + } + + if (this.expanded || contentLines.length <= RESULT_PREVIEW_LINES) { + return rendered; + } + + // Leading blank + first PREVIEW_LINES content lines + hint line. + const truncated = rendered.slice(0, 1 + RESULT_PREVIEW_LINES); + const remaining = contentLines.length - RESULT_PREVIEW_LINES; + truncated.push( + MESSAGE_INDENT + chalk.dim(`... (${String(remaining)} more lines, ctrl+o to expand)`), + ); + return truncated; + } + + private startSpinner(): void { + if (this.ui === undefined || this.spinnerInterval !== undefined) return; + this.spinnerInterval = setInterval(() => { + this.spinnerFrame = (this.spinnerFrame + 1) % BRAILLE_SPINNER_FRAMES.length; + this.ui?.requestRender(); + }, BRAILLE_SPINNER_INTERVAL_MS); + } + + private stopSpinner(): void { + if (this.spinnerInterval === undefined) return; + clearInterval(this.spinnerInterval); + this.spinnerInterval = undefined; + } +} diff --git a/apps/kimi-code/src/tui/components/messages/tool-call.ts b/apps/kimi-code/src/tui/components/messages/tool-call.ts new file mode 100644 index 000000000..dc831e8be --- /dev/null +++ b/apps/kimi-code/src/tui/components/messages/tool-call.ts @@ -0,0 +1,1675 @@ +/** + * Renders a tool call entry in the transcript. + * Supports expand/collapse via Ctrl+O. + */ + +import { isAbsolute, relative, sep } from 'node:path'; + +import { Container, Text, Spacer, visibleWidth } from '@earendil-works/pi-tui'; +import type { Component, MarkdownTheme, TUI } from '@earendil-works/pi-tui'; +import chalk from 'chalk'; + +import { highlightLines, langFromPath } from '#/tui/components/media/code-highlight'; +import { renderDiffLinesClustered } from '#/tui/components/media/diff-preview'; +import { COMMAND_PREVIEW_LINES } from '#/tui/constant/rendering'; +import { STREAMING_ARGS_FIELD_RE } from '#/tui/constant/streaming'; +import { STATUS_BULLET } from '#/tui/constant/symbols'; +import type { ColorPalette } from '#/tui/theme/colors'; +import type { ToolCallBlockData, ToolResultBlockData } from '#/tui/types'; +import { decodeMcpToolName } from '#/tui/utils/mcp-tool-name'; + +import { PlanBoxComponent } from './plan-box'; +import { ShellExecutionComponent } from './shell-execution'; +import { countNonEmptyLines, pickChip } from './tool-renderers/chip'; +import { pickResultRenderer } from './tool-renderers/registry'; + +const MAX_ARG_LENGTH = 60; +const MAX_SUB_TOOL_CALLS_SHOWN = 4; +const MAX_SINGLE_SUBAGENT_TOOL_ROWS = 4; +const APPROVED_PLAN_MARKER = '## Approved Plan:'; +const STREAMING_PROGRESS_INTERVAL_MS = 1000; +const SUBAGENT_ELAPSED_INTERVAL_MS = 1000; +const PROGRESS_URL_RE = /https?:\/\/\S+/g; + +type SubagentTextKind = 'thinking' | 'text'; + +interface SubagentTokenUsage { + readonly input?: number | undefined; + readonly inputOther?: number | undefined; + readonly inputCacheRead?: number | undefined; + readonly inputCacheCreation?: number | undefined; + readonly output: number; +} + +interface FinishedSubCall { + readonly name: string; + readonly args: Record<string, unknown>; + readonly output: string; + readonly isError: boolean; +} + +interface OngoingSubCall { + readonly name: string; + readonly args: Record<string, unknown>; + readonly streamingArguments?: string | undefined; +} + +interface SubToolActivity { + readonly id: string; + name: string; + args: Record<string, unknown>; + phase: 'ongoing' | 'done' | 'failed'; + readonly orderSeq: number; +} + +/** + * Immutable subagent state snapshot. `AgentGroupComponent` reads one-time + * views via `ToolCallComponent.getSubagentSnapshot()` and renders its own + * branch lines; `onSnapshotChange` notifies it when state changes. + * + * `latestActivity` priority, used only while running: + * 1. latest ongoing sub-tool (`Using {name} ({keyArg})`) + * 2. latest finished sub-tool (`Used {name} ({keyArg})`) + * 3. last non-empty line from accumulated subagent text + */ +export interface ToolCallSubagentSnapshot { + readonly toolCallId: string; + readonly toolName: string; + readonly toolCallDescription: string; + readonly agentName: string | undefined; + readonly phase: 'spawning' | 'running' | 'done' | 'failed' | 'backgrounded' | undefined; + readonly toolCount: number; + readonly tokens: number; + readonly isError: boolean; + readonly errorText: string | undefined; + readonly latestActivity: string | undefined; +} + +/** + * Immutable Read tool state snapshot. `ReadGroupComponent` reads one-time + * views via `ToolCallComponent.getReadSnapshot()` and sums lines for the group + * header. `lines` is 0 while pending or failed, and the non-empty result line + * count when done, matching the single-card chip. + */ +export interface ToolCallReadSnapshot { + readonly toolCallId: string; + readonly filePath: string | undefined; + readonly phase: 'pending' | 'done' | 'failed'; + readonly lines: number; +} + +function str(v: unknown): string { + return typeof v === 'string' ? v : ''; +} + +function usageInputTotal(usage: SubagentTokenUsage): number { + return ( + usage.input ?? + (usage.inputOther ?? 0) + (usage.inputCacheRead ?? 0) + (usage.inputCacheCreation ?? 0) + ); +} + +function formatSubagentTokens(usage: SubagentTokenUsage | undefined): string | undefined { + if (usage === undefined) return undefined; + const total = usageInputTotal(usage) + usage.output; + if (total <= 0) return undefined; + const formatted = total >= 1000 ? `${(total / 1000).toFixed(1)}k` : String(total); + return `${formatted} tok`; +} + +function formatByteSize(bytes: number): string { + if (bytes < 1024) return `${String(bytes)} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + return `${(bytes / 1024 / 1024).toFixed(1)} MB`; +} + +function formatElapsed(seconds: number): string { + if (seconds < 60) return `${String(seconds)}s`; + const minutes = Math.floor(seconds / 60); + const remainder = seconds % 60; + return `${String(minutes)}m ${String(remainder)}s`; +} + +function extractApprovedPlan(output: string): string { + const markerIndex = output.indexOf(APPROVED_PLAN_MARKER); + if (markerIndex < 0) return ''; + return output.slice(markerIndex + APPROVED_PLAN_MARKER.length).trim(); +} + +interface ExitPlanModeOutcome { + readonly kind: 'approved' | 'rejected'; + readonly chosen?: string; + readonly feedback?: string; + readonly path?: string; +} + +const REJECT_PREFIX = 'User rejected the plan.'; +const REJECT_FEEDBACK_PREFIX = 'User rejected the plan. Feedback:'; +const APPROVED_OPTION_RE = /^User approved option "([^"]+)"\./; +const PLAN_REJECT_PREFIX = 'Plan rejected by user.'; +const SELECTED_APPROACH_RE = /^Exited plan mode\. Selected approach: ([^\n]+)\n/; +const PLAN_SAVED_TO_RE = /\nPlan saved to: ([^\n]+)\n/; + +/** + * Parses the ExitPlanMode result content string to recover the approval outcome + * and optional plan path. Core-side templates live in + * `packages/agent-core/src/tools/builtin/planning/exit-plan-mode.ts`: + * - Approved output starts with 'Exited plan mode.' and selected options + * are reported as 'Selected approach: <label>'. Older outputs may start + * with 'User approved option "<label>".' Plan-file mode may include + * 'Plan saved to: <path>'. + * - Rejected output starts with 'Plan rejected by user.' or older + * 'User rejected the plan.'; feedback uses 'User rejected the plan. + * Feedback:\n\n<text>'. + * This is a string protocol rather than a structured payload. Prefer a + * structured event payload if core starts emitting one. + */ +function interpretExitPlanModeOutcome(output: string): ExitPlanModeOutcome { + if (output.startsWith(REJECT_PREFIX)) { + if (output.startsWith(REJECT_FEEDBACK_PREFIX)) { + const feedback = output.slice(REJECT_FEEDBACK_PREFIX.length).trimStart(); + return { kind: 'rejected', feedback }; + } + return { kind: 'rejected' }; + } + if (output.startsWith(PLAN_REJECT_PREFIX)) { + return { kind: 'rejected' }; + } + const pathMatch = PLAN_SAVED_TO_RE.exec(output); + const path = pathMatch?.[1]?.trim(); + const optionMatch = SELECTED_APPROACH_RE.exec(output) ?? APPROVED_OPTION_RE.exec(output); + if (optionMatch !== null) { + return path !== undefined && path.length > 0 + ? { kind: 'approved', chosen: optionMatch[1], path } + : { kind: 'approved', chosen: optionMatch[1] }; + } + return path !== undefined && path.length > 0 ? { kind: 'approved', path } : { kind: 'approved' }; +} + +function unescapeJsonString(s: string): string { + return s.replaceAll(/\\(["\\/bfnrt])/g, (_, ch: string) => { + switch (ch) { + case 'n': + return '\n'; + case 't': + return '\t'; + case 'r': + return '\r'; + case 'b': + return '\b'; + case 'f': + return '\f'; + case '"': + return '"'; + case '\\': + return '\\'; + case '/': + return '/'; + default: + return ch; + } + }); +} + +/** + * Pull the live value of a JSON string field out of partially-streamed + * arguments, even if the closing quote hasn't arrived yet. Handles the + * common JSON string escapes so `\n` in a streamed `content` becomes a + * real newline we can highlight. Returns `undefined` if the field hasn't + * started streaming yet. + */ +function extractPartialStringField(text: string, key: string): string | undefined { + const opener = new RegExp(`"${key}"\\s*:\\s*"`); + const match = opener.exec(text); + if (match === null) return undefined; + const start = match.index + match[0].length; + let out = ''; + let i = start; + while (i < text.length) { + const ch = text[i]; + if (ch === '\\') { + const next = text[i + 1]; + if (next === undefined) return out; + switch (next) { + case 'n': + out += '\n'; + break; + case 't': + out += '\t'; + break; + case 'r': + out += '\r'; + break; + case 'b': + out += '\b'; + break; + case 'f': + out += '\f'; + break; + case '"': + out += '"'; + break; + case '\\': + out += '\\'; + break; + case '/': + out += '/'; + break; + case 'u': { + if (i + 5 >= text.length) return out; + const hex = text.slice(i + 2, i + 6); + const code = Number.parseInt(hex, 16); + if (Number.isNaN(code)) return out; + out += String.fromCodePoint(code); + i += 6; + continue; + } + default: + out += next; + } + i += 2; + continue; + } + if (ch === '"') return out; + out += ch; + i++; + } + return out; +} + +function parseArgsPreview(value: string): Record<string, unknown> { + if (value.trim().length === 0) return {}; + if (value.trimEnd().endsWith('}')) { + try { + const parsed = JSON.parse(value) as unknown; + if (typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)) { + return parsed as Record<string, unknown>; + } + } catch { + // fall through to partial scan + } + } + const result: Record<string, unknown> = {}; + for (const match of value.matchAll(STREAMING_ARGS_FIELD_RE)) { + const key = match[1]; + const rawValue = match[2]; + if (key === undefined || rawValue === undefined) continue; + if (!(key in result)) result[key] = unescapeJsonString(rawValue); + } + return result; +} + +const PATH_KEYS = new Set(['path', 'file_path']); + +function truncateArgValue(key: string, value: string): string { + if (value.length <= MAX_ARG_LENGTH) return value; + if (PATH_KEYS.has(key)) { + // Preserve the tail (filename) — drop the prefix so the user can + // still tell which file is being touched. + return '…' + value.slice(value.length - (MAX_ARG_LENGTH - 1)); + } + return value.slice(0, MAX_ARG_LENGTH - 3) + '...'; +} + +function makeWorkspaceRelativePath(filePath: string, workspaceDir: string | undefined): string { + if (workspaceDir === undefined || workspaceDir.length === 0 || !isAbsolute(filePath)) { + return filePath; + } + const relativePath = relative(workspaceDir, filePath); + if ( + relativePath.length === 0 || + relativePath === '..' || + relativePath.startsWith(`..${sep}`) || + isAbsolute(relativePath) + ) { + return filePath; + } + return relativePath; +} + +function formatKeyArgument( + toolName: string, + key: string, + value: string, + workspaceDir: string | undefined, +): string { + const displayValue = + toolName === 'Read' && PATH_KEYS.has(key) + ? makeWorkspaceRelativePath(value, workspaceDir) + : value; + return truncateArgValue(key, displayValue); +} + +function extractKeyArgument( + toolName: string, + args: Record<string, unknown>, + workspaceDir?: string, +): string | null { + const keyMap: Record<string, string[]> = { + Bash: ['command'], + Read: ['path', 'file_path'], + Write: ['path', 'file_path'], + Edit: ['path', 'file_path'], + Grep: ['pattern'], + Glob: ['pattern'], + FetchURL: ['url'], + WebSearch: ['query'], + // Prefer the short `description` so the header preview never spills a + // multi-line `prompt` into the TUI chrome. + Agent: ['description', 'prompt'], + }; + + const candidates = keyMap[toolName] ?? Object.keys(args); + for (const key of candidates) { + const val = args[key]; + if (typeof val === 'string' && val.length > 0) { + const firstLine = val.split('\n')[0] ?? val; + return formatKeyArgument(toolName, key, firstLine, workspaceDir); + } + } + return null; +} + +function formatSubagentLabel(agentName: string | undefined): string { + const raw = agentName?.trim(); + if (raw === undefined || raw.length === 0) return 'SubAgent'; + const label = raw + .split(/[-_\s]+/) + .filter((part) => part.length > 0) + .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) + .join(' '); + if (/\bagent$/i.test(label)) return label; + return `${label} Agent`; +} + +function tailNonEmptyLines(text: string, maxLines: number): string[] { + if (text.length === 0) return []; + return text + .split('\n') + .map((line) => line.trimEnd()) + .filter((line) => line.trim().length > 0) + .slice(-maxLines); +} + +class PrefixedWrappedLine implements Component { + constructor( + private readonly firstPrefix: string, + private readonly continuationPrefix: string, + private readonly text: string, + ) { } + + invalidate(): void { } + + render(width: number): string[] { + const prefixWidth = Math.max( + visibleWidth(this.firstPrefix), + visibleWidth(this.continuationPrefix), + ); + const contentWidth = Math.max(1, width - prefixWidth); + const lines = new Text(this.text, 0, 0).render(contentWidth); + return lines.map((line, index) => + index === 0 ? `${this.firstPrefix}${line}` : `${this.continuationPrefix}${line}`, + ); + } +} + +export class ToolCallComponent extends Container { + private expanded = false; + private planExpanded = false; + private toolCall: ToolCallBlockData; + private result: ToolResultBlockData | undefined; + private colors: ColorPalette; + private ui: TUI | undefined; + private markdownTheme: MarkdownTheme | undefined; + private planPath: string | undefined; + /** + * Fallback plan body used when the LLM uses plan-file mode and + * `args.plan` is empty. `KimiTUI` calls `setPlanInfo` with + * `session.getPlan()` content so the plan box can render while + * approval is pending, and so rejected or revised results still show + * the plan body even without a `## Approved Plan:` marker. + */ + private currentPlan: string | undefined; + private headerText: Text; + private callPreviewEndIndex = 0; + + // ── Subagent state ─────────────────────────────────────────────── + // + // Populated by `setSubagentMeta` / `appendSubToolCall` / `finishSubToolCall` + // when KimiTUI routes a `subagent.event` with this tool call + // id as its `parent_tool_call_id`. Rendered at the tail of + // buildContent so it shows up both during streaming and after the + // parent tool call resolves. + private subagentAgentId: string | undefined; + private subagentAgentName: string | undefined; + private readonly ongoingSubCalls = new Map<string, OngoingSubCall>(); + private readonly finishedSubCalls: FinishedSubCall[] = []; + private readonly subToolActivities = new Map<string, SubToolActivity>(); + private subToolOrderSeq = 0; + private hiddenSubCallCount = 0; + /** + * Recent normal-output lines from the child agent. Historical replay can also + * store mixed text here. + */ + private subagentText = ''; + private subagentThinkingText = ''; + // ── Subagent lifecycle state from subagent.spawned/completed/failed ── + private subagentPhase: 'spawning' | 'running' | 'done' | 'failed' | 'backgrounded' | undefined; + private subagentUsage: SubagentTokenUsage | undefined; + private subagentResultSummary: string | undefined; + private subagentError: string | undefined; + private streamingProgressTimer: ReturnType<typeof setInterval> | undefined; + private subagentElapsedTimer: ReturnType<typeof setInterval> | undefined; + private subagentStartedAtMs: number | undefined; + private subagentEndedAtMs: number | undefined; + + // ── Live progress lines ────────────────────────────────────────── + // + // Populated by `appendProgress` whenever the tool emits an + // `onUpdate({kind:'status', text})` while still running. Used by + // long-blocking tools (e.g. the MCP `authenticate` synthetic tool + // whose 15-minute browser wait would otherwise display only a + // spinner). Cleared when the result lands — the result is the + // authoritative final state. + private progressLines: string[] = []; + private static readonly MAX_PROGRESS_LINES = 24; + + /** + * Registered by a group container (`AgentGroupComponent` or + * `ReadGroupComponent`) when this component is borrowed as a hidden state + * container. Any state change (subagent meta, phase, sub-tool, result, etc.) + * triggers a throttled group re-render. `undefined` means no group is + * subscribed and standalone rendering is unaffected. A ToolCallComponent can + * only belong to one group at a time, so one listener slot is enough. + */ + private onSnapshotChange: (() => void) | undefined; + + constructor( + toolCall: ToolCallBlockData, + result: ToolResultBlockData | undefined, + colors: ColorPalette, + ui?: TUI, + markdownTheme?: MarkdownTheme, + private readonly workspaceDir?: string, + ) { + super(); + this.toolCall = toolCall; + this.result = result; + this.colors = colors; + this.ui = ui; + this.markdownTheme = markdownTheme; + this.applySubagentReplay(toolCall.subagent); + + this.addChild(new Spacer(1)); + this.headerText = new Text(this.buildHeader(), 0, 0); + this.addChild(this.headerText); + this.buildCallPreview(); + this.callPreviewEndIndex = this.children.length; + this.buildProgressBlock(); + this.buildContent(); + this.buildSubagentBlock(); + this.syncStreamingProgressTimer(); + this.syncSubagentElapsedTimer(); + } + + setExpanded(expanded: boolean): void { + if (this.expanded === expanded) return; + this.expanded = expanded; + // rebuildBody (not rebuildContent) so the args-driven call preview + // — which is what carries Write content / Edit diff — re-renders + // with the new line cap. rebuildContent only touches result-driven + // children and would leave the call preview stuck at its initial + // collapsed size. + this.rebuildBody(); + } + + // Toggle the plan box's expanded state independently from tool-output + // expansion. Returns true iff this card actually owns a plan preview + // (ExitPlanMode), so the caller can decide whether to consume the keystroke. + setPlanExpanded(expanded: boolean): boolean { + if (this.toolCall.name !== 'ExitPlanMode') return false; + if (this.planExpanded === expanded) return true; + this.planExpanded = expanded; + this.rebuildBody(); + return true; + } + + setResult(result: ToolResultBlockData): void { + this.result = result; + // Result supersedes any live progress chatter; the result body is the + // authoritative final state. Without this clear, a finished tool would + // show both the streamed status lines and the final output stacked. + this.progressLines = []; + this.finalizeSubagentElapsedIfNeeded(); + this.syncStreamingProgressTimer(); + this.syncSubagentElapsedTimer(); + this.headerText.setText(this.buildHeader()); + // rebuildBody (not rebuildContent) so the call preview re-renders + // with the collapsed cap applied — Write streaming previews and + // Edit's progress placeholder needs to snap to the final preview on + // result. + this.rebuildBody(); + // Final results affect group summaries, especially failed/done counts. + this.notifySnapshotChange(); + } + + updateToolCall(toolCall: ToolCallBlockData): void { + this.toolCall = toolCall; + this.syncStreamingProgressTimer(); + this.headerText.setText(this.buildHeader()); + this.rebuildBody(); + this.notifySnapshotChange(); + this.ui?.requestRender(); + } + + /** + * Append a live progress line emitted by the tool via + * `onUpdate({kind:'status', text})`. Splits on newlines so multi-line + * status payloads render row-by-row. Old lines are dropped once the + * buffer fills past {@link ToolCallComponent.MAX_PROGRESS_LINES} so a + * misbehaving tool can't grow the box unboundedly. + */ + appendProgress(text: string): void { + if (this.result !== undefined) return; + for (const line of text.split('\n')) { + this.progressLines.push(line); + } + while (this.progressLines.length > ToolCallComponent.MAX_PROGRESS_LINES) { + this.progressLines.shift(); + } + this.rebuildBody(); + this.notifySnapshotChange(); + this.ui?.requestRender(); + } + + dispose(): void { + this.stopStreamingProgressTimer(); + this.stopSubagentElapsedTimer(); + } + + /** + * Injects plan body/path asynchronously. Only ExitPlanMode cards use + * this: plan-file mode leaves `args.plan` empty, so `KimiTUI` fetches + * the plan via `session.getPlan()` and calls this method to render the + * plan box. + */ + setPlanInfo(info: { plan?: string; path?: string }): void { + if (this.toolCall.name !== 'ExitPlanMode') return; + let changed = false; + if (info.plan !== undefined && info.plan.length > 0 && this.currentPlan !== info.plan) { + this.currentPlan = info.plan; + changed = true; + } + if (info.path !== undefined && info.path.length > 0 && this.planPath !== info.path) { + this.planPath = info.path; + changed = true; + } + if (!changed) return; + this.rebuildBody(); + this.ui?.requestRender(); + } + + private applySubagentReplay(subagent: ToolCallBlockData['subagent']): void { + if (subagent === undefined) return; + this.subagentAgentId = subagent.id; + this.subagentAgentName = subagent.name; + this.subagentText = subagent.text ?? ''; + for (const call of subagent.toolCalls ?? []) { + if (call.result === undefined) { + this.ongoingSubCalls.set(call.id, { name: call.name, args: call.args }); + this.upsertSubToolActivity(call.id, call.name, call.args, 'ongoing'); + continue; + } + this.finishedSubCalls.push({ + name: call.name, + args: call.args, + output: call.result.output, + isError: call.result.is_error ?? false, + }); + this.upsertSubToolActivity( + call.id, + call.name, + call.args, + call.result.is_error === true ? 'failed' : 'done', + ); + } + while (this.finishedSubCalls.length > MAX_SUB_TOOL_CALLS_SHOWN) { + this.finishedSubCalls.shift(); + this.hiddenSubCallCount += 1; + } + } + + // ── Subagent API (called by KimiTUI event routing) ─────────────── + + setSubagentMeta(agentId: string, agentName?: string): void { + if (this.subagentAgentId === agentId && this.subagentAgentName === agentName) return; + this.subagentAgentId = agentId; + this.subagentAgentName = agentName; + this.headerText.setText(this.buildHeader()); + this.rebuildContent(); + this.notifySnapshotChange(); + this.ui?.requestRender(); + } + + /** + * Lets group containers (AgentGroup or ReadGroup) subscribe to this card's + * state changes. Registration immediately calls back so the group receives + * the current snapshot without separately calling getSubagentSnapshot or + * getReadSnapshot. Pass `undefined` to unsubscribe. + */ + setSnapshotListener(cb: (() => void) | undefined): void { + this.onSnapshotChange = cb; + if (cb !== undefined) cb(); + } + + getSubagentSnapshot(): ToolCallSubagentSnapshot { + const finished = this.finishedSubCalls.length + this.hiddenSubCallCount; + const tokens = + this.subagentUsage === undefined + ? 0 + : usageInputTotal(this.subagentUsage) + this.subagentUsage.output; + const latestActivity = computeLatestActivity( + this.ongoingSubCalls, + this.finishedSubCalls, + this.getCombinedSubagentText(), + this.workspaceDir, + ); + // Terminal-state priority: SDK `tool.result` is authoritative for Agent + // tool calls. Once it arrives, force done/failed over intermediate + // spawning/running states for two reasons: + // 1. Replay does not replay spawned/completed/failed events, so + // `subagentPhase` stays undefined and result must be used. + // 2. Live type-validation failures may skip `subagent.failed`, or + // `tool.result` may arrive first; otherwise the UI can stay stuck at + // 'spawning' and keep showing `Initializing...`. + // Intermediate states without a result still use `subagentPhase`. + // `backgrounded` has no result because background agents do not enter the + // transcript. + const derivedPhase: ToolCallSubagentSnapshot['phase'] = + this.result !== undefined ? (this.result.is_error ? 'failed' : 'done') : this.subagentPhase; + return { + toolCallId: this.toolCall.id, + toolName: this.toolCall.name, + toolCallDescription: str(this.toolCall.args['description']) || str(this.toolCall.description), + agentName: this.subagentAgentName, + phase: derivedPhase, + toolCount: finished, + tokens, + isError: derivedPhase === 'failed', + errorText: + this.subagentError ?? (derivedPhase === 'failed' ? this.result?.output : undefined), + latestActivity, + }; + } + + /** + * Used by `ReadGroupComponent` to sum line counts across same-step Read + * cards. `lines` matches the single-card chip + * (`pluralize(countNonEmptyLines(...), 'line')`) so group and card counts do + * not drift. + */ + getReadSnapshot(): ToolCallReadSnapshot { + const args = this.toolCall.args; + const filePathRaw = args['file_path'] ?? args['path']; + const filePath = + typeof filePathRaw === 'string' + ? makeWorkspaceRelativePath(filePathRaw, this.workspaceDir) + : undefined; + if (this.result === undefined) { + return { toolCallId: this.toolCall.id, filePath, phase: 'pending', lines: 0 }; + } + if (this.result.is_error === true) { + return { toolCallId: this.toolCall.id, filePath, phase: 'failed', lines: 0 }; + } + return { + toolCallId: this.toolCall.id, + filePath, + phase: 'done', + lines: countNonEmptyLines(this.result.output), + }; + } + + // Readonly view for group access to toolCall metadata (id, name, description). + get toolCallView(): Readonly<ToolCallBlockData> { + return this.toolCall; + } + + /** Notifies the listener when internal state changes, if a group is attached. */ + private notifySnapshotChange(): void { + this.onSnapshotChange?.(); + } + + private upsertSubToolActivity( + id: string, + name: string, + args: Record<string, unknown>, + phase: SubToolActivity['phase'], + ): void { + const existing = this.subToolActivities.get(id); + if (existing !== undefined) { + existing.name = name; + existing.args = args; + existing.phase = phase; + return; + } + this.subToolActivities.set(id, { + id, + name, + args, + phase, + orderSeq: ++this.subToolOrderSeq, + }); + } + + private getCombinedSubagentText(): string { + return [this.subagentThinkingText, this.subagentText].filter((s) => s.length > 0).join('\n'); + } + + private isStreamingEditPreview(): boolean { + return ( + this.toolCall.name === 'Edit' && + this.result === undefined && + this.toolCall.streamingArguments !== undefined + ); + } + + private syncStreamingProgressTimer(): void { + if (!this.isStreamingEditPreview()) { + this.stopStreamingProgressTimer(); + return; + } + if (this.ui === undefined || this.streamingProgressTimer !== undefined) return; + this.streamingProgressTimer = setInterval(() => { + if (!this.isStreamingEditPreview()) { + this.stopStreamingProgressTimer(); + return; + } + this.rebuildBody(); + this.ui?.requestRender(); + }, STREAMING_PROGRESS_INTERVAL_MS); + } + + private stopStreamingProgressTimer(): void { + if (this.streamingProgressTimer === undefined) return; + clearInterval(this.streamingProgressTimer); + this.streamingProgressTimer = undefined; + } + + private syncSubagentElapsedTimer(): void { + const phase = this.getDerivedSubagentPhase(); + const shouldTick = + this.isSingleSubagentView() && + this.subagentStartedAtMs !== undefined && + (phase === 'spawning' || phase === 'running'); + if (!shouldTick) { + this.stopSubagentElapsedTimer(); + return; + } + if (this.ui === undefined || this.subagentElapsedTimer !== undefined) return; + this.subagentElapsedTimer = setInterval(() => { + const latestPhase = this.getDerivedSubagentPhase(); + if (latestPhase !== 'spawning' && latestPhase !== 'running') { + this.stopSubagentElapsedTimer(); + return; + } + this.headerText.setText(this.buildHeader()); + this.invalidate(); + this.ui?.requestRender(); + }, SUBAGENT_ELAPSED_INTERVAL_MS); + } + + private stopSubagentElapsedTimer(): void { + if (this.subagentElapsedTimer === undefined) return; + clearInterval(this.subagentElapsedTimer); + this.subagentElapsedTimer = undefined; + } + + private finalizeSubagentElapsedIfNeeded(): void { + if ( + this.toolCall.name === 'Agent' && + this.subagentStartedAtMs !== undefined && + this.subagentEndedAtMs === undefined + ) { + this.subagentEndedAtMs = Date.now(); + } + } + + /** + * Handles SDK `subagent.spawned`. The child agent is registered, but internal + * activity events (`assistant.delta` or `tool.call.started`) may not have + * arrived yet, so the UI moves to the 'spawning' placeholder state unless the + * agent is running in the background. + */ + onSubagentSpawned(meta: { + agentId: string; + agentName?: string | undefined; + runInBackground: boolean; + }): void { + this.subagentAgentId = meta.agentId; + this.subagentAgentName = meta.agentName; + this.subagentPhase = meta.runInBackground ? 'backgrounded' : 'spawning'; + this.subagentStartedAtMs = Date.now(); + this.subagentEndedAtMs = undefined; + this.syncSubagentElapsedTimer(); + this.headerText.setText(this.buildHeader()); + this.rebuildContent(); + this.notifySnapshotChange(); + this.ui?.requestRender(); + } + + /** + * Handles SDK `subagent.completed`. Moves the phase to 'done' and records + * token usage plus the result summary for the header chip and tail summary. + */ + onSubagentCompleted(payload: { + usage?: SubagentTokenUsage | undefined; + resultSummary: string; + }): void { + this.subagentPhase = 'done'; + this.subagentEndedAtMs ??= Date.now(); + this.subagentUsage = payload.usage; + this.subagentResultSummary = + payload.resultSummary.length > 0 ? payload.resultSummary : undefined; + if (this.subagentText.trim().length === 0 && this.subagentResultSummary !== undefined) { + this.subagentText = this.subagentResultSummary; + } + this.syncSubagentElapsedTimer(); + this.headerText.setText(this.buildHeader()); + this.rebuildContent(); + this.notifySnapshotChange(); + this.ui?.requestRender(); + } + + /** Handles SDK `subagent.failed`. */ + onSubagentFailed(payload: { error: string }): void { + this.subagentPhase = 'failed'; + this.subagentEndedAtMs ??= Date.now(); + this.subagentError = payload.error; + this.syncSubagentElapsedTimer(); + this.headerText.setText(this.buildHeader()); + this.rebuildContent(); + this.notifySnapshotChange(); + this.ui?.requestRender(); + } + + appendSubagentText(text: string, kind: SubagentTextKind = 'text'): void { + if (kind === 'thinking') { + this.subagentThinkingText += text; + } else { + this.subagentText += text; + } + // Child-agent activity means it is running unless already terminal/backgrounded. + if (this.subagentPhase === undefined || this.subagentPhase === 'spawning') { + this.subagentPhase = 'running'; + } + this.headerText.setText(this.buildHeader()); + this.rebuildContent(); + this.notifySnapshotChange(); + this.ui?.requestRender(); + } + + appendSubToolCall(call: { id: string; name: string; args: Record<string, unknown> }): void { + const existing = this.ongoingSubCalls.get(call.id); + this.ongoingSubCalls.set(call.id, { + name: call.name, + args: call.args, + ...(existing?.streamingArguments !== undefined + ? { streamingArguments: existing.streamingArguments } + : {}), + }); + this.upsertSubToolActivity(call.id, call.name, call.args, 'ongoing'); + if (this.subagentPhase === undefined || this.subagentPhase === 'spawning') { + this.subagentPhase = 'running'; + } + this.headerText.setText(this.buildHeader()); + this.rebuildContent(); + this.notifySnapshotChange(); + this.ui?.requestRender(); + } + + appendSubToolCallDelta(delta: { + id: string; + name?: string | undefined; + argumentsPart: string | null; + }): void { + const existing = this.ongoingSubCalls.get(delta.id); + const nextArgsText = `${existing?.streamingArguments ?? ''}${delta.argumentsPart ?? ''}`; + const parsed = parseArgsPreview(nextArgsText); + this.ongoingSubCalls.set(delta.id, { + name: delta.name ?? existing?.name ?? 'Tool', + args: parsed, + streamingArguments: nextArgsText, + }); + this.upsertSubToolActivity(delta.id, delta.name ?? existing?.name ?? 'Tool', parsed, 'ongoing'); + this.headerText.setText(this.buildHeader()); + this.rebuildContent(); + this.notifySnapshotChange(); + this.ui?.requestRender(); + } + + finishSubToolCall(result: { + tool_call_id: string; + output: string; + is_error?: boolean | undefined; + }): void { + const ongoing = this.ongoingSubCalls.get(result.tool_call_id); + if (ongoing === undefined) return; + this.ongoingSubCalls.delete(result.tool_call_id); + this.finishedSubCalls.push({ + name: ongoing.name, + args: ongoing.args, + output: result.output, + isError: result.is_error ?? false, + }); + this.upsertSubToolActivity( + result.tool_call_id, + ongoing.name, + ongoing.args, + result.is_error === true ? 'failed' : 'done', + ); + while (this.finishedSubCalls.length > MAX_SUB_TOOL_CALLS_SHOWN) { + this.finishedSubCalls.shift(); + this.hiddenSubCallCount += 1; + } + this.headerText.setText(this.buildHeader()); + this.rebuildContent(); + this.notifySnapshotChange(); + this.ui?.requestRender(); + } + + private buildHeader(): string { + const { toolCall, result, colors } = this; + const isFinished = result !== undefined; + const isError = result?.is_error ?? false; + const isTruncated = toolCall.truncated === true && !isFinished; + + let bullet: string; + if (isFinished) { + bullet = isError ? chalk.hex(colors.error)('✗ ') : chalk.hex(colors.success)(STATUS_BULLET); + } else if (isTruncated) { + bullet = chalk.hex(colors.error)('✗ '); + } else { + // Solid bullet for in-flight tools — the previous marker ↔ blank + // toggle caused visible flicker on every re-render. + bullet = chalk.hex(colors.roleAssistant)(STATUS_BULLET); + } + + if (toolCall.name === 'ExitPlanMode') { + const label = chalk.hex(colors.primary).bold('Current plan'); + if (!isFinished || result === undefined || result.is_error === true) { + return label; + } + const outcome = interpretExitPlanModeOutcome(result.output); + if (outcome.kind === 'approved') { + const chipText = + outcome.chosen !== undefined && outcome.chosen.length > 0 + ? `Approved: ${outcome.chosen}` + : 'Approved'; + return `${label}${chalk.hex(colors.success)(` · ${chipText}`)}`; + } + return `${label}${chalk.hex(colors.error)(' · Rejected')}`; + } + + if (toolCall.name === 'AskUserQuestion') { + const label = isFinished + ? isError + ? 'Could not collect your input' + : 'Collected your answers' + : 'Waiting for your input'; + const tone = isError ? chalk.hex(colors.error) : chalk.hex(colors.primary); + return `${bullet}${tone.bold(label)}`; + } + + if (this.isSingleSubagentView()) { + return this.buildSingleSubagentHeader(); + } + + const verb = isFinished ? 'Used' : isTruncated ? 'Truncated' : 'Using'; + const keyArg = extractKeyArgument(toolCall.name, toolCall.args, this.workspaceDir); + const decoded = decodeMcpToolName(toolCall.name); + const verbStyled = isTruncated + ? chalk.hex(colors.error)(verb) + : verb; + const toolLabel = + decoded !== null + ? `${chalk.hex(colors.primary).bold(decoded.toolName)}${chalk.dim(` · MCP/${decoded.serverName}`)}` + : chalk.hex(colors.primary).bold(toolCall.name); + const argStr = keyArg ? chalk.dim(` (${keyArg})`) : ''; + let chipStr = ''; + if (isFinished && result) chipStr = this.buildHeaderChip(result); + return `${bullet}${verbStyled} ${toolLabel}${argStr}${chipStr}`; + } + + private buildHeaderChip(result: ToolResultBlockData): string { + const provider = pickChip(this.toolCall.name); + if (provider === undefined) return ''; + const text = provider(this.toolCall, result); + if (text.length === 0) return ''; + const tone = result.is_error ? chalk.hex(this.colors.error) : chalk.dim; + return tone(` · ${text}`); + } + + private rebuildContent(): void { + while (this.children.length > this.callPreviewEndIndex) { + this.children.pop(); + } + this.buildProgressBlock(); + this.buildContent(); + this.buildSubagentBlock(); + } + + private rebuildBody(): void { + while (this.children.length > 2) { + this.children.pop(); + } + this.buildCallPreview(); + this.callPreviewEndIndex = this.children.length; + this.buildProgressBlock(); + this.buildContent(); + this.buildSubagentBlock(); + } + + /** + * Render the accumulated `progressLines` between the call preview and + * the result body. URLs inside a line are wrapped in an OSC 8 hyperlink + * sequence so terminals that support it (iTerm2, Ghostty, kitty, modern + * Terminal.app, VS Code) make the URL Cmd-clickable and expose + * "Copy Link" via the context menu — even when pi-tui soft-wraps the + * URL across multiple rows (pi-tui's wrapTextWithAnsi re-opens the + * active OSC 8 link on each continuation line). Each embedded URL is + * styled individually so surrounding prose keeps its default dim tone. + */ + private buildProgressBlock(): void { + if (this.progressLines.length === 0) return; + if (this.result !== undefined) return; + for (const raw of this.progressLines) { + if (raw.length === 0) { + this.addChild(new Text('', 2, 0)); + continue; + } + PROGRESS_URL_RE.lastIndex = 0; + const styled = PROGRESS_URL_RE.test(raw) + ? raw.replace(PROGRESS_URL_RE, (url) => { + const visible = chalk.hex(this.colors.warning).underline(url); + return `\u001B]8;;${url}\u001B\\${visible}\u001B]8;;\u001B\\`; + }) + : chalk.dim(raw); + PROGRESS_URL_RE.lastIndex = 0; + this.addChild(new Text(styled, 2, 0)); + } + } + + private buildSubagentBlock(): void { + if ( + this.subagentAgentId === undefined && + this.ongoingSubCalls.size === 0 && + this.finishedSubCalls.length === 0 && + this.subagentText.length === 0 && + this.subagentPhase === undefined + ) { + return; + } + + if (this.isSingleSubagentView()) { + this.buildSingleSubagentBlock(); + return; + } + + const dim = chalk.dim; + const phaseChip = this.formatPhaseChip(); + const headerLabel = + this.subagentAgentName !== undefined + ? `subagent ${this.subagentAgentName} (${this.formatAgentId()})` + : `subagent (${this.formatAgentId()})`; + this.addChild(new Text(` ${dim(`↳ ${headerLabel}`)}${phaseChip}`, 0, 0)); + + if (this.hiddenSubCallCount > 0) { + const suffix = this.hiddenSubCallCount > 1 ? 's' : ''; + this.addChild( + new Text( + dim.italic(` ${String(this.hiddenSubCallCount)} more tool call${suffix} ...`), + 0, + 0, + ), + ); + } + + for (const sub of this.finishedSubCalls) { + const mark = sub.isError + ? chalk.hex(this.colors.error)('✗') + : chalk.hex(this.colors.success)('•'); + const keyArg = extractKeyArgument(sub.name, sub.args, this.workspaceDir); + const nameCol = chalk.hex(this.colors.primary)(sub.name); + const argCol = keyArg ? dim(` (${keyArg})`) : ''; + this.addChild(new Text(` ${mark} Used ${nameCol}${argCol}`, 0, 0)); + } + + for (const [id, call] of this.ongoingSubCalls) { + const keyArg = extractKeyArgument(call.name, call.args, this.workspaceDir); + const nameCol = chalk.hex(this.colors.primary)(call.name); + const argCol = keyArg ? dim(` (${keyArg})`) : ''; + void id; + this.addChild(new Text(` ${dim('…')} Using ${nameCol}${argCol}`, 0, 0)); + } + + if (this.subagentText.length > 0) { + const tailLines = this.subagentText.split('\n').slice(-3); + for (const line of tailLines) { + this.addChild(new Text(` ${dim(line)}`, 0, 0)); + } + } + + // Result summary from subagent.completed. + if (this.subagentPhase === 'done' && this.subagentResultSummary !== undefined) { + const summaryLines = this.subagentResultSummary.split('\n').slice(0, 2); + for (const line of summaryLines) { + this.addChild(new Text(` ${dim('└')} ${line}`, 0, 0)); + } + } + + // Full error text from subagent.failed; do not collapse it. + if (this.subagentPhase === 'failed' && this.subagentError !== undefined) { + const errLines = this.subagentError.split('\n'); + for (const line of errLines) { + this.addChild(new Text(` ${chalk.hex(this.colors.error)('└')} ${line}`, 0, 0)); + } + } + } + + /** + * Header phase/token chip. No chip is shown when phase is undefined. + * spawning -> starting + * running -> running + * done -> N tools, 8.4k tok + * failed -> failed + * backgrounded -> backgrounded + */ + private formatPhaseChip(): string { + if (this.subagentPhase === undefined) return ''; + const dim = chalk.dim; + const parts: string[] = []; + switch (this.subagentPhase) { + case 'spawning': + parts.push('↻ starting…'); + break; + case 'running': + parts.push('↻ running'); + break; + case 'done': { + parts.push(chalk.hex(this.colors.success)('✓ done')); + const toolCount = this.finishedSubCalls.length + this.hiddenSubCallCount; + if (toolCount > 0) parts.push(`${String(toolCount)} tool${toolCount > 1 ? 's' : ''}`); + const tokens = formatSubagentTokens(this.subagentUsage); + if (tokens !== undefined) parts.push(tokens); + break; + } + case 'failed': + parts.push(chalk.hex(this.colors.error)('✗ failed')); + break; + case 'backgrounded': + parts.push('◐ backgrounded'); + break; + } + return parts.length > 0 ? dim(` · ${parts.join(' · ')}`) : ''; + } + + private formatAgentId(): string { + const id = this.subagentAgentId ?? ''; + return id.length > 10 ? id.slice(0, 10) + '…' : id; + } + + private hasSubagentState(): boolean { + return ( + this.subagentAgentId !== undefined || + this.ongoingSubCalls.size > 0 || + this.finishedSubCalls.length > 0 || + this.subToolActivities.size > 0 || + this.subagentText.length > 0 || + this.subagentThinkingText.length > 0 || + this.subagentPhase !== undefined + ); + } + + private isSingleSubagentView(): boolean { + return this.toolCall.name === 'Agent' && this.hasSubagentState(); + } + + private getDerivedSubagentPhase(): + | 'spawning' + | 'running' + | 'done' + | 'failed' + | 'backgrounded' + | undefined { + if (this.result !== undefined) return this.result.is_error ? 'failed' : 'done'; + return this.subagentPhase; + } + + private buildSingleSubagentHeader(): string { + const phase = this.getDerivedSubagentPhase(); + const isFailed = phase === 'failed'; + const isDone = phase === 'done'; + const bullet = isFailed + ? chalk.hex(this.colors.error)('✗ ') + : isDone + ? chalk.hex(this.colors.success)(STATUS_BULLET) + : chalk.hex(this.colors.roleAssistant)(STATUS_BULLET); + const labelText = formatSubagentLabel(this.subagentAgentName); + const label = chalk.hex(this.colors.primary).bold(labelText); + const status = this.formatSingleSubagentStatus(phase); + const description = str(this.toolCall.args['description']); + const descriptionPlain = description.length > 0 ? ` (${description})` : ''; + const descriptionText = descriptionPlain.length > 0 ? chalk.dim(descriptionPlain) : ''; + const statsText = this.formatSingleSubagentStatsText(); + if (isDone) { + const success = chalk.hex(this.colors.success); + return `${bullet}${success.bold(labelText)} ${success(`Completed${descriptionPlain}${statsText}`)}`; + } + const stats = chalk.dim(statsText); + return `${bullet}${label} ${status}${descriptionText}${stats}`; + } + + private formatSingleSubagentStatus( + phase: 'spawning' | 'running' | 'done' | 'failed' | 'backgrounded' | undefined, + ): string { + switch (phase) { + case 'done': + return chalk.hex(this.colors.success)('Completed'); + case 'failed': + return chalk.hex(this.colors.error)('Failed'); + case 'running': + return chalk.hex(this.colors.primary)('Running'); + case 'backgrounded': + return 'Backgrounded'; + case 'spawning': + case undefined: + return chalk.hex(this.colors.primary)('Starting'); + } + } + + private formatSingleSubagentStatsText(): string { + const parts = [ + `${String(this.subToolActivities.size)} tool${this.subToolActivities.size === 1 ? '' : 's'}`, + ]; + const elapsed = this.getSubagentElapsedSeconds(); + if (elapsed !== undefined) parts.push(formatElapsed(elapsed)); + return ` · ${parts.join(' · ')}`; + } + + private getSubagentElapsedSeconds(): number | undefined { + if (this.subagentStartedAtMs === undefined) return undefined; + const end = this.subagentEndedAtMs ?? Date.now(); + return Math.max(0, Math.floor((end - this.subagentStartedAtMs) / 1000)); + } + + private buildSingleSubagentBlock(): void { + for (const activity of this.getRecentSubToolActivities()) { + const mark = + activity.phase === 'failed' + ? chalk.hex(this.colors.error)('✗') + : activity.phase === 'done' + ? chalk.hex(this.colors.success)('•') + : chalk.hex(this.colors.text)('•'); + const verb = activity.phase === 'ongoing' ? 'Using' : 'Used'; + this.addChild(new Text(` ${mark} ${this.formatSubToolActivity(verb, activity)}`, 0, 0)); + } + + if (this.getDerivedSubagentPhase() === 'failed' && this.subagentError !== undefined) { + const errorLine = tailNonEmptyLines(this.subagentError, 1).at(-1); + if (errorLine !== undefined) { + this.addChild( + new PrefixedWrappedLine( + ` ${chalk.hex(this.colors.error)('└')} `, + ' ', + chalk.hex(this.colors.error)(errorLine), + ), + ); + } + return; + } + + const outputLine = tailNonEmptyLines(this.subagentText, 1).at(-1); + const thinkingLine = tailNonEmptyLines(this.subagentThinkingText, 1).at(-1); + if (this.getDerivedSubagentPhase() !== 'done' && thinkingLine !== undefined) { + this.addChild( + new PrefixedWrappedLine(` ${chalk.dim('◌')} `, ' ', chalk.dim(thinkingLine)), + ); + } + if (outputLine !== undefined) { + this.addChild( + new PrefixedWrappedLine( + ` ${chalk.hex(this.colors.text)('└')} `, + ' ', + chalk.hex(this.colors.text)(outputLine), + ), + ); + } + } + + private getRecentSubToolActivities(): SubToolActivity[] { + return [...this.subToolActivities.values()] + .toSorted((a, b) => a.orderSeq - b.orderSeq) + .slice(-MAX_SINGLE_SUBAGENT_TOOL_ROWS); + } + + private formatSubToolActivity(verb: string, activity: SubToolActivity): string { + const keyArg = extractKeyArgument(activity.name, activity.args, this.workspaceDir); + const nameCol = chalk.hex(this.colors.primary)(activity.name); + const argCol = keyArg ? chalk.dim(` (${keyArg})`) : ''; + return `${verb} ${nameCol}${argCol}`; + } + + private buildCallPreview(): void { + const name = this.toolCall.name; + if (name === 'ExitPlanMode') { + this.buildPlanPreview(); + return; + } + if (this.result === undefined && this.toolCall.truncated === true) { + this.addChild( + new Text( + chalk.dim('Tool call arguments truncated by max_tokens — call never executed.'), + 2, + 0, + ), + ); + return; + } + if (this.result === undefined && this.toolCall.streamingArguments !== undefined) { + this.buildStreamingPreview(this.toolCall.streamingArguments); + return; + } + // Collapse only kicks in once the result lands (bullet turns + // green). Between args-finalize and result we may sit in approval + // for a while — keep the preview fully visible during that gap so + // the user reviews the full change in context. + const shouldCap = this.result !== undefined && !this.expanded; + if (name === 'Write') { + const content = str(this.toolCall.args['content']); + if (content.length === 0) return; + const filePath = str(this.toolCall.args['file_path'] ?? this.toolCall.args['path']); + const lang = langFromPath(filePath); + const allLines = highlightLines(content, lang); + const shown = shouldCap ? allLines.slice(0, COMMAND_PREVIEW_LINES) : allLines; + const remaining = allLines.length - shown.length; + for (const [i, line] of shown.entries()) { + const lineNum = chalk.dim(String(i + 1).padStart(4) + ' '); + this.addChild(new Text(lineNum + line, 2, 0)); + } + if (shouldCap && remaining > 0) { + this.addChild( + new Text( + chalk.dim( + `... (${String(remaining)} more lines, ${String(allLines.length)} total, ctrl+o to expand)`, + ), + 2, + 0, + ), + ); + } + } else if (name === 'Edit') { + const oldStr = str(this.toolCall.args['old_string']); + const newStr = str(this.toolCall.args['new_string']); + if (oldStr.length === 0 && newStr.length === 0) return; + const filePath = str(this.toolCall.args['file_path'] ?? this.toolCall.args['path']); + const lines = renderDiffLinesClustered(oldStr, newStr, filePath, this.colors, { + contextLines: 3, + ...(shouldCap ? { maxLines: COMMAND_PREVIEW_LINES } : {}), + }); + for (const line of lines) { + this.addChild(new Text(line, 2, 0)); + } + } + } + + /** + * Live-rendering during the `tool.call.delta` streaming window. + * + * For tools we recognise, we reach into the partial JSON (via + * `extractPartialStringField`) and render a stable high-signal + * preview: Write's `content` as highlighted code, Edit's argument + * receive progress, Bash's `$ command`, etc. While args are still + * streaming we render the body in full (no 10-line cap) — once the + * result lands, the preview snaps to the collapsed cap unless the user + * has expanded. + */ + private buildStreamingPreview(streamText: string): void { + const name = this.toolCall.name; + if (name === 'Write') { + const content = extractPartialStringField(streamText, 'content'); + if (content === undefined || content.length === 0) return; + const filePath = + extractPartialStringField(streamText, 'file_path') ?? + extractPartialStringField(streamText, 'path') ?? + ''; + const lang = langFromPath(filePath); + const allLines = highlightLines(content, lang); + for (const [i, line] of allLines.entries()) { + const lineNum = chalk.dim(String(i + 1).padStart(4) + ' '); + this.addChild(new Text(lineNum + line, 2, 0)); + } + return; + } + if (name === 'Edit') { + const filePath = + extractPartialStringField(streamText, 'file_path') ?? + extractPartialStringField(streamText, 'path') ?? + ''; + const bytes = Buffer.byteLength(streamText, 'utf8'); + const startedAtMs = this.toolCall.streamingStartedAtMs; + const elapsedSeconds = + startedAtMs === undefined ? 0 : Math.max(0, Math.floor((Date.now() - startedAtMs) / 1000)); + const target = filePath.length > 0 ? ` for ${filePath}` : ''; + const progress = `Preparing changes${target}... ${formatByteSize(bytes)} · ${formatElapsed( + elapsedSeconds, + )} elapsed`; + this.addChild(new Text(chalk.dim(progress), 2, 0)); + return; + } + if (name === 'Bash') { + const cmd = extractPartialStringField(streamText, 'command'); + if (cmd === undefined || cmd.length === 0) return; + this.addChild( + new ShellExecutionComponent({ + command: cmd, + colors: this.colors, + showCommand: true, + commandPreviewLines: COMMAND_PREVIEW_LINES, + }), + ); + } + // Unknown tools: nothing sensible to stream without a schema, so + // leave the body blank and let the header do the talking. + } + + private buildPlanPreview(): void { + // Priority: inline `args.plan`, approved plan parsed from result, then + // asynchronously injected currentPlan used while approval is in flight. + // Once a plan is found, PlanBoxComponent renders it. Without markdownTheme + // (unit tests), fall back to indented dim text so it remains visible. + const plan = this.resolvePlanForPreview(); + if (plan.length === 0) return; + const path = this.resolvePlanPath(); + if (this.markdownTheme !== undefined) { + this.addChild( + new PlanBoxComponent(plan, this.markdownTheme, this.colors.success, path, { + maxContentLines: this.computePlanBoxMaxContentLines(), + expanded: this.planExpanded, + }), + ); + } else { + this.addChild(new Text(chalk.dim(plan), 2, 0)); + } + } + + private computePlanBoxMaxContentLines(): number | undefined { + const rows = this.ui?.terminal.rows; + if (rows === undefined || !Number.isFinite(rows) || rows <= 0) return undefined; + return Math.max(8, Math.floor(rows * 0.6) - 4); + } + + private resolvePlanForPreview(): string { + const inlinePlan = str(this.toolCall.args['plan']); + if (inlinePlan.length > 0) return inlinePlan; + if (this.result !== undefined && !this.result.is_error) { + const approved = extractApprovedPlan(this.result.output); + if (approved.length > 0) return approved; + } + return this.currentPlan ?? ''; + } + + // Priority: approved result.output with 'Plan saved to: <path>', then the + // planPath asynchronously injected by setPlanInfo while approval is in flight. + private resolvePlanPath(): string | undefined { + if (this.result !== undefined && !this.result.is_error) { + const fromResult = interpretExitPlanModeOutcome(this.result.output).path; + if (fromResult !== undefined && fromResult.length > 0) return fromResult; + } + return this.planPath; + } + + private buildContent(): void { + const { result } = this; + if (result === undefined || !result.output) return; + + if (this.isSingleSubagentView()) { + return; + } + + // Outputs that start with a `<system…>` tag are harness-injected + // reminders piggy-backing on a tool result. They are noise for the + // user, so suppress the body while keeping the header chip intact. + if (result.output.trimStart().startsWith('<system')) { + return; + } + + if (this.toolCall.name === 'ExitPlanMode' && !result.is_error) { + // Approved plans are already rendered by buildCallPreview via + // resolvePlanForPreview. Rejected or revise feedback uses a warning label + // plus normal body text so it remains visible in the transcript. + const outcome = interpretExitPlanModeOutcome(result.output); + if (outcome.kind === 'rejected' && outcome.feedback !== undefined) { + const trimmed = outcome.feedback.trim(); + if (trimmed.length > 0) { + const labelTone = chalk.hex(this.colors.warning).bold; + this.addChild(new Text(labelTone('↪ Suggestion'), 2, 0)); + for (const line of trimmed.split('\n')) { + this.addChild(new Text(line, 4, 0)); + } + } + } + return; + } + + // TodoList: the authoritative list is shown in the dedicated + // TodoPanel before the input area, so repeating the text dump here is + // pure clutter. Keep the headline, drop the body. + if (this.toolCall.name === 'TodoList' && !result.is_error) { + return; + } + + if (this.toolCall.name === 'EnterPlanMode' && !result.is_error) { + return; + } + + if ( + this.toolCall.name === 'AskUserQuestion' && + !result.is_error && + this.renderAskUserQuestionResult(result.output) + ) { + return; + } + + const renderer = pickResultRenderer(this.toolCall.name); + const components = renderer(this.toolCall, result, { + expanded: this.expanded, + colors: this.colors, + }); + for (const component of components) { + this.addChild(component); + } + } + + /** + * Render AskUserQuestion's JSON payload as a friendly Q/A list. + * Returns true on success (caller skips the default JSON dump); + * false on parse failure (caller falls back to raw display). + */ + private renderAskUserQuestionResult(output: string): boolean { + let parsed: unknown; + try { + parsed = JSON.parse(output); + } catch { + return false; + } + if (typeof parsed !== 'object' || parsed === null) return false; + + const colors = this.colors; + const dim = chalk.dim; + const accent = chalk.hex(colors.primary); + + const answers = (parsed as { answers?: unknown }).answers; + const note = (parsed as { note?: unknown }).note; + + const hasAnswers = + typeof answers === 'object' && answers !== null && Object.keys(answers).length > 0; + + if (!hasAnswers) { + const noteText = + typeof note === 'string' && note.length > 0 ? note : 'User dismissed the question.'; + this.addChild(new Text(dim(` ${noteText}`), 0, 0)); + return true; + } + + for (const [question, answer] of Object.entries(answers as Record<string, unknown>)) { + const answerText = typeof answer === 'string' ? answer : JSON.stringify(answer); + this.addChild(new Text(` ${dim('Q')} ${question}`, 0, 0)); + this.addChild(new Text(` ${accent('→')} ${answerText}`, 0, 0)); + } + return true; + } +} + +/** + * Computes the second-level "latest activity" line for group rows: + * 1. latest ongoing sub-tool (`Using {name} ({keyArg})`) + * 2. latest finished sub-tool (`Used {name} ({keyArg})`) + * 3. last non-empty line from accumulated subagent text + */ +function computeLatestActivity( + ongoing: ReadonlyMap<string, OngoingSubCall>, + finished: readonly FinishedSubCall[], + text: string, + workspaceDir?: string, +): string | undefined { + if (ongoing.size > 0) { + const lastOngoing = [...ongoing.values()].at(-1); + if (lastOngoing !== undefined) { + return formatActivityLine('Using', lastOngoing.name, lastOngoing.args, workspaceDir); + } + } + if (finished.length > 0) { + const last = finished.at(-1); + if (last !== undefined) { + return formatActivityLine('Used', last.name, last.args, workspaceDir); + } + } + if (text.length > 0) { + const tail = text + .split('\n') + .toReversed() + .find((l) => l.trim().length > 0); + if (tail !== undefined) return tail.trim(); + } + return undefined; +} + +function formatActivityLine( + verb: string, + toolName: string, + args: Record<string, unknown>, + workspaceDir?: string, +): string { + const keyArg = extractKeyArgument(toolName, args, workspaceDir); + return keyArg ? `${verb} ${toolName} (${keyArg})` : `${verb} ${toolName}`; +} diff --git a/apps/kimi-code/src/tui/components/messages/tool-renderers/chip.ts b/apps/kimi-code/src/tui/components/messages/tool-renderers/chip.ts new file mode 100644 index 000000000..a0200a761 --- /dev/null +++ b/apps/kimi-code/src/tui/components/messages/tool-renderers/chip.ts @@ -0,0 +1,126 @@ +/** + * Header chip providers — produce a short "stat" suffix appended to the + * tool call header once a result has arrived. Chips own the *numeric* + * summary (line counts, exit codes, byte sizes), so summary renderers + * below don't repeat them. + * + * A chip returning `''` is suppressed; tools without an entry in the + * registry get no chip at all. + */ + +import { computeDiffLines } from '#/tui/components/media/diff-preview'; +import type { ToolCallBlockData, ToolResultBlockData } from '#/tui/types'; + +import { readMediaChip } from './media'; +import { strArg } from './types'; + +export type ChipProvider = (toolCall: ToolCallBlockData, result: ToolResultBlockData) => string; + +export function countNonEmptyLines(text: string): number { + if (text.length === 0) return 0; + let n = 0; + for (const line of text.split('\n')) if (line.length > 0) n++; + return n; +} + +function pluralize(n: number, singular: string, plural?: string): string { + return `${String(n)} ${n === 1 ? singular : (plural ?? `${singular}s`)}`; +} + +function formatBytes(bytes: number): string { + if (bytes < 1024) return `${String(bytes)} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + return `${(bytes / 1024 / 1024).toFixed(1)} MB`; +} + +export interface EditStats { + readonly added: number; + readonly removed: number; +} + +export interface WriteStats { + readonly lines: number; +} + +export function computeEditStats(args: Record<string, unknown>): EditStats { + const oldStr = strArg(args, 'old_string'); + const newStr = strArg(args, 'new_string'); + if (oldStr.length === 0 && newStr.length === 0) return { added: 0, removed: 0 }; + const diff = computeDiffLines(oldStr, newStr); + let added = 0; + let removed = 0; + for (const line of diff) { + if (line.kind === 'add') added++; + else if (line.kind === 'delete') removed++; + } + return { added, removed }; +} + +export function computeWriteStats(args: Record<string, unknown>): WriteStats { + const content = strArg(args, 'content'); + const normalized = content.endsWith('\n') ? content.slice(0, -1) : content; + const lines = normalized.length > 0 ? normalized.split('\n').length : 0; + return { lines }; +} + +export function formatEditChip(stats: EditStats): string { + const parts: string[] = []; + if (stats.added > 0) parts.push(`+${String(stats.added)}`); + if (stats.removed > 0) parts.push(`-${String(stats.removed)}`); + return parts.join(' '); +} + +export function formatWriteChip(stats: WriteStats): string { + return pluralize(stats.lines, 'line'); +} + +const editChip: ChipProvider = (toolCall) => { + const stats = computeEditStats(toolCall.args); + if (stats.added === 0 && stats.removed === 0) return ''; + return formatEditChip(stats); +}; + +const writeChip: ChipProvider = (toolCall) => formatWriteChip(computeWriteStats(toolCall.args)); + +const readChip: ChipProvider = (_toolCall, result) => + pluralize(countNonEmptyLines(result.output), 'line'); + +const grepChip: ChipProvider = (_toolCall, result) => { + const matches = countNonEmptyLines(result.output); + if (matches === 0) return 'no matches'; + return pluralize(matches, 'match', 'matches'); +}; + +const globChip: ChipProvider = (_toolCall, result) => { + const files = countNonEmptyLines(result.output); + if (files === 0) return 'no files'; + return pluralize(files, 'file'); +}; + +const fetchChip: ChipProvider = (_toolCall, result) => + formatBytes(Buffer.byteLength(result.output, 'utf8')); + +const webSearchChip: ChipProvider = (_toolCall, result) => { + const lines = result.output.split('\n').filter((l) => l.trim().length > 0); + let count = 0; + for (const line of lines) { + if (/^\s*(\d+\.|[-*])\s+/.test(line)) count++; + } + if (count === 0) return lines.length === 0 ? 'no results' : 'web result'; + return pluralize(count, 'result'); +}; + +const REGISTRY: Record<string, ChipProvider> = { + Edit: editChip, + Write: writeChip, + Read: readChip, + ReadMediaFile: readMediaChip, + Grep: grepChip, + Glob: globChip, + FetchURL: fetchChip, + WebSearch: webSearchChip, +}; + +export function pickChip(toolName: string): ChipProvider | undefined { + return REGISTRY[toolName]; +} diff --git a/apps/kimi-code/src/tui/components/messages/tool-renderers/media.ts b/apps/kimi-code/src/tui/components/messages/tool-renderers/media.ts new file mode 100644 index 000000000..fd753cd27 --- /dev/null +++ b/apps/kimi-code/src/tui/components/messages/tool-renderers/media.ts @@ -0,0 +1,152 @@ +/** + * ReadMediaFile renderer. + * + * The ReadMediaFile tool `output` is the JSON-serialized array of + * content parts the tool returned — which includes the full base64 of + * the image/video. Dumping that string into the transcript blasts a + * multi-screen blob of base64. This renderer parses the envelope and + * surfaces just the human-readable bits (kind, path, mime, size) via + * a header chip + a tiny expanded body. It never emits the base64. + * + * On error, or when the output isn't the expected media envelope, we + * fall back to the truncated renderer so the user still sees the raw + * message. + */ + +import type { Component } from '@earendil-works/pi-tui'; +import { Text } from '@earendil-works/pi-tui'; +import chalk from 'chalk'; + +import type { ChipProvider } from './chip'; +import { renderTruncated } from './truncated'; +import type { ResultRenderer } from './types'; + +export interface ReadMediaSummary { + kind: 'image' | 'video'; + path?: string; + mimeType?: string; + bytes?: number; + url?: string; + originalSize?: string; +} + +const PATH_TAG_RE = /^<(image|video)\s+path="([^"]+)">$/; +const ORIGINAL_SIZE_RE = /original size\s+(\d+x\d+px)/; +const DATA_URL_RE = /^data:([^;]+);base64,(.*)$/s; + +function bytesFromBase64(b64: string): number { + const len = b64.length; + if (len === 0) return 0; + const padding = b64.endsWith('==') ? 2 : b64.endsWith('=') ? 1 : 0; + return Math.floor((len * 3) / 4) - padding; +} + +export function parseReadMediaOutput(output: string): ReadMediaSummary | null { + let parsed: unknown; + try { + parsed = JSON.parse(output); + } catch { + return null; + } + if (!Array.isArray(parsed)) return null; + + let kind: 'image' | 'video' | undefined; + let path: string | undefined; + let mimeType: string | undefined; + let bytes: number | undefined; + let url: string | undefined; + let originalSize: string | undefined; + let foundMedia = false; + + for (const raw of parsed) { + if (typeof raw !== 'object' || raw === null) continue; + const part = raw as Record<string, unknown>; + const type = part['type']; + + if (type === 'text' && typeof part['text'] === 'string') { + const text = part['text']; + const tag = PATH_TAG_RE.exec(text); + if (tag) { + kind = tag[1] as 'image' | 'video'; + path = tag[2]; + continue; + } + const size = ORIGINAL_SIZE_RE.exec(text); + if (size) originalSize = size[1]; + continue; + } + + if (type === 'image_url' || type === 'video_url') { + foundMedia = true; + kind = type === 'image_url' ? 'image' : 'video'; + const holder = part[type === 'image_url' ? 'imageUrl' : 'videoUrl']; + if (typeof holder === 'object' && holder !== null) { + const h = holder as Record<string, unknown>; + const u = h['url']; + if (typeof u === 'string') { + const data = DATA_URL_RE.exec(u); + if (data && data[1] !== undefined && data[2] !== undefined) { + mimeType = data[1]; + bytes = bytesFromBase64(data[2]); + } else { + url = u; + } + } + } + } + } + + if (!foundMedia || kind === undefined) return null; + + const summary: ReadMediaSummary = { kind }; + if (path !== undefined) summary.path = path; + if (mimeType !== undefined) summary.mimeType = mimeType; + if (bytes !== undefined) summary.bytes = bytes; + if (url !== undefined) summary.url = url; + if (originalSize !== undefined) summary.originalSize = originalSize; + return summary; +} + +function formatBytes(bytes: number): string { + if (bytes < 1024) return `${String(bytes)} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + return `${(bytes / 1024 / 1024).toFixed(1)} MB`; +} + +function metaSegments(summary: ReadMediaSummary): string[] { + const segs: string[] = []; + if (summary.mimeType !== undefined) segs.push(summary.mimeType); + if (summary.bytes !== undefined) segs.push(formatBytes(summary.bytes)); + if (summary.originalSize !== undefined) segs.push(summary.originalSize); + return segs; +} + +export const readMediaChip: ChipProvider = (_toolCall, result) => { + if (result.is_error) return ''; + const summary = parseReadMediaOutput(result.output); + if (summary === null) return ''; + const meta = metaSegments(summary); + if (meta.length === 0) { + return summary.url !== undefined ? `${summary.kind} · uploaded` : summary.kind; + } + return `${summary.kind} (${meta.join(', ')})`; +}; + +export const readMediaSummary: ResultRenderer = (toolCall, result, ctx) => { + if (result.is_error) return renderTruncated(toolCall, result, ctx); + const summary = parseReadMediaOutput(result.output); + if (summary === null) return renderTruncated(toolCall, result, ctx); + if (!ctx.expanded) return []; + + const dim = chalk.dim; + const out: Component[] = []; + if (summary.path !== undefined) { + out.push(new Text(` ${dim(summary.path)}`, 0, 0)); + } + const meta = metaSegments(summary); + const tail: string[] = [summary.kind]; + if (meta.length > 0) tail.push(meta.join(', ')); + if (summary.url !== undefined) tail.push(summary.url); + out.push(new Text(` ${dim(tail.join(' · '))}`, 0, 0)); + return out; +}; diff --git a/apps/kimi-code/src/tui/components/messages/tool-renderers/registry.ts b/apps/kimi-code/src/tui/components/messages/tool-renderers/registry.ts new file mode 100644 index 000000000..2d59b805b --- /dev/null +++ b/apps/kimi-code/src/tui/components/messages/tool-renderers/registry.ts @@ -0,0 +1,55 @@ +/** + * Tool result renderer registry. + * + * Each tool name maps to a `ResultRenderer` that turns the tool's + * `ToolResultBlockData` into renderable Components. Tools without an + * explicit entry fall through to `renderTruncated` (the original + * 3-line + ctrl+o behavior). + * + * Keep this dispatch flat — tool names live next to the renderer they + * choose, so adding a new tool means appending one case. + */ + +import { readMediaSummary } from './media'; +import { shellExecutionResultRenderer } from '../shell-execution'; +import { + editSummary, + fetchSummary, + globSummary, + grepSummary, + readSummary, + thinkSummary, + webSearchSummary, + writeSummary, +} from './summary'; +import { renderTruncated } from './truncated'; +import type { ResultRenderer } from './types'; + +export function pickResultRenderer(toolName: string): ResultRenderer { + switch (toolName) { + case 'Read': + return readSummary; + case 'ReadMediaFile': + return readMediaSummary; + case 'Grep': + return grepSummary; + case 'Glob': + return globSummary; + case 'FetchURL': + return fetchSummary; + case 'WebSearch': + return webSearchSummary; + case 'Bash': + return shellExecutionResultRenderer; + case 'Think': + return thinkSummary; + case 'Edit': + return editSummary; + case 'Write': + return writeSummary; + default: + return renderTruncated; + } +} + +export type { ResultRenderer } from './types'; diff --git a/apps/kimi-code/src/tui/components/messages/tool-renderers/summary.ts b/apps/kimi-code/src/tui/components/messages/tool-renderers/summary.ts new file mode 100644 index 000000000..a3f929dcc --- /dev/null +++ b/apps/kimi-code/src/tui/components/messages/tool-renderers/summary.ts @@ -0,0 +1,91 @@ +/** + * Summary-style renderers — produce optional inline-glance content for + * tools whose raw output is high-volume but low-information (Grep, + * Glob). The numeric summary (line counts, exit codes, sizes) lives in + * the header chip (see chip.ts), so most tools intentionally render an + * empty body and only expose details when the global expand toggle is + * on. + * + * Errors always fall through to the truncated renderer so the user + * sees the actual error message, not a synthetic summary. + */ + +import type { Component } from '@earendil-works/pi-tui'; +import { Text } from '@earendil-works/pi-tui'; +import chalk from 'chalk'; + +import { renderTruncated } from './truncated'; +import type { ResultRenderer } from './types'; + +const GLANCE_SAMPLES = 3; + +type GlanceFn = ( + toolCall: Parameters<ResultRenderer>[0], + result: Parameters<ResultRenderer>[1], +) => string; + +function withGlance(glance: GlanceFn | null): ResultRenderer { + return (toolCall, result, ctx) => { + if (result.is_error) return renderTruncated(toolCall, result, ctx); + + const out: Component[] = []; + if (glance !== null) { + const line = glance(toolCall, result); + if (line.length > 0) { + out.push(new Text(` ${chalk.dim(line)}`, 0, 0)); + } + } + if (ctx.expanded && result.output.length > 0) { + out.push(new Text(chalk.dim(result.output), 4, 0)); + } + return out; + }; +} + +function nonEmptyLines(text: string): string[] { + if (text.length === 0) return []; + return text.split('\n').filter((line) => line.length > 0); +} + +// Strip a trailing `:line:col:text` so the glance shows the file path +// only, even when grep is in `content` mode (`src/foo.ts:42: foo()`). +function pathFromGrepLine(line: string): string { + const idx = line.indexOf(':'); + if (idx <= 0) return line; + const second = line.indexOf(':', idx + 1); + if (second <= 0) return line; + return line.slice(0, second); +} + +const grepGlance: GlanceFn = (_toolCall, result) => { + const lines = nonEmptyLines(result.output); + if (lines.length === 0) return ''; + const samples = lines.slice(0, GLANCE_SAMPLES).map(pathFromGrepLine); + const remaining = lines.length - samples.length; + const tail = remaining > 0 ? `, +${String(remaining)} more` : ''; + return `${samples.join(', ')}${tail}`; +}; + +const globGlance: GlanceFn = (_toolCall, result) => { + const lines = nonEmptyLines(result.output); + if (lines.length === 0) return ''; + const samples = lines.slice(0, GLANCE_SAMPLES); + const remaining = lines.length - samples.length; + const tail = remaining > 0 ? `, +${String(remaining)} more` : ''; + return `${samples.join(', ')}${tail}`; +}; + +// ── Exports ────────────────────────────────────────────────────────── + +// Tools whose chip already conveys everything — the body is empty in +// the collapsed state and only the raw output appears when expanded. +export const readSummary: ResultRenderer = withGlance(null); +export const fetchSummary: ResultRenderer = withGlance(null); +export const webSearchSummary: ResultRenderer = withGlance(null); +export const thinkSummary: ResultRenderer = withGlance(null); +export const editSummary: ResultRenderer = withGlance(null); +export const writeSummary: ResultRenderer = withGlance(null); + +// Tools that benefit from inline path samples below the chip. +export const grepSummary: ResultRenderer = withGlance(grepGlance); +export const globSummary: ResultRenderer = withGlance(globGlance); diff --git a/apps/kimi-code/src/tui/components/messages/tool-renderers/truncated.ts b/apps/kimi-code/src/tui/components/messages/tool-renderers/truncated.ts new file mode 100644 index 000000000..2bd9cf01b --- /dev/null +++ b/apps/kimi-code/src/tui/components/messages/tool-renderers/truncated.ts @@ -0,0 +1,22 @@ +import type { Component } from '@earendil-works/pi-tui'; +import { Text } from '@earendil-works/pi-tui'; +import chalk from 'chalk'; + +import type { ResultRenderer } from './types'; +import { PREVIEW_LINES } from './types'; + +export const renderTruncated: ResultRenderer = (_toolCall, result, ctx) => { + if (!result.output) return []; + const tint = result.is_error ? chalk.hex(ctx.colors.error) : chalk.dim; + const lines = result.output.split('\n'); + if (ctx.expanded) { + return [new Text(tint(result.output), 2, 0)]; + } + const shown = lines.slice(0, PREVIEW_LINES); + const remaining = lines.length - shown.length; + const out: Component[] = [new Text(tint(shown.join('\n')), 2, 0)]; + if (remaining > 0) { + out.push(new Text(chalk.dim(`... (${String(remaining)} more lines, ctrl+o to expand)`), 2, 0)); + } + return out; +}; diff --git a/apps/kimi-code/src/tui/components/messages/tool-renderers/types.ts b/apps/kimi-code/src/tui/components/messages/tool-renderers/types.ts new file mode 100644 index 000000000..462b53a44 --- /dev/null +++ b/apps/kimi-code/src/tui/components/messages/tool-renderers/types.ts @@ -0,0 +1,26 @@ +import type { Component } from '@earendil-works/pi-tui'; + +import { RESULT_PREVIEW_LINES } from '#/tui/constant/rendering'; +import type { ColorPalette } from '#/tui/theme/colors'; +import type { ToolCallBlockData, ToolResultBlockData } from '#/tui/types'; + +export interface RendererContext { + readonly expanded: boolean; + readonly colors: ColorPalette; +} + +export type ResultRenderer = ( + toolCall: ToolCallBlockData, + result: ToolResultBlockData, + ctx: RendererContext, +) => Component[]; + +export const PREVIEW_LINES = RESULT_PREVIEW_LINES; + +export function strArg(args: Record<string, unknown>, ...keys: string[]): string { + for (const key of keys) { + const v = args[key]; + if (typeof v === 'string' && v.length > 0) return v; + } + return ''; +} diff --git a/apps/kimi-code/src/tui/components/messages/usage-panel.ts b/apps/kimi-code/src/tui/components/messages/usage-panel.ts new file mode 100644 index 000000000..4db3d8c88 --- /dev/null +++ b/apps/kimi-code/src/tui/components/messages/usage-panel.ts @@ -0,0 +1,246 @@ +/** + * UsagePanelComponent — wraps pre-coloured `/usage` lines in a blue box + * border with a left indent, mirroring the PlanBoxComponent layout so + * the pattern stays consistent across command-triggered panels. + */ + +import type { Component } from '@earendil-works/pi-tui'; +import { truncateToWidth, visibleWidth } from '@earendil-works/pi-tui'; +import type { SessionUsage, TokenUsage } from '@moonshot-ai/kimi-code-sdk'; +import chalk from 'chalk'; + +import { + formatTokenCount, + ratioSeverity, + renderProgressBar, + safeUsageRatio, +} from '#/utils/usage/usage-format'; +import type { ColorPalette } from '#/tui/theme/colors'; + +const LEFT_MARGIN = 2; +const SIDE_PADDING = 1; +const MIN_INTERIOR_WIDTH = 20; + +type Colorize = (text: string) => string; + +export interface ManagedUsageRow { + readonly label: string; + readonly used: number; + readonly limit: number; + readonly resetHint?: string; +} + +export interface ManagedUsageReport { + readonly summary: ManagedUsageRow | null; + readonly limits: readonly ManagedUsageRow[]; +} + +export interface UsageReportOptions { + readonly colors: ColorPalette; + readonly sessionUsage?: SessionUsage; + readonly sessionUsageError?: string; + readonly contextUsage: number; + readonly contextTokens: number; + readonly maxContextTokens: number; + readonly managedUsage?: ManagedUsageReport; + readonly managedUsageError?: string; +} + +export interface ManagedUsageReportLineOptions { + readonly colors: ColorPalette; + readonly managedUsage?: ManagedUsageReport; + readonly managedUsageError?: string; +} + +function usageNumber(value: unknown): number { + return typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : 0; +} + +function usageInputTotal(usage: TokenUsage): number { + return ( + usageNumber(usage.inputOther) + + usageNumber(usage.inputCacheRead) + + usageNumber(usage.inputCacheCreation) + ); +} + +function buildSessionUsageSection( + usage: SessionUsage | undefined, + error: string | undefined, + value: Colorize, + muted: Colorize, + errorStyle: Colorize, +): string[] { + if (error !== undefined) return [errorStyle(` ${error}`)]; + const byModel = (usage as { readonly byModel?: Record<string, TokenUsage> } | undefined) + ?.byModel; + const entries = Object.entries(byModel ?? {}); + if (entries.length === 0) return [muted(' No token usage recorded yet.')]; + + const lines: string[] = []; + let totalInput = 0; + let totalOutput = 0; + for (const [model, row] of entries) { + const input = usageInputTotal(row); + const output = usageNumber(row.output); + totalInput += input; + totalOutput += output; + lines.push( + ` ${muted(model)} input ${value(formatTokenCount(input))} output ${value( + formatTokenCount(output), + )} total ${value(formatTokenCount(input + output))}`, + ); + } + if (entries.length > 1) { + lines.push( + ` ${muted('total')} input ${value(formatTokenCount(totalInput))} output ${value( + formatTokenCount(totalOutput), + )} total ${value(formatTokenCount(totalInput + totalOutput))}`, + ); + } + return lines; +} + +function buildManagedUsageSection( + usage: ManagedUsageReport | undefined, + error: string | undefined, + accent: Colorize, + value: Colorize, + muted: Colorize, + errorStyle: Colorize, + severityHex: (sev: 'ok' | 'warn' | 'danger') => string, +): string[] { + if (error !== undefined) return [accent('Plan usage'), errorStyle(` ${error}`)]; + if (usage === undefined) return []; + const { summary, limits } = usage; + if (summary === null && limits.length === 0) { + return [accent('Plan usage'), muted(' No usage data available.')]; + } + + const rows: ManagedUsageRow[] = []; + if (summary !== null) rows.push(summary); + rows.push(...limits); + const labelWidth = Math.max(10, ...rows.map((r) => r.label.length)); + const out: string[] = [accent('Plan usage')]; + for (const row of rows) { + const ratioUsed = row.limit > 0 ? row.used / row.limit : 0; + const leftRatio = 1 - Math.max(0, Math.min(ratioUsed, 1)); + const bar = renderProgressBar(Math.max(0, Math.min(ratioUsed, 1)), 20); + const pct = `${Math.round(leftRatio * 100)}% left`; + const barColoured = chalk.hex(severityHex(ratioSeverity(ratioUsed)))(bar); + const label = row.label.padEnd(labelWidth, ' '); + const resetStr = row.resetHint ? muted(` (${row.resetHint})`) : ''; + out.push(` ${muted(label)} ${barColoured} ${value(pct)}${resetStr}`); + } + return out; +} + +export function buildManagedUsageReportLines(options: ManagedUsageReportLineOptions): string[] { + const colors = options.colors; + const accent = chalk.hex(colors.primary).bold; + const value = chalk.hex(colors.text); + const muted = chalk.hex(colors.textDim); + const errorStyle = chalk.hex(colors.error); + const severityHex = (sev: 'ok' | 'warn' | 'danger'): string => + sev === 'danger' ? colors.error : sev === 'warn' ? colors.warning : colors.success; + + return buildManagedUsageSection( + options.managedUsage, + options.managedUsageError, + accent, + value, + muted, + errorStyle, + severityHex, + ); +} + +export function buildUsageReportLines(options: UsageReportOptions): string[] { + const colors = options.colors; + const accent = chalk.hex(colors.primary).bold; + const value = chalk.hex(colors.text); + const muted = chalk.hex(colors.textDim); + const errorStyle = chalk.hex(colors.error); + const severityHex = (sev: 'ok' | 'warn' | 'danger'): string => + sev === 'danger' ? colors.error : sev === 'warn' ? colors.warning : colors.success; + + const lines: string[] = [ + accent('Session usage'), + ...buildSessionUsageSection( + options.sessionUsage, + options.sessionUsageError, + value, + muted, + errorStyle, + ), + ]; + + if (options.maxContextTokens > 0) { + const ratio = safeUsageRatio(options.contextUsage); + const bar = renderProgressBar(ratio, 20); + const pct = `${(ratio * 100).toFixed(1)}%`; + const barColoured = chalk.hex(severityHex(ratioSeverity(ratio)))(bar); + lines.push(''); + lines.push(accent('Context window')); + lines.push( + ` ${barColoured} ${value(pct.padStart(6, ' '))} ` + + muted( + `(${formatTokenCount(options.contextTokens)} / ${formatTokenCount( + options.maxContextTokens, + )})`, + ), + ); + } + + const managedSection = buildManagedUsageReportLines({ + colors, + managedUsage: options.managedUsage, + managedUsageError: options.managedUsageError, + }); + if (managedSection.length > 0) { + lines.push(''); + lines.push(...managedSection); + } + + return lines; +} + +export class UsagePanelComponent implements Component { + constructor( + private readonly lines: readonly string[], + private readonly borderHex: string, + private readonly title: string = ' Usage ', + ) {} + + invalidate(): void {} + + render(width: number): string[] { + const paint = (s: string): string => chalk.hex(this.borderHex)(s); + const indent = ' '.repeat(LEFT_MARGIN); + + const availableInterior = Math.max( + MIN_INTERIOR_WIDTH, + width - LEFT_MARGIN - 2 - 2 * SIDE_PADDING, + ); + const longestLine = this.lines.reduce((max, line) => Math.max(max, visibleWidth(line)), 0); + const contentWidth = Math.max( + MIN_INTERIOR_WIDTH, + Math.min(availableInterior, longestLine, Math.max(longestLine, this.title.length)), + ); + const horzLen = contentWidth + 2 * SIDE_PADDING; + + const trailingDashLen = Math.max(0, horzLen - this.title.length); + const top = + indent + paint('╭') + paint(this.title) + paint('─'.repeat(trailingDashLen)) + paint('╮'); + const bottom = indent + paint('╰' + '─'.repeat(horzLen) + '╯'); + + const out: string[] = [top]; + for (const line of this.lines) { + const clipped = visibleWidth(line) > contentWidth ? truncateToWidth(line, contentWidth) : line; + const pad = Math.max(0, contentWidth - visibleWidth(clipped)); + out.push(indent + paint('│') + ' ' + clipped + ' '.repeat(pad) + ' ' + paint('│')); + } + out.push(bottom); + return out; + } +} diff --git a/apps/kimi-code/src/tui/components/messages/user-message.ts b/apps/kimi-code/src/tui/components/messages/user-message.ts new file mode 100644 index 000000000..8617598a0 --- /dev/null +++ b/apps/kimi-code/src/tui/components/messages/user-message.ts @@ -0,0 +1,63 @@ +/** + * Renders a user message in the transcript. + */ + +import type { Component } from '@earendil-works/pi-tui'; +import { Spacer, Text, visibleWidth } from '@earendil-works/pi-tui'; +import chalk from 'chalk'; + +import { ImageThumbnail } from '#/tui/components/media/image-thumbnail'; +import { USER_MESSAGE_BULLET } from '#/tui/constant/symbols'; +import type { ColorPalette } from '#/tui/theme/colors'; +import type { ImageAttachment } from '#/tui/utils/image-attachment-store'; + +export class UserMessageComponent implements Component { + private color: string; + private textComponent: Text; + private spacerComponent: Spacer; + private imageThumbnails: ImageThumbnail[]; + + constructor(text: string, colors: ColorPalette, images?: ImageAttachment[]) { + this.color = colors.roleUser; + this.textComponent = new Text(chalk.hex(colors.roleUser).bold(text), 0, 0); + this.spacerComponent = new Spacer(1); + this.imageThumbnails = images?.map((img) => new ImageThumbnail(img, colors)) ?? []; + } + + invalidate(): void { + this.textComponent.invalidate(); + for (const img of this.imageThumbnails) { + img.invalidate?.(); + } + } + + render(width: number): string[] { + const bullet = chalk.hex(this.color).bold(USER_MESSAGE_BULLET); + const bulletWidth = visibleWidth(bullet); + const contentWidth = Math.max(1, width - bulletWidth); + + const lines: string[] = []; + + // Spacer + for (const line of this.spacerComponent.render(width)) { + lines.push(line); + } + + // Text + const textLines = this.textComponent.render(contentWidth); + for (let i = 0; i < textLines.length; i++) { + const prefix = i === 0 ? bullet : ' '.repeat(bulletWidth); + lines.push(prefix + textLines[i]); + } + + // Images — indented to align with text after the bullet + for (const thumbnail of this.imageThumbnails) { + const imageLines = thumbnail.render(contentWidth); + for (const line of imageLines) { + lines.push(' '.repeat(bulletWidth) + line); + } + } + + return lines; + } +} diff --git a/apps/kimi-code/src/tui/components/panes/activity-pane.ts b/apps/kimi-code/src/tui/components/panes/activity-pane.ts new file mode 100644 index 000000000..2a1d8ea1d --- /dev/null +++ b/apps/kimi-code/src/tui/components/panes/activity-pane.ts @@ -0,0 +1,29 @@ +import { Container, Spacer } from '@earendil-works/pi-tui'; + +import type { MoonLoader } from '../chrome/moon-loader'; + +export type ActivityPaneMode = 'hidden' | 'waiting' | 'thinking' | 'composing' | 'tool'; + +export interface ActivityPaneOptions { + readonly mode: ActivityPaneMode; + readonly spinner?: MoonLoader; +} + +export class ActivityPaneComponent extends Container { + constructor(options: ActivityPaneOptions) { + super(); + + if (options.mode === 'waiting' || options.mode === 'tool') { + if (options.spinner !== undefined) { + this.addChild(new Spacer(1)); + this.addChild(options.spinner); + } + return; + } + + if (options.mode === 'composing' && options.spinner !== undefined) { + this.addChild(new Spacer(1)); + this.addChild(options.spinner); + } + } +} diff --git a/apps/kimi-code/src/tui/components/panes/queue-pane.ts b/apps/kimi-code/src/tui/components/panes/queue-pane.ts new file mode 100644 index 000000000..87b0d924f --- /dev/null +++ b/apps/kimi-code/src/tui/components/panes/queue-pane.ts @@ -0,0 +1,36 @@ +import { Container, Text } from '@earendil-works/pi-tui'; +import chalk from 'chalk'; + +import type { QueuedMessage } from '../../types'; +import type { ColorPalette } from '../../theme/colors'; + +export interface QueuePaneOptions { + readonly messages: readonly QueuedMessage[]; + readonly colors: ColorPalette; + readonly isCompacting: boolean; + readonly isStreaming: boolean; + readonly canSteerImmediately: boolean; +} + +export class QueuePaneComponent extends Container { + constructor(options: QueuePaneOptions) { + super(); + + const accent = chalk.hex(options.colors.accent); + const dim = chalk.hex(options.colors.textDim); + + for (const item of options.messages) { + this.addChild(new Text(accent(` ❯ ${item.text}`), 0, 0)); + } + + if (options.messages.length > 0) { + const hint = + options.isCompacting && !options.isStreaming + ? ' ↑ to edit · will send after compaction' + : !options.canSteerImmediately + ? ' ↑ to edit · will send after current task' + : ' ↑ to edit · ctrl-s to steer immediately'; + this.addChild(new Text(dim(hint), 0, 0)); + } + } +} diff --git a/apps/kimi-code/src/tui/config.ts b/apps/kimi-code/src/tui/config.ts new file mode 100644 index 000000000..e09774a40 --- /dev/null +++ b/apps/kimi-code/src/tui/config.ts @@ -0,0 +1,153 @@ +/** + * TUI-owned configuration. + * + * Agent/runtime settings live in core's `config.toml`; this file owns only + * terminal UI preferences for the kimi-code client. + */ + +import { existsSync } from 'node:fs'; +import { mkdir, readFile, writeFile } from 'node:fs/promises'; +import { dirname, join } from 'node:path'; + +import { parse as parseToml } from 'smol-toml'; +import { z } from 'zod'; + +import { getDataDir } from '#/utils/paths'; + +export const INVALID_TUI_CONFIG_MESSAGE = + 'Invalid TUI config in ~/.kimi-code/tui.toml; using defaults.'; + +export const TuiThemeSchema = z.enum(['dark', 'light', 'auto']); + +export const NotificationConditionSchema = z.enum(['unfocused', 'always']); + +export const NotificationsConfigSchema = z.object({ + enabled: z.boolean(), + condition: NotificationConditionSchema, +}); + +export const TuiConfigFileSchema = z.object({ + theme: TuiThemeSchema.optional(), + editor: z + .object({ + command: z.string().optional(), + }) + .optional(), + notifications: z + .object({ + enabled: z.boolean().optional(), + notification_condition: NotificationConditionSchema.optional(), + }) + .optional(), +}); + +export const TuiConfigSchema = z.object({ + theme: TuiThemeSchema, + editorCommand: z.string().nullable(), + notifications: NotificationsConfigSchema, +}); + +export type TuiConfigFileShape = z.infer<typeof TuiConfigFileSchema>; +export type TuiConfig = z.infer<typeof TuiConfigSchema>; +export type NotificationsConfig = z.infer<typeof NotificationsConfigSchema>; + +export const DEFAULT_NOTIFICATIONS_CONFIG: NotificationsConfig = { + enabled: true, + condition: 'unfocused', +}; + +export const DEFAULT_TUI_CONFIG: TuiConfig = TuiConfigSchema.parse({ + theme: 'auto', + editorCommand: null, + notifications: DEFAULT_NOTIFICATIONS_CONFIG, +}); + +/** + * Thrown by `loadTuiConfig` when the on-disk TOML cannot be parsed. + * Carries `fallback` so the caller can recover without re-running the + * I/O, and use `message` (== `INVALID_TUI_CONFIG_MESSAGE`) as a + * user-facing notice. + */ +export class TuiConfigParseError extends Error { + override readonly name = 'TuiConfigParseError'; + readonly fallback: TuiConfig; + constructor(fallback: TuiConfig) { + super(INVALID_TUI_CONFIG_MESSAGE); + this.fallback = fallback; + } +} + +export function getTuiConfigPath(): string { + return join(getDataDir(), 'tui.toml'); +} + +export async function loadTuiConfig(filePath: string = getTuiConfigPath()): Promise<TuiConfig> { + if (!existsSync(filePath)) { + await saveTuiConfig(DEFAULT_TUI_CONFIG, filePath); + return DEFAULT_TUI_CONFIG; + } + + try { + const text = await readFile(filePath, 'utf-8'); + return parseTuiConfig(text); + } catch { + throw new TuiConfigParseError(DEFAULT_TUI_CONFIG); + } +} + +export function parseTuiConfig(tomlText: string): TuiConfig { + if (tomlText.trim().length === 0) { + return DEFAULT_TUI_CONFIG; + } + const raw = parseToml(tomlText) as Record<string, unknown>; + const parsed = TuiConfigFileSchema.parse(raw); + return normalizeTuiConfig(parsed); +} + +export async function saveTuiConfig( + config: TuiConfig, + filePath: string = getTuiConfigPath(), +): Promise<void> { + await mkdir(dirname(filePath), { recursive: true }); + await writeFile(filePath, renderTuiConfig(config), 'utf-8'); +} + +export function normalizeTuiConfig(config: TuiConfigFileShape): TuiConfig { + const command = config.editor?.command?.trim(); + return TuiConfigSchema.parse({ + theme: config.theme ?? DEFAULT_TUI_CONFIG.theme, + editorCommand: command === undefined || command.length === 0 ? null : command, + notifications: { + enabled: config.notifications?.enabled ?? DEFAULT_NOTIFICATIONS_CONFIG.enabled, + condition: + config.notifications?.notification_condition ?? DEFAULT_NOTIFICATIONS_CONFIG.condition, + }, + }); +} + +export function renderTuiConfig(config: TuiConfig): string { + return `# ~/.kimi-code/tui.toml +# Terminal UI preferences for kimi-code. +# Agent/runtime settings stay in ~/.kimi-code/config.toml. + +theme = "${config.theme}" # "auto" | "dark" | "light" + +[editor] +command = "${escapeTomlBasicString(config.editorCommand ?? '')}" # Empty uses $VISUAL / $EDITOR + +[notifications] +enabled = ${String(config.notifications.enabled)} # true | false +notification_condition = "${config.notifications.condition}" # "unfocused" | "always" +`; +} + +function escapeTomlBasicString(value: string): string { + return value + .replaceAll('\\', '\\\\') + .replaceAll('"', '\\"') + .replaceAll('\b', '\\b') + .replaceAll('\t', '\\t') + .replaceAll('\n', '\\n') + .replaceAll('\f', '\\f') + .replaceAll('\r', '\\r'); +} diff --git a/apps/kimi-code/src/tui/constant/feedback.ts b/apps/kimi-code/src/tui/constant/feedback.ts new file mode 100644 index 000000000..37a50a43a --- /dev/null +++ b/apps/kimi-code/src/tui/constant/feedback.ts @@ -0,0 +1,42 @@ +/** + * Constants for the /feedback command — endpoints, telemetry keys, and + * the status messages shown around the feedback submission flow. + * + * Dialog-internal copy (the box title, subtitle, footer) lives next to + * the dialog component itself, since it is part of that component's + * visual contract. + */ + +import { FEEDBACK_VERSION_PREFIX } from '#/constant/app'; + +export { + FEEDBACK_ISSUE_URL, + FEEDBACK_TELEMETRY_EVENT, + FEEDBACK_VERSION_PREFIX, +} from '#/constant/app'; + +export const FEEDBACK_STATUS_SUBMITTING = 'Submitting feedback…'; +export const FEEDBACK_STATUS_SUCCESS = 'Feedback submitted, thank you!'; +export const FEEDBACK_STATUS_CANCELLED = 'Feedback cancelled.'; +export const FEEDBACK_STATUS_NETWORK_ERROR = 'Network error, failed to submit feedback.'; +export const FEEDBACK_STATUS_FALLBACK = 'Opening GitHub Issues as fallback…'; +export const FEEDBACK_STATUS_NOT_SIGNED_IN = + "You're not signed in. Opening GitHub Issues for feedback…"; + +export function feedbackHttpErrorMessage(status: number): string { + return `Failed to submit feedback (HTTP ${String(status)}).`; +} + +export function feedbackSessionLine(sessionId: string): string { + return `Session: ${sessionId}`; +} + +// Hint shown beneath session-level error messages in the TUI to point users +// at the `kimi export` workflow so they can share diagnostics with us. +export function errorReportHintLine(sessionId: string): string { + return `If this persists, run \`kimi export ${sessionId}\` and share the file with us for diagnosis. Please don't share it publicly.`; +} + +export function withFeedbackVersionPrefix(version: string): string { + return `${FEEDBACK_VERSION_PREFIX}${version}`; +} diff --git a/apps/kimi-code/src/tui/constant/kimi-tui.ts b/apps/kimi-code/src/tui/constant/kimi-tui.ts new file mode 100644 index 000000000..03ef814c0 --- /dev/null +++ b/apps/kimi-code/src/tui/constant/kimi-tui.ts @@ -0,0 +1,17 @@ +import { DEFAULT_OAUTH_PROVIDER_NAME } from '#/constant/app'; + +export { DEFAULT_OAUTH_PROVIDER_NAME, OAUTH_LOGIN_REQUIRED_CODE } from '#/constant/app'; + +export const LLM_NOT_SET_MESSAGE = 'LLM not set, send "/login" to login'; +export const NO_ACTIVE_SESSION_MESSAGE = 'No active session. Send /login to login.'; +export const CTRL_D_HINT = 'Press Ctrl+D again to exit'; +export const CTRL_C_HINT = 'Press Ctrl+C again to exit'; +export const MAIN_AGENT_ID = 'main'; +export const OAUTH_LOGIN_REQUIRED_STARTUP_NOTICE = 'OAuth login expired. Send /login to login.'; +export const EXIT_CONFIRM_WINDOW_MS = 1500; + +export function isManagedUsageProvider( + providerKey: string | undefined, +): providerKey is typeof DEFAULT_OAUTH_PROVIDER_NAME { + return providerKey === DEFAULT_OAUTH_PROVIDER_NAME; +} diff --git a/apps/kimi-code/src/tui/constant/rendering.ts b/apps/kimi-code/src/tui/constant/rendering.ts new file mode 100644 index 000000000..c96abf466 --- /dev/null +++ b/apps/kimi-code/src/tui/constant/rendering.ts @@ -0,0 +1,19 @@ +// Continuation indent for transcript rows that use a two-cell leading marker. +export const MESSAGE_INDENT = ' '; + +// Outer left/right padding applied to the transcript, panels, and the +// statusline so the chrome's left edge lines up with the input box's +// interior (the `>` prompt). The editor itself stays at column 0 — its +// vertical borders are the visual anchor everything else aligns against. +export const CHROME_GUTTER = 1; + +// Shared preview caps used by thinking, tool results, and shell snippets. +export const RESULT_PREVIEW_LINES = 3; +export const COMMAND_PREVIEW_LINES = 10; + +// Animation frames are shared by the login/update loaders and live thinking. +export const BRAILLE_SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']; +export const BRAILLE_SPINNER_INTERVAL_MS = 80; + +export const MOON_SPINNER_FRAMES = ['🌑', '🌒', '🌓', '🌔', '🌕', '🌖', '🌗', '🌘']; +export const MOON_SPINNER_INTERVAL_MS = 120; diff --git a/apps/kimi-code/src/tui/constant/streaming.ts b/apps/kimi-code/src/tui/constant/streaming.ts new file mode 100644 index 000000000..e52117249 --- /dev/null +++ b/apps/kimi-code/src/tui/constant/streaming.ts @@ -0,0 +1,4 @@ +// Extracts useful string fields from partially streamed JSON tool args. +// This is intentionally a preview parser, not a full JSON parser. +export const STREAMING_ARGS_FIELD_RE = + /"(path|file_path|command|pattern|query|url|description|title|name)"\s*:\s*"((?:\\.|[^"\\])*)"/g; diff --git a/apps/kimi-code/src/tui/constant/symbols.ts b/apps/kimi-code/src/tui/constant/symbols.ts new file mode 100644 index 000000000..670b94eb0 --- /dev/null +++ b/apps/kimi-code/src/tui/constant/symbols.ts @@ -0,0 +1,7 @@ +// Use U+25CF instead of U+23FA to avoid emoji/fallback rendering in terminals. +export const STATUS_BULLET = '● '; + +// Shared transcript markers. Keep widths stable because message wrapping +// assumes the marker occupies the leading cells. +export const USER_MESSAGE_BULLET = '✨ '; +export const FAILURE_MARK = '✗ '; diff --git a/apps/kimi-code/src/tui/constant/terminal.ts b/apps/kimi-code/src/tui/constant/terminal.ts new file mode 100644 index 000000000..9d68af25f --- /dev/null +++ b/apps/kimi-code/src/tui/constant/terminal.ts @@ -0,0 +1,40 @@ +import { BEL, ESC, ST } from "#/constant/terminal"; + +export { BEL, ESC, ST } from "#/constant/terminal"; + +// Terminal theme reporting uses private CSI sequences: enable reporting, +// query once, then parse dark/light reports from the input stream. +export const QUERY_TERMINAL_THEME = `${ESC}[?996n`; +export const TERMINAL_THEME_DARK = `${ESC}[?997;1n`; +export const TERMINAL_THEME_LIGHT = `${ESC}[?997;2n`; +export const ENABLE_TERMINAL_THEME_REPORTING = `${ESC}[?2031h`; +export const DISABLE_TERMINAL_THEME_REPORTING = `${ESC}[?2031l`; + +// Xterm-style focus reporting. Input listeners consume these bytes so +// they do not leak into the editor. +export const TERMINAL_FOCUS_IN = `${ESC}[I`; +export const TERMINAL_FOCUS_OUT = `${ESC}[O`; +export const ENABLE_TERMINAL_FOCUS_REPORTING = `${ESC}[?1004h`; +export const DISABLE_TERMINAL_FOCUS_REPORTING = `${ESC}[?1004l`; + +// Standard OSC 11 background-color query. The response regex intentionally +// allows a missing leading ESC because terminals can echo replies alongside +// other raw input, but it requires an OSC terminator so fragmented color +// channels are not parsed as complete replies. +export const OSC11_QUERY = `${ESC}]11;?${BEL}`; +const OSC11_RESPONSE_TERMINATOR_PATTERN = `(?:${BEL}|${ESC}\\\\)`; +export const OSC11_RESPONSE = new RegExp( + String.raw`${ESC}?\]11;rgb:([0-9a-f]{1,4})\/([0-9a-f]{1,4})\/([0-9a-f]{1,4})${OSC11_RESPONSE_TERMINATOR_PATTERN}`, + "i", +); +export const OSC11_RESPONSE_PREFIX = `${ESC}]11;rgb:`; +export const OSC11_RESPONSE_PREFIX_NO_ESC = "]11;rgb:"; + +// Keep notification/title payloads bounded so terminal tabs and desktop +// notifications stay readable. +export const MAX_TERMINAL_NOTIFICATION_MESSAGE_LENGTH = 240; +export const MAX_PROCESS_TITLE_LENGTH = 80; + +// OSC 11 probing must be short because unsupported terminals do not reply. +export const TERMINAL_THEME_DETECT_TIMEOUT_MS = 250; +export const TERMINAL_THEME_INPUT_BUFFER_MAX_LENGTH = 512; diff --git a/apps/kimi-code/src/tui/index.ts b/apps/kimi-code/src/tui/index.ts new file mode 100644 index 000000000..e4581aa39 --- /dev/null +++ b/apps/kimi-code/src/tui/index.ts @@ -0,0 +1,3 @@ +export { KimiTUI } from './kimi-tui'; +export type { KimiTUIStartupInput } from './kimi-tui'; +export type { KimiTUIOptions } from './kimi-tui'; diff --git a/apps/kimi-code/src/tui/kimi-tui.ts b/apps/kimi-code/src/tui/kimi-tui.ts new file mode 100644 index 000000000..9dc684f3c --- /dev/null +++ b/apps/kimi-code/src/tui/kimi-tui.ts @@ -0,0 +1,5221 @@ +/** + * KimiTUI owns the terminal UI shell for a Kimi Code session. + * + * It builds the pi-tui layout, tracks view state, wires editor shortcuts and + * slash commands, drives session startup/switching, renders SDK events into the + * transcript and live panes, and bridges approval, question, auth, and config + * flows back to the harness. + */ + +import { writeFileSync } from 'node:fs'; +import { release as osRelease, type as osType } from 'node:os'; +import { join } from 'node:path'; + +import { + Container, + deleteAllKittyImages, + type Component, + type Focusable, + getCapabilities, + ProcessTerminal, + type SlashCommand, + Spacer, + TUI, +} from '@earendil-works/pi-tui'; +import type { MigrationPlan } from '@moonshot-ai/migration-legacy'; +import { + applyOpenPlatformConfig, + capabilitiesForModel, + fetchOpenPlatformModels, + filterModelsByPrefix, + getOpenPlatformById, + isOpenPlatformId, + OpenPlatformApiError, + type DeviceAuthorization, + type ManagedKimiCodeModelInfo, + type ManagedKimiConfigShape, + type OpenPlatformDefinition, +} from '@moonshot-ai/kimi-code-oauth'; +import { log } from '@moonshot-ai/kimi-code-sdk'; +import type { + AgentStatusUpdatedEvent, + ApprovalRequest, + ApprovalResponse, + AssistantDeltaEvent, + BackgroundTaskInfo, + BackgroundTaskStartedEvent, + BackgroundTaskTerminatedEvent, + BackgroundTaskUpdatedEvent, + CompactionCancelledEvent, + CompactionCompletedEvent, + CompactionStartedEvent, + CreateSessionOptions, + ErrorEvent, + Event, + HookResultEvent, + KimiHarness, + ModelAlias, + McpServerInfo, + PermissionMode, + PromptPart, + Session, + SessionMetaUpdatedEvent, + SessionStatus, + SessionUsage, + SkillActivatedEvent, + SubagentCompletedEvent, + SubagentFailedEvent, + SubagentSpawnedEvent, + ThinkingDeltaEvent, + ToolCallDeltaEvent, + ToolCallStartedEvent, + ToolProgressEvent, + ToolResultEvent, + TurnEndedEvent, + TurnStartedEvent, + TurnStepCompletedEvent, + TurnStepInterruptedEvent, + TurnStepStartedEvent, +} from '@moonshot-ai/kimi-code-sdk'; +import chalk from 'chalk'; + +import type { CLIOptions } from '#/cli/options'; +import { MigrationScreenComponent, type MigrationScreenResult } from '#/migration/index'; +import { ClipboardMediaError, readClipboardMedia } from '#/utils/clipboard/clipboard-image'; +import type { GitLsFilesCache } from '#/utils/git/git-ls-files'; +import { createGitLsFilesCache } from '#/utils/git/git-ls-files'; +import { appendInputHistory, loadInputHistory } from '#/utils/history/input-history'; +import { parseImageMeta } from '#/utils/image/image-mime'; +import { getInputHistoryFile } from '#/utils/paths'; +import { editInExternalEditor, resolveEditorCommand } from '#/utils/process/external-editor'; +import { detectFdPath } from '#/utils/process/fd-detect'; + +import { hydrateTranscriptFromReplay, type ReplayHydrationHooks } from './actions/replay-ops'; +import { + BUILTIN_SLASH_COMMANDS, + buildSkillSlashCommands, + parseSlashInput, + resolveSlashCommandInput, + slashBusyMessage, + sortSlashCommands, + type BuiltinSlashCommandName, + type KimiSlashCommand, + type SkillListSession, +} from './commands'; +import { DeviceCodeBoxComponent } from './components/chrome/device-code-box'; +import { FooterComponent } from './components/chrome/footer'; +import { GutterContainer } from './components/chrome/gutter-container'; +import { CHROME_GUTTER } from './constant/rendering'; +import { MoonLoader, type SpinnerStyle } from './components/chrome/moon-loader'; +import { TodoPanelComponent, type TodoItem } from './components/chrome/todo-panel'; +import { WelcomeComponent } from './components/chrome/welcome'; +import { + ApprovalPanelComponent, + type ApprovalPanelResponse, +} from './components/dialogs/approval-panel'; +import { + ApiKeyInputDialogComponent, + type ApiKeyInputResult, +} from './components/dialogs/api-key-input-dialog'; +import { CompactionComponent } from './components/dialogs/compaction'; +import { EditorSelectorComponent } from './components/dialogs/editor-selector'; +import { + FeedbackInputDialogComponent, + type FeedbackInputDialogResult, +} from './components/dialogs/feedback-input-dialog'; +import { HelpPanelComponent } from './components/dialogs/help-panel'; +import { ModelSelectorComponent } from './components/dialogs/model-selector'; +import { PlatformSelectorComponent } from './components/dialogs/platform-selector'; +import { PermissionSelectorComponent } from './components/dialogs/permission-selector'; +import { QuestionDialogComponent } from './components/dialogs/question-dialog'; +import { SessionPickerComponent, type SessionRow } from './components/dialogs/session-picker'; +import { TaskOutputViewer } from './components/dialogs/task-output-viewer'; +import { TasksBrowserApp, type TasksFilter } from './components/dialogs/tasks-browser'; +import { + SettingsSelectorComponent, + type SettingsSelection, +} from './components/dialogs/settings-selector'; +import { ThemeSelectorComponent } from './components/dialogs/theme-selector'; +import { CustomEditor } from './components/editor/custom-editor'; +import { FileMentionProvider } from './components/editor/file-mention-provider'; +import { AgentGroupComponent } from './components/messages/agent-group'; +import { AssistantMessageComponent } from './components/messages/assistant-message'; +import { BackgroundAgentStatusComponent } from './components/messages/background-agent-status'; +import { buildMcpStatusReportLines } from './components/messages/mcp-status-panel'; +import { ReadGroupComponent } from './components/messages/read-group'; +import { SkillActivationComponent } from './components/messages/skill-activation'; +import { + NoticeMessageComponent, + StatusMessageComponent, +} from './components/messages/status-message'; +import { buildStatusReportLines } from './components/messages/status-panel'; +import { ThinkingComponent } from './components/messages/thinking'; +import { ToolCallComponent } from './components/messages/tool-call'; +import { + buildUsageReportLines, + UsagePanelComponent, + type ManagedUsageReport, +} from './components/messages/usage-panel'; +import { UserMessageComponent } from './components/messages/user-message'; +import { ActivityPaneComponent, type ActivityPaneMode } from './components/panes/activity-pane'; +import { QueuePaneComponent } from './components/panes/queue-pane'; +import { saveTuiConfig, type TuiConfig } from './config'; +import { + FEEDBACK_ISSUE_URL, + FEEDBACK_STATUS_CANCELLED, + FEEDBACK_STATUS_FALLBACK, + FEEDBACK_STATUS_NETWORK_ERROR, + FEEDBACK_STATUS_NOT_SIGNED_IN, + FEEDBACK_STATUS_SUBMITTING, + FEEDBACK_STATUS_SUCCESS, + FEEDBACK_TELEMETRY_EVENT, + errorReportHintLine, + feedbackHttpErrorMessage, + feedbackSessionLine, + withFeedbackVersionPrefix, +} from './constant/feedback'; +import { + CTRL_C_HINT, + CTRL_D_HINT, + DEFAULT_OAUTH_PROVIDER_NAME, + EXIT_CONFIRM_WINDOW_MS, + isManagedUsageProvider, + LLM_NOT_SET_MESSAGE, + MAIN_AGENT_ID, + NO_ACTIVE_SESSION_MESSAGE, + OAUTH_LOGIN_REQUIRED_CODE, + OAUTH_LOGIN_REQUIRED_STARTUP_NOTICE, +} from './constant/kimi-tui'; +import { adaptPanelResponse } from './reverse-rpc/approval/adapter'; +import { ApprovalController } from './reverse-rpc/approval/controller'; +import { createApprovalRequestHandler } from './reverse-rpc/approval/handler'; +import { registerReverseRPCHandlers } from './reverse-rpc/index'; +import { QuestionController } from './reverse-rpc/question/controller'; +import { createQuestionAskHandler } from './reverse-rpc/question/handler'; +import type { ApprovalPanelData, QuestionPanelData } from './reverse-rpc/types'; +import { createKimiTUIThemeBundle, type KimiTUIThemeBundle } from './theme/bundle'; +import type { ResolvedTheme } from './theme/colors'; +import { isTheme, type Theme } from './theme/index'; +import { + INITIAL_LIVE_PANE, + type AppState, + type BackgroundAgentMetadata, + type LivePaneState, + type QueuedMessage, + type ToolCallBlockData, + type ToolResultBlockData, + type TranscriptEntry, +} from './types'; +import { formatBackgroundAgentTranscript } from './utils/background-agent-status'; +import { formatBackgroundTaskTranscript } from './utils/background-task-status'; +import { hasDispose, isExpandable, isPlanExpandable } from './utils/component-capabilities'; +import { + argsRecord, + formatErrorMessage, + isTodoItemShape, + parseStreamingArgs, + serializeToolResultOutput, + stringValue, +} from './utils/event-payload'; +import { isAbortError } from './utils/errors'; +import { ImageAttachmentStore, type ImageAttachment } from './utils/image-attachment-store'; +import { extractMediaAttachments } from './utils/image-placeholder'; +import { McpOAuthAuthorizationUrlOpener } from './utils/mcp-oauth'; +import { + formatMcpStartupStatusSummary, + mcpServerStatusKey, + type McpServerStatusSnapshot, + selectMcpStartupStatusRows, +} from './utils/mcp-server-status'; +import { openUrl } from './utils/open-url'; +import { setProcessTitle } from './utils/proctitle'; +import { installTerminalFocusTracking } from './utils/terminal-focus'; +import { notifyTerminalOnce } from './utils/terminal-notification'; +import { createTerminalState, type TerminalState } from './utils/terminal-state'; +import { installTerminalThemeTracking } from './utils/terminal-theme'; +import { nextTranscriptId } from './utils/transcript-id'; + +export interface KimiTUIStartupInput { + readonly cliOptions: CLIOptions; + readonly tuiConfig: TuiConfig; + readonly version: string; + readonly workDir: string; + readonly startupNotice?: string; + readonly resolvedTheme?: ResolvedTheme; + readonly migrationPlan?: MigrationPlan | null; + /** When true, run only the migration screen, then exit (the `kimi migrate` command). */ + readonly migrateOnly?: boolean; +} + +export interface PendingExit { + readonly kind: 'ctrl-c' | 'ctrl-d'; + readonly timer: ReturnType<typeof setTimeout>; +} + +type EffectiveActivityPaneMode = ActivityPaneMode | 'idle' | 'session'; + +export interface TUIStartupOptions { + readonly sessionFlag?: string; + readonly continueLast: boolean; + readonly yolo: boolean; + readonly plan: boolean; + readonly model?: string; + readonly startupNotice?: string; +} + +export type TUIStartupState = 'pending' | 'ready' | 'picker'; + +export interface KimiTUIOptions { + initialAppState: AppState; + startup: TUIStartupOptions; + resolvedTheme?: ResolvedTheme; +} + +export interface TUIState { + ui: TUI; + terminal: ProcessTerminal; + transcriptContainer: Container; + activityContainer: Container; + todoPanelContainer: Container; + todoPanel: TodoPanelComponent; + queueContainer: Container; + editorContainer: Container; + footer: FooterComponent; + editor: CustomEditor; + theme: KimiTUIThemeBundle; + appState: AppState; + startupState: TUIStartupState; + startupNotice: string | undefined; + livePane: LivePaneState; + transcriptEntries: TranscriptEntry[]; + terminalState: TerminalState; + activitySpinner: MoonLoader | undefined; + activitySpinnerStyle: SpinnerStyle | undefined; + activeThinkingComponent: ThinkingComponent | undefined; + streamingComponent: AssistantMessageComponent | undefined; + streamingTranscriptEntry: TranscriptEntry | undefined; + activeCompactionBlock: CompactionComponent | undefined; + toolOutputExpanded: boolean; + planExpanded: boolean; + lastActivityMode: string | undefined; + lastHistoryContent: string | undefined; + pendingToolComponents: Map<string, ToolCallComponent>; + pendingAgentGroup: { + readonly turnId: string | undefined; + readonly step: number; + solo?: ToolCallComponent; + group?: AgentGroupComponent; + } | null; + pendingReadGroup: { + readonly turnId: string | undefined; + readonly step: number; + solo?: ToolCallComponent; + group?: ReadGroupComponent; + } | null; + backgroundAgents: Set<string>; + backgroundAgentMetadata: Map<string, BackgroundAgentMetadata>; + /** + * Authoritative live mirror of the BPM. Keyed by `taskId`. Includes + * both bash and agent tasks, and retains terminal entries until they + * are explicitly forgotten (kept so transcript replay and footer + * lookups stay consistent). + */ + backgroundTasks: Map<string, BackgroundTaskInfo>; + /** + * Task IDs whose terminal transcript card has already been pushed. + * Used to dedupe between the BPM `background.task.terminated` event + * and the older `subagent.completed/failed` flow, both of which + * arrive for `agent-*` tasks. + */ + backgroundTaskTranscriptedTerminal: Set<string>; + renderedSkillActivationIds: Set<string>; + renderedMcpServerStatusKeys: Map<string, string>; + mcpServerStatusSpinners: Map<string, MoonLoader>; + subagentParentToolCallIds: Map<string, string>; + subagentNames: Map<string, string>; + sessions: SessionRow[]; + loadingSessions: boolean; + showingSessionPicker: boolean; + showingHelpPanel: boolean; + /** + * Active `/tasks` full-screen takeover. When non-undefined, the main + * TUI's children have been replaced by `component`; `savedChildren` + * holds the original list so we can restore on exit. + */ + tasksBrowser: + | { + component: TasksBrowserApp; + savedChildren: readonly Component[]; + filter: TasksFilter; + selectedTaskId: string | undefined; + tailOutput: string | undefined; + tailLoading: boolean; + tailRequestId: number; + flashMessage: string | undefined; + flashTimer: NodeJS.Timeout | undefined; + pollTimer: NodeJS.Timeout | undefined; + /** + * Active nested output viewer (TaskOutputViewer). Undefined when + * the browser is showing its normal 3-pane layout. + */ + viewer: + | { + component: TaskOutputViewer; + savedChildren: readonly Component[]; + /** Task whose output the viewer is currently following. */ + taskId: string; + /** Latest output snapshot pushed into the viewer. */ + output: string; + /** Last in-flight refresh — used to ignore late responses. */ + refreshId: number; + /** 1s background poll so live tail still works if events drop. */ + pollTimer: NodeJS.Timeout; + } + | undefined; + } + | undefined; + externalEditorRunning: boolean; + currentTurnId: string | undefined; + currentStep: number; + assistantDraft: string; + assistantStreamActive: boolean; + thinkingDraft: string; + activeToolCalls: Map<string, ToolCallBlockData>; + streamingToolCallArguments: Map< + string, + { name?: string; argumentsText: string; startedAtMs: number } + >; + queuedMessages: QueuedMessage[]; +} + +// Builds the app-state snapshot used before a session is attached. +function createInitialAppState(input: KimiTUIStartupInput): AppState { + const startupPermission: PermissionMode = input.cliOptions.yolo ? 'yolo' : 'manual'; + return { + model: '', + workDir: input.workDir, + sessionId: '', + yolo: input.cliOptions.yolo, + permissionMode: startupPermission, + planMode: input.cliOptions.plan, + thinking: false, + contextUsage: 0, + contextTokens: 0, + maxContextTokens: 0, + isStreaming: false, + isCompacting: false, + isReplaying: false, + streamingPhase: 'idle', + streamingStartTime: 0, + theme: input.tuiConfig.theme, + version: input.version, + editorCommand: input.tuiConfig.editorCommand, + notifications: input.tuiConfig.notifications, + availableModels: {}, + availableProviders: {}, + sessionTitle: null, + }; +} + +// Creates all pi-tui components and mutable runtime state owned by KimiTUI. +export function createTUIState(options: KimiTUIOptions): TUIState { + const initialAppState = options.initialAppState; + const theme = createKimiTUIThemeBundle(initialAppState.theme, options.resolvedTheme); + + const terminal = new ProcessTerminal(); + const ui = new TUI(terminal); + + // Every chrome container runs with a 2-column outer gutter on each + // side. That gives the transcript, panels, the editor and the + // statusline a shared left edge — the input box's `│` lines up with + // panel borders like Welcome's `│`, and bullets / `>` share a column. + const transcriptContainer = new GutterContainer(CHROME_GUTTER, CHROME_GUTTER); + const activityContainer = new GutterContainer(CHROME_GUTTER, CHROME_GUTTER); + const todoPanelContainer = new GutterContainer(CHROME_GUTTER, CHROME_GUTTER); + const todoPanel = new TodoPanelComponent(theme.colors); + const queueContainer = new GutterContainer(CHROME_GUTTER, CHROME_GUTTER); + const editorContainer = new GutterContainer(CHROME_GUTTER, CHROME_GUTTER); + const editor = new CustomEditor(ui, theme.colors); + const footer = new FooterComponent({ ...initialAppState }, theme.colors, () => { + ui.requestRender(); + }); + + return { + ui, + terminal, + transcriptContainer, + activityContainer, + todoPanelContainer, + todoPanel, + queueContainer, + editorContainer, + footer, + editor, + theme, + appState: { ...initialAppState }, + startupState: 'pending', + startupNotice: options.startup.startupNotice, + livePane: { ...INITIAL_LIVE_PANE }, + transcriptEntries: [], + terminalState: createTerminalState(), + activitySpinner: undefined, + activitySpinnerStyle: undefined, + activeThinkingComponent: undefined, + streamingComponent: undefined, + streamingTranscriptEntry: undefined, + activeCompactionBlock: undefined, + toolOutputExpanded: false, + planExpanded: false, + lastActivityMode: undefined, + lastHistoryContent: undefined, + pendingToolComponents: new Map<string, ToolCallComponent>(), + pendingAgentGroup: null, + pendingReadGroup: null, + backgroundAgents: new Set<string>(), + backgroundAgentMetadata: new Map<string, BackgroundAgentMetadata>(), + backgroundTasks: new Map<string, BackgroundTaskInfo>(), + backgroundTaskTranscriptedTerminal: new Set<string>(), + renderedSkillActivationIds: new Set<string>(), + renderedMcpServerStatusKeys: new Map<string, string>(), + mcpServerStatusSpinners: new Map<string, MoonLoader>(), + subagentParentToolCallIds: new Map<string, string>(), + subagentNames: new Map<string, string>(), + sessions: [], + loadingSessions: false, + showingSessionPicker: false, + showingHelpPanel: false, + tasksBrowser: undefined, + externalEditorRunning: false, + currentTurnId: undefined, + currentStep: 0, + assistantDraft: '', + assistantStreamActive: false, + thinkingDraft: '', + activeToolCalls: new Map<string, ToolCallBlockData>(), + streamingToolCallArguments: new Map(), + queuedMessages: [], + }; +} + +// Merges startup notices while preserving their display order. +function combineStartupNotice( + existing: string | undefined, + next: string | undefined, +): string | undefined { + if (existing !== undefined && next !== undefined) { + return `${existing}\n${next}`; + } + return existing ?? next; +} + +function isOAuthLoginRequiredError(error: unknown): boolean { + if (typeof error !== 'object' || error === null) return false; + return (error as { readonly code?: unknown }).code === OAUTH_LOGIN_REQUIRED_CODE; +} + +interface SessionUsageResult { + readonly usage?: SessionUsage; + readonly error?: string; +} + +interface ManagedUsageResult { + readonly usage?: ManagedUsageReport; + readonly error?: string; +} + +interface RuntimeStatusResult { + readonly status?: SessionStatus; + readonly error?: string; +} + +interface SendMessageOptions { + readonly parts?: readonly PromptPart[]; + readonly imageAttachmentIds?: readonly number[]; + readonly hasMedia?: boolean; +} + +interface LoginProgressSpinnerHandle { + /** Stops the login progress row and replaces it with a final status. */ + stop(opts: { ok: boolean; label: string }): void; +} + +export class KimiTUI { + private readonly harness: KimiHarness; + private readonly options: KimiTUIOptions; + private session: Session | undefined; + private state: TUIState; + private readonly approvalController = new ApprovalController(); + private readonly questionController = new QuestionController(); + private readonly reverseRpcDisposers: Array<() => void> = []; + private skillCommands: readonly KimiSlashCommand[] = []; + private readonly skillCommandMap = new Map<string, string>(); + private readonly imageStore = new ImageAttachmentStore(); + private readonly fdPath: string | null = detectFdPath(); + private readonly gitLsFilesCache: GitLsFilesCache; + private sessionEventUnsubscribe: (() => void) | undefined; + private pendingExit: PendingExit | null = null; + private cancelInFlight: (() => void) | undefined; + // Queues editor messages instead of sending or steering them. Used by /init. + private deferUserMessages = false; + private aborted = false; + private terminalFocusTrackingDispose: (() => void) | undefined; + private terminalThemeTrackingDispose: (() => void) | undefined; + // First-launch migration plan detected pre-TUI; null when nothing to migrate. + private readonly migrationPlan: MigrationPlan | null; + // When true, the migration screen is the whole session: run it, then exit. + private readonly migrateOnly: boolean; + + public onExit?: (exitCode?: number) => Promise<void>; + + private track( + event: string, + properties?: Parameters<KimiHarness['track']>[1], + ): void { + this.harness.track(event, properties); + } + + // Initializes state, reverse-RPC handlers, editor callbacks, and layout. + constructor(harness: KimiHarness, startupInput: KimiTUIStartupInput) { + this.harness = harness; + const tuiOptions: KimiTUIOptions = { + initialAppState: createInitialAppState(startupInput), + startup: { + sessionFlag: startupInput.cliOptions.session, + continueLast: startupInput.cliOptions.continue, + yolo: startupInput.cliOptions.yolo, + plan: startupInput.cliOptions.plan, + model: startupInput.cliOptions.model, + startupNotice: startupInput.startupNotice, + }, + resolvedTheme: startupInput.resolvedTheme, + }; + this.options = tuiOptions; + this.migrationPlan = startupInput.migrationPlan ?? null; + this.migrateOnly = startupInput.migrateOnly ?? false; + this.state = createTUIState(tuiOptions); + this.gitLsFilesCache = createGitLsFilesCache(tuiOptions.initialAppState.workDir); + + // Register approval / question UI controllers before SDK handlers. + this.reverseRpcDisposers.push( + ...registerReverseRPCHandlers(this.approvalController, this.questionController, { + showApprovalPanel: (payload) => { + this.showApprovalPanel(payload); + }, + hideApprovalPanel: () => { + this.hideApprovalPanel(); + }, + showQuestionDialog: (payload) => { + this.showQuestionDialog(payload); + }, + hideQuestionDialog: () => { + this.hideQuestionDialog(); + }, + }), + ); + this.setupEditorHandlers(); + this.buildLayout(); + } + + // ========================================================================= + // Startup Helpers + // ========================================================================= + + // Returns built-in and dynamically loaded slash commands in display order. + private getSlashCommands(): readonly KimiSlashCommand[] { + return [...sortSlashCommands(BUILTIN_SLASH_COMMANDS), ...this.skillCommands]; + } + + // Rebuilds editor autocomplete from slash commands and file mentions. + private setupAutocomplete(): void { + const slashCommands: SlashCommand[] = this.getSlashCommands().map((cmd) => ({ + name: cmd.name, + description: cmd.description, + })); + const provider = new FileMentionProvider( + slashCommands, + this.state.appState.workDir, + this.fdPath, + this.gitLsFilesCache, + ); + this.state.editor.setAutocompleteProvider(provider); + } + + // Loads skill-backed slash commands from the active session. + private async refreshSkillCommands(session?: SkillListSession): Promise<void> { + if (session === undefined) { + this.skillCommands = []; + this.skillCommandMap.clear(); + this.setupAutocomplete(); + return; + } + + let skills; + try { + skills = await session.listSkills(); + } catch { + return; + } + const skillCommands = buildSkillSlashCommands(skills); + this.skillCommands = skillCommands.commands; + this.skillCommandMap.clear(); + for (const [commandName, skillName] of skillCommands.commandMap) { + this.skillCommandMap.set(commandName, skillName); + } + this.setupAutocomplete(); + } + + // Restores persisted input history for the current working directory. + private async loadPersistedInputHistory(): Promise<void> { + try { + const file = getInputHistoryFile(this.state.appState.workDir); + const entries = await loadInputHistory(file); + for (const entry of entries) { + this.state.editor.addToHistory(entry.content); + } + this.state.lastHistoryContent = entries.at(-1)?.content; + } catch { + /* history is best-effort */ + } + } + + // ========================================================================= + // Lifecycle + // ========================================================================= + + // Starts the TUI, performs startup routing, and begins session event handling. + async start(): Promise<void> { + // Migration path: the migration screen is a pi-tui component, so the event + // loop must run first. It then renders as the very first thing on screen, + // before the session is created and the Welcome banner is drawn. + if (this.migrationPlan !== null) { + this.startEventLoop(); + try { + const migrationResult = await this.runMigrationScreen(this.migrationPlan); + if (this.migrateOnly) { + // Explicit `kimi migrate`: the screen is the whole command — exit + // instead of continuing into the chat TUI. A migration that ran but + // failed exits non-zero so scripted callers can detect it. + const failed = + migrationResult.decision === 'now' && migrationResult.migrated === false; + // Restore the terminal before `onExit` calls `process.exit`: dispose + // the focus/theme tracking `startEventLoop()` installed, then stop + // the pi-tui loop. Skipping either leaves the terminal in raw mode + // or still emitting focus/OSC sequences after the command finishes. + this.disposeTerminalTracking(); + this.state.ui.stop(); + await this.onExit?.(failed ? 1 : 0); + return; + } + const shouldReplayHistory = await this.initMainTui(); + await this.finishStartup(shouldReplayHistory); + } catch (error) { + // The pi-tui loop is running and startEventLoop() installed focus/ + // theme tracking; a startup failure must tear all of it down before + // the exception propagates, otherwise the terminal is left in raw + // mode or still emitting focus/OSC sequences. + this.disposeTerminalTracking(); + this.state.ui.stop(); + throw error; + } + return; + } + + // No-migration path: ordering is identical to the original `start()`. + const shouldReplayHistory = await this.initMainTui(); + this.startEventLoop(); + try { + await this.finishStartup(shouldReplayHistory); + } catch (error) { + // The pi-tui loop is running and startEventLoop() installed focus/theme + // tracking; tear all of it down so a finishStartup failure does not + // leave the terminal in raw mode or emitting focus/OSC sequences. + this.disposeTerminalTracking(); + this.state.ui.stop(); + throw error; + } + } + + // Creates/resumes the session, renders the Welcome banner, configures + // autocomplete and input history, and mounts the editor. Returns whether + // transcript history should be replayed. + private async initMainTui(): Promise<boolean> { + const shouldReplayHistory = await this.init(); + + this.renderWelcome(); + this.setupAutocomplete(); + void this.loadPersistedInputHistory(); + this.state.editorContainer.clear(); + this.state.editorContainer.addChild(this.state.editor); + this.state.ui.setFocus(this.state.editor); + return shouldReplayHistory; + } + + // Starts the pi-tui event loop and installs terminal focus/theme tracking. + private startEventLoop(): void { + this.state.ui.start(); + this.terminalFocusTrackingDispose = installTerminalFocusTracking(this.state); + this.refreshTerminalThemeTracking(); + } + + // Runs post-init startup tasks: startup notice, picker bootstrap, transcript + // replay, and session event subscriptions. + private async finishStartup(shouldReplayHistory: boolean): Promise<void> { + if (this.state.startupNotice !== undefined) { + this.showStatus(this.state.startupNotice); + this.state.startupNotice = undefined; + } + if (this.state.startupState === 'picker') { + void this.bootstrapFromPicker(); + // resumeSession (fired on picker select) owns post-pick init; nothing + // else to do here until the user makes a choice. + return; + } + if (shouldReplayHistory) { + await hydrateTranscriptFromReplay( + this.state, + this.replayHydrationHooks(), + this.requireSession(), + ); + } + if (this.session !== undefined) { + this.startSessionEventSubscription(); + } + void this.fetchSessions(); + if (this.session !== undefined) { + this.refreshSessionTitle(); + } + void this.refreshSkillCommands(this.session); + } + + // Creates or resumes the startup session and reports whether history should replay. + private async init(): Promise<boolean> { + await this.refreshAvailableModels(); + + const { startup } = this.options; + const { workDir } = this.state.appState; + let session: Session | undefined; + let shouldReplayHistory = false; + const isResumeStartup = startup.sessionFlag !== undefined || startup.continueLast; + const createSessionOptions: CreateSessionOptions = { + workDir, + model: startup.model, + permission: startup.yolo ? 'yolo' : undefined, + planMode: startup.plan ? true : undefined, + }; + + try { + if (isResumeStartup) { + if (startup.sessionFlag === '') { + this.state.startupState = 'picker'; + return false; + } + + if (startup.sessionFlag !== undefined) { + const sessions = await this.harness.listSessions({ workDir }); + const target = sessions.find((candidate) => candidate.id === startup.sessionFlag); + if (target === undefined) { + throw new Error(`Session "${startup.sessionFlag}" not found.`); + } + session = await this.harness.resumeSession({ id: startup.sessionFlag }); + shouldReplayHistory = true; + } else { + const sessions = await this.harness.listSessions({ workDir }); + const target = sessions[0]; + if (target !== undefined) { + session = await this.harness.resumeSession({ id: target.id }); + shouldReplayHistory = true; + } else { + session = await this.harness.createSession(createSessionOptions); + this.state.startupNotice = combineStartupNotice( + this.state.startupNotice, + `No sessions to continue under "${workDir}"; starting a fresh session.`, + ); + } + } + } else { + session = await this.harness.createSession(createSessionOptions); + } + if (session !== undefined && startup.model !== undefined && isResumeStartup) { + await session.setModel(startup.model); + } + } catch (error) { + if (!isOAuthLoginRequiredError(error)) throw error; + this.enterLoginRequiredStartupState(); + return false; + } + + if (session === undefined) { + throw new Error('Startup session was not initialized.'); + } + await this.setSession(session); + await this.syncRuntimeState(session); + this.state.startupState = 'ready'; + return shouldReplayHistory; + } + + // Stops UI resources, active sessions, reverse-RPC handlers, and the harness. + async stop(): Promise<void> { + this.aborted = true; + if (this.pendingExit) { + clearTimeout(this.pendingExit.timer); + this.pendingExit = null; + } + for (const dispose of this.reverseRpcDisposers) { + dispose(); + } + this.reverseRpcDisposers.length = 0; + this.disposeTerminalTracking(); + await this.closeSession('shutting down'); + await this.harness.close(); + this.stopAllMcpServerStatusSpinners(); + this.state.ui.stop(); + if (this.onExit) { + await this.onExit(); + } + } + + // Tears down the terminal focus + theme tracking installed by + // `startEventLoop()`. Every exit path must run this, or the terminal is + // left with focus-reporting / theme-query modes on and emits stray + // focus/OSC sequences after the process exits. + private disposeTerminalTracking(): void { + this.stopTerminalThemeTracking(); + this.terminalFocusTrackingDispose?.(); + this.terminalFocusTrackingDispose = undefined; + } + + // Returns the currently selected session id shown by the UI. + getCurrentSessionId(): string { + return this.state.appState.sessionId; + } + + // Reports whether the transcript contains user-visible session content. + hasSessionContent(): boolean { + return this.state.transcriptEntries.length > 0; + } + + async getStartupMcpMs(): Promise<number> { + const session = this.session; + if (session === undefined) return 0; + try { + const metrics = await session.getMcpStartupMetrics(); + return metrics.durationMs; + } catch { + return 0; + } + } + + // ========================================================================= + // Auth / Model Bootstrap + // ========================================================================= + + // Refreshes model metadata from the harness config. + private async refreshAvailableModels(): Promise<void> { + const config = await this.harness.getConfig({ reload: true }); + this.setAppState({ + availableModels: config.models ?? {}, + availableProviders: config.providers ?? {}, + }); + } + + // Allows the shell to start even when the managed OAuth token needs login. + private enterLoginRequiredStartupState(): void { + this.resetSessionRuntime(); + this.setAppState({ + sessionId: '', + model: '', + thinking: false, + contextTokens: 0, + maxContextTokens: 0, + contextUsage: 0, + sessionTitle: null, + }); + this.state.startupNotice = combineStartupNotice( + this.state.startupNotice, + OAUTH_LOGIN_REQUIRED_STARTUP_NOTICE, + ); + this.state.startupState = 'ready'; + } + + // Ensures a usable session exists for the default model after login. + private async activateModelAfterLogin(model: string, thinking?: boolean): Promise<void> { + const level = thinking === undefined ? undefined : thinking ? 'on' : 'off'; + if (this.session !== undefined) { + await this.session.setModel(model); + if (level !== undefined) { + await this.session.setThinking(level); + } + return; + } + + const session = await this.harness.createSession({ + workDir: this.state.appState.workDir, + model, + thinking: level, + permission: this.options.startup.yolo ? 'yolo' : undefined, + planMode: this.state.appState.planMode ? true : undefined, + }); + await this.setSession(session); + this.setAppState({ + sessionId: session.id, + sessionTitle: session.summary?.title ?? null, + }); + await this.syncRuntimeState(session); + this.startSessionEventSubscription(); + void this.fetchSessions(); + this.refreshSessionTitle(); + void this.refreshSkillCommands(this.session); + } + + // Clears the active session and runtime UI after logout. + private async clearActiveSessionAfterLogout(): Promise<void> { + await this.closeSession('logged out'); + this.resetSessionRuntime(); + this.setAppState({ + sessionId: '', + model: '', + sessionTitle: null, + }); + await this.refreshSkillCommands(); + } + + // Reloads config after login and selects the configured default model. + private async refreshConfigAfterLogin(): Promise<void> { + const config = await this.harness.getConfig({ reload: true }); + const availableModels = config.models ?? {}; + const availableProviders = config.providers ?? {}; + const defaultModel = this.options.startup.model ?? config.defaultModel; + const selected = defaultModel !== undefined ? availableModels[defaultModel] : undefined; + + if (defaultModel === undefined || selected === undefined) { + this.setAppState({ availableModels, availableProviders }); + return; + } + + await this.activateModelAfterLogin(defaultModel, config.defaultThinking); + const appStatePatch: Partial<AppState> = { + availableModels, + availableProviders, + model: defaultModel, + maxContextTokens: selected.maxContextSize, + }; + if (config.defaultThinking !== undefined) { + appStatePatch.thinking = config.defaultThinking; + } + this.setAppState(appStatePatch); + } + + // Reloads config after logout and clears model-dependent state. + private async refreshConfigAfterLogout(): Promise<void> { + const config = await this.harness.getConfig({ reload: true }); + const availableModels = config.models ?? {}; + const availableProviders = config.providers ?? {}; + this.setAppState({ + availableModels, + availableProviders, + model: '', + thinking: false, + maxContextTokens: 0, + contextUsage: 0, + contextTokens: 0, + }); + } + + // ========================================================================= + // Layout / Editor Setup + // ========================================================================= + + // Mounts the root TUI containers in their rendering order. + private buildLayout(): void { + const { ui } = this.state; + ui.clear(); + ui.addChild(this.state.transcriptContainer); + ui.addChild(this.state.activityContainer); + ui.addChild(this.state.todoPanelContainer); + ui.addChild(this.state.queueContainer); + ui.addChild(this.state.editorContainer); + // FooterComponent isn't a Container; wrap it so it picks up the same + // outer gutter as the transcript/panels above. + const footerWrap = new GutterContainer(CHROME_GUTTER, CHROME_GUTTER); + footerWrap.addChild(this.state.footer); + ui.addChild(footerWrap); + } + + // Wires editor shortcuts, submission, paste, and navigation callbacks. + private setupEditorHandlers(): void { + const editor = this.state.editor; + + editor.onSubmit = (text: string) => { + this.handleUserInput(text); + }; + + editor.onChange = (text: string) => { + if (this.pendingExit) this.clearPendingExit(); + this.updateEditorBorderHighlight(text); + }; + + editor.onCtrlC = () => { + if (this.cancelInFlight !== undefined) { + const cancel = this.cancelInFlight; + this.cancelInFlight = undefined; + this.clearPendingExit(); + cancel(); + return; + } + + if (this.state.appState.isStreaming) { + this.clearPendingExit(); + this.cancelCurrentStream(); + return; + } + + if (this.state.appState.isCompacting) { + this.clearPendingExit(); + this.cancelCurrentCompaction(); + return; + } + + if (this.pendingExit?.kind === 'ctrl-c') { + this.clearPendingExit(); + void this.stop(); + return; + } + + if (editor.getText().length > 0) { + editor.setText(''); + } + this.armPendingExit('ctrl-c', CTRL_C_HINT); + }; + + editor.onCtrlD = () => { + if (this.pendingExit?.kind === 'ctrl-d') { + this.clearPendingExit(); + void this.stop(); + return; + } + this.armPendingExit('ctrl-d', CTRL_D_HINT); + }; + + editor.onEscape = () => { + if (this.pendingExit) this.clearPendingExit(); + if (this.state.showingSessionPicker) { + this.hideSessionPicker(); + return; + } + if (this.state.appState.isStreaming) { + this.cancelCurrentStream(); + return; + } + if (this.state.appState.isCompacting) { + this.cancelCurrentCompaction(); + } + }; + + editor.onShiftTab = () => { + const session = this.session; + if (session === undefined) { + this.showError(NO_ACTIVE_SESSION_MESSAGE); + return; + } + const next = !this.state.appState.planMode; + this.track('shortcut_plan_toggle', { enabled: next }); + this.track('shortcut_mode_switch', { to_mode: next ? 'plan' : 'agent' }); + void this.applyPlanMode(session, next); + }; + + editor.onOpenExternalEditor = () => { + this.track('shortcut_editor'); + void this.openExternalEditor(); + }; + + editor.onToggleToolExpand = () => { + this.track('shortcut_expand'); + this.toggleToolOutputExpansion(); + }; + + editor.onTogglePlanExpand = () => this.togglePlanExpansion(); + + editor.onCtrlS = () => { + if (!this.state.appState.isStreaming || this.state.appState.isCompacting) return; + const text = editor.getText().trim(); + const queuedTexts = this.state.queuedMessages.map((m) => m.text); + this.state.queuedMessages = []; + + const parts: string[] = []; + for (const q of queuedTexts) { + const trimmed = q.trim(); + if (trimmed.length > 0) parts.push(trimmed); + } + if (text.length > 0) parts.push(text); + + if (parts.length > 0) { + editor.setText(''); + const session = this.session; + if (this.state.appState.model.trim().length === 0 || session === undefined) { + this.showError(LLM_NOT_SET_MESSAGE); + } else { + this.steerMessage(session, parts); + } + } + this.updateQueueDisplay(); + this.state.ui.requestRender(); + }; + + editor.onUndo = () => { + this.track('undo'); + }; + + editor.onInsertNewline = () => { + this.track('shortcut_newline'); + }; + + editor.onTextPaste = () => { + this.track('shortcut_paste', { kind: 'text' }); + }; + + editor.onUpArrowEmpty = () => { + if (!this.state.appState.isStreaming && !this.state.appState.isCompacting) return false; + const recalled = this.recallLastQueued(); + if (recalled !== undefined) { + editor.setText(recalled); + this.updateQueueDisplay(); + this.state.ui.requestRender(); + return true; + } + return false; + }; + + editor.onPasteImage = async () => this.handleClipboardImagePaste(); + } + + // Cancels the pending double-key exit prompt. + private clearPendingExit(): void { + if (!this.pendingExit) return; + clearTimeout(this.pendingExit.timer); + this.state.footer.setTransientHint(null); + this.pendingExit = null; + } + + // Starts a timed confirmation window for Ctrl-C or Ctrl-D exit. + private armPendingExit(kind: 'ctrl-c' | 'ctrl-d', hint: string): void { + this.clearPendingExit(); + this.state.footer.setTransientHint(hint); + + const timer = setTimeout(() => { + if (this.pendingExit?.timer === timer) { + this.clearPendingExit(); + this.state.ui.requestRender(); + } + }, EXIT_CONFIRM_WINDOW_MS); + + this.pendingExit = { kind, timer }; + this.state.ui.requestRender(); + } + + // Reads image or video data from the clipboard and inserts an attachment placeholder. + private async handleClipboardImagePaste(): Promise<boolean> { + let media; + try { + media = await readClipboardMedia(); + } catch (error) { + if (error instanceof ClipboardMediaError) { + this.showError(error.message); + return true; + } + return false; + } + if (media === null) return false; + + if (media.kind === 'video') { + const attachment = this.imageStore.addVideo(media.mimeType, media.sourcePath, media.filename); + this.state.editor.insertTextAtCursor?.(`${attachment.placeholder} `); + this.state.ui.requestRender(); + this.track('shortcut_paste', { kind: 'video' }); + return true; + } + + const meta = parseImageMeta(media.bytes); + if (meta === null) return false; + const attachment = this.imageStore.addImage(media.bytes, meta.mime, meta.width, meta.height); + this.state.editor.insertTextAtCursor?.(`${attachment.placeholder} `); + this.state.ui.requestRender(); + this.track('shortcut_paste', { kind: 'image' }); + return true; + } + + // Opens the configured external editor and writes the edited text back. + private async openExternalEditor(): Promise<void> { + if (this.state.externalEditorRunning) return; + const cmd = resolveEditorCommand(this.state.appState.editorCommand); + if (cmd === undefined) { + this.showError('No editor configured. Set $VISUAL / $EDITOR, or run /editor <command>.'); + return; + } + this.state.externalEditorRunning = true; + const seed = this.state.editor.getExpandedText?.() ?? this.state.editor.getText(); + this.state.ui.stop(); + await new Promise<void>((resolve) => { + setImmediate(resolve); + }); + try { + const result = await editInExternalEditor(seed, cmd); + if (result !== undefined) { + this.state.editor.setText(result.replaceAll('\r\n', '\n').replace(/\n$/, '')); + } + } catch (error) { + const msg = formatErrorMessage(error); + this.showError(`External editor failed: ${msg}`); + } finally { + if (typeof process.stdin.pause === 'function') { + process.stdin.pause(); + } + this.state.ui.start(); + this.state.ui.setFocus(this.state.editor); + this.state.ui.requestRender(true); + this.state.externalEditorRunning = false; + } + } + + // ========================================================================= + // Input Dispatch + // ========================================================================= + + // Routes submitted editor text to slash command handling or normal prompting. + private handleUserInput(text: string): void { + if (text.trim().length === 0) return; + if (this.state.appState.isReplaying) { + this.showError('Cannot send input while session history is replaying.'); + return; + } + void this.persistInputHistory(text); + if (parseSlashInput(text) !== null) { + void this.executeSlashCommand(text); + return; + } + + this.sendNormalUserInput(text); + } + + // Parses and executes a slash command intent. + private async executeSlashCommand(input: string): Promise<void> { + const parsedCommand = parseSlashInput(input); + const intent = resolveSlashCommandInput({ + input, + skillCommandMap: this.skillCommandMap, + isStreaming: this.state.appState.isStreaming, + isCompacting: this.state.appState.isCompacting, + }); + + switch (intent.kind) { + case 'not-command': + return; + case 'blocked': + this.track('input_command_invalid', { reason: 'blocked', command: intent.commandName }); + this.showError(slashBusyMessage(intent.commandName, intent.reason)); + return; + case 'skill': { + const session = this.session; + if (this.state.appState.model.trim().length === 0 || session === undefined) { + this.showError(LLM_NOT_SET_MESSAGE); + return; + } + this.track('input_command', { + command: intent.commandName, + skill_name: intent.skillName, + }); + this.sendSkillActivation(session, intent.skillName, intent.args); + return; + } + case 'message': { + this.sendNormalUserInput(intent.input); + return; + } + case 'builtin': + this.track('input_command', { command: intent.name }); + if (intent.name === 'new' && parsedCommand?.name === 'clear') { + this.track('clear'); + } + try { + await this.handleBuiltInSlashCommand(intent.name, intent.args); + } catch (error) { + this.showError(formatErrorMessage(error)); + } + return; + } + } + + // Dispatches a built-in slash command to its concrete handler. + private async handleBuiltInSlashCommand( + name: BuiltinSlashCommandName, + args: string, + ): Promise<void> { + switch (name) { + case 'exit': + void this.stop(); + return; + case 'help': + this.showHelpPanel(); + return; + case 'version': + this.showStatus(`Kimi Code v${this.state.appState.version}`); + return; + case 'new': + await this.createNewSession(); + this.state.ui.requestRender(); + return; + case 'sessions': + void this.showSessionPicker(); + return; + case 'tasks': + void this.showTasksBrowser(); + return; + case 'mcp': + void this.showMcpServers(); + return; + case 'editor': + await this.handleEditorCommand(args, {}); + return; + case 'theme': + await this.handleThemeCommand(args); + return; + case 'model': + this.handleModelCommand(args); + return; + case 'permission': + this.showPermissionPicker(); + return; + case 'settings': + this.showSettingsSelector(); + return; + case 'usage': + void this.showUsage(); + return; + case 'status': + void this.showStatusReport(); + return; + case 'feedback': + await this.handleFeedbackCommand(); + return; + case 'title': + await this.handleTitleCommand(args); + return; + case 'yolo': + await this.handleYoloCommand(args); + return; + case 'plan': + await this.handlePlanCommand(args); + return; + case 'compact': + await this.handleCompactCommand(args); + return; + case 'init': + await this.handleInitCommand(); + return; + case 'fork': + await this.handleForkCommand(args); + return; + case 'login': + await this.handleLoginCommand(); + return; + case 'logout': + await this.handleLogoutCommand(); + return; + default: + this.showError(`Unknown slash command: /${String(name)}`); + return; + } + } + + // Sends regular user input after validating model and media support. + private sendNormalUserInput(text: string): void { + if (this.state.appState.model.trim().length === 0) { + this.showError(LLM_NOT_SET_MESSAGE); + return; + } + const extraction = extractMediaAttachments(text, this.imageStore); + if (!this.validateMediaCapabilities(extraction)) return; + const session = this.session; + if (session === undefined) { + this.showError(LLM_NOT_SET_MESSAGE); + return; + } + if (extraction.hasMedia) { + this.sendMessage(session, text, { + hasMedia: true, + parts: extraction.parts, + imageAttachmentIds: extraction.imageAttachmentIds, + }); + } else { + this.sendMessage(session, text); + } + this.updateQueueDisplay(); + this.state.ui.requestRender(); + } + + // Checks whether the current model can accept attached media. + private validateMediaCapabilities( + extraction: ReturnType<typeof extractMediaAttachments>, + ): boolean { + if (!extraction.hasMedia) return true; + if ( + extraction.imageAttachmentIds.length > 0 && + !this.supportsCurrentModelCapability('image_in') + ) { + this.showError('Current model does not support image input.'); + return false; + } + if ( + extraction.videoAttachmentIds.length > 0 && + !this.supportsCurrentModelCapability('video_in') + ) { + this.showError('Current model does not support video input.'); + return false; + } + return true; + } + + // Tests the active model's advertised capability list. + private supportsCurrentModelCapability(capability: string): boolean { + const capabilities = + this.state.appState.availableModels[this.state.appState.model]?.capabilities; + if (capabilities === undefined) return true; + return capabilities.includes(capability); + } + + // Persists a submitted input line and mirrors it into editor history. + private async persistInputHistory(text: string): Promise<void> { + const trimmed = text.trim(); + if (trimmed.length === 0) return; + if (trimmed === this.state.lastHistoryContent) return; + this.state.editor.addToHistory(trimmed); + try { + const file = getInputHistoryFile(this.state.appState.workDir); + const written = await appendInputHistory(file, trimmed, this.state.lastHistoryContent); + if (written) this.state.lastHistoryContent = trimmed; + } catch { + this.state.lastHistoryContent = trimmed; + } + } + + // Pops the most recent queued message back into the editor. + private recallLastQueued(): string | undefined { + if (this.state.queuedMessages.length === 0) return undefined; + const last = this.state.queuedMessages.at(-1)!; + this.state.queuedMessages = this.state.queuedMessages.slice(0, -1); + return last.text; + } + + // ========================================================================= + // Session Requests / Queues + // ========================================================================= + + // Adds a message to the queue for delivery after current work finishes. + private enqueueMessage(text: string, options?: SendMessageOptions): void { + this.state.queuedMessages.push({ + text, + agentId: this.harness.interactiveAgentId, + parts: options?.parts, + imageAttachmentIds: + options?.imageAttachmentIds !== undefined && options.imageAttachmentIds.length > 0 + ? options.imageAttachmentIds + : undefined, + }); + this.track('input_queue'); + } + + // Resets request-scoped state before submitting work to the active session. + private beginSessionRequest(): void { + this.state.currentTurnId = undefined; + this.resetLiveTextRuntime(); + this.resetLiveToolUiState(); + this.resetToolCallState(); + + this.patchLivePane({ + mode: 'waiting', + pendingApproval: null, + pendingQuestion: null, + }); + this.setAppState({ + isStreaming: true, + streamingPhase: 'waiting', + streamingStartTime: Date.now(), + }); + } + + // Ends a failed session request and renders the failure to the transcript. + private failSessionRequest(message: string): void { + this.setAppState({ isStreaming: false, streamingPhase: 'idle' }); + this.resetLivePane(); + this.showError(message); + } + + // Sends a queued message after restoring the agent target captured at enqueue time. + private sendQueuedMessage(session: Session, item: QueuedMessage): void { + this.harness.interactiveAgentId = item.agentId ?? MAIN_AGENT_ID; + this.sendMessageInternal(session, item.text, { + parts: item.parts, + imageAttachmentIds: item.imageAttachmentIds, + }); + } + + // Appends the user message and sends the prompt to the session immediately. + private sendMessageInternal(session: Session, input: string, options?: SendMessageOptions): void { + const imageAttachmentIds = + options?.imageAttachmentIds !== undefined && options.imageAttachmentIds.length > 0 + ? options.imageAttachmentIds + : undefined; + this.appendTranscriptEntry({ + id: nextTranscriptId(), + kind: 'user', + turnId: undefined, + renderMode: 'plain', + content: input, + imageAttachmentIds, + }); + + this.beginSessionRequest(); + + const sdkInput = options?.parts ?? input; + void session.prompt(sdkInput).catch((error: unknown) => { + const message = formatErrorMessage(error); + this.failSessionRequest(`Failed to send: ${message}`); + }); + } + + // Starts a skill activation turn on the session. + private sendSkillActivation(session: Session, skillName: string, skillArgs: string): void { + this.beginSessionRequest(); + void session.activateSkill(skillName, skillArgs).catch((error: unknown) => { + const message = formatErrorMessage(error); + this.failSessionRequest(`Skill "${skillName}" failed: ${message}`); + }); + } + + // Sends a message now or queues it when the session is busy. + private sendMessage(session: Session, input: string, options?: SendMessageOptions): void { + if ( + this.deferUserMessages || + this.state.appState.isStreaming || + this.state.appState.isCompacting + ) { + this.enqueueMessage(input, options); + return; + } + this.sendMessageInternal(session, input, options); + } + + // Sends steering input into an active stream or falls back to normal prompts. + private steerMessage(session: Session, input: string[]): void { + if (this.deferUserMessages || this.state.appState.isCompacting) { + for (const part of input) { + this.enqueueMessage(part); + } + return; + } + if (!this.state.appState.isStreaming) { + for (const part of input) { + this.sendMessageInternal(session, part); + } + return; + } + + for (const part of input) { + this.appendTranscriptEntry({ + id: nextTranscriptId(), + kind: 'user', + turnId: this.state.currentTurnId, + renderMode: 'plain', + content: part, + }); + } + + void session.steer(input.join('\n\n')).catch((error: unknown) => { + const message = formatErrorMessage(error); + this.showError(`Failed to steer: ${message}`); + }); + } + + // Requests cancellation of the active session stream. + private cancelCurrentStream(): void { + const session = this.session; + if (session === undefined) return; + void session.cancel(); + } + + private cancelCurrentCompaction(): void { + const session = this.session; + if (session === undefined) return; + void session.cancelCompaction().catch((error: unknown) => { + const message = formatErrorMessage(error); + this.showError(`Failed to cancel compaction: ${message}`); + }); + } + + // Finalizes live thinking output and moves the live pane to the next mode. + private flushThinkingToTranscript(nextMode: LivePaneState['mode'] = 'idle'): void { + if (this.state.thinkingDraft.length === 0) { + this.patchLivePane({ mode: nextMode }); + return; + } + this.state.thinkingDraft = ''; + this.onThinkingEnd(); + this.patchLivePane({ mode: nextMode }); + } + + // Finalizes live assistant text and clears streaming component state. + private finalizeAssistantStream(): void { + if (this.state.assistantStreamActive) { + this.onStreamingTextEnd(); + this.state.assistantStreamActive = false; + } + this.state.assistantDraft = ''; + this.updateActivityPane(); + this.state.ui.requestRender(); + } + + // Discards live thinking and assistant text state without finalizing transcript output. + private resetLiveTextRuntime(): void { + this.state.assistantDraft = ''; + this.state.assistantStreamActive = false; + this.state.streamingComponent = undefined; + this.state.streamingTranscriptEntry = undefined; + this.state.thinkingDraft = ''; + this.disposeActiveThinkingComponent(); + } + + // Clears live tool UI state while preserving active tool-call tracking. + private resetLiveToolUiState(): void { + this.state.streamingToolCallArguments.clear(); + this.disposeAndClearPendingToolComponents(); + this.state.pendingAgentGroup = null; + this.state.pendingReadGroup = null; + } + + // Clears SDK tool-call tracking. + private resetToolCallState(): void { + this.state.activeToolCalls.clear(); + } + + // Finalizes any live thinking and assistant text for a phase transition. + private finalizeLiveTextBuffers(nextMode: LivePaneState['mode'] = 'idle'): void { + this.flushThinkingToTranscript(nextMode); + this.finalizeAssistantStream(); + } + + // Completes a turn, dispatches queued work, and sends completion notification. + private finalizeTurn(sendQueued: (item: QueuedMessage) => void): void { + if (!this.state.appState.isStreaming) return; + this.deferUserMessages = false; + const completedTurnKey = + this.state.currentTurnId ?? `local:${String(this.state.appState.streamingStartTime)}`; + this.finalizeLiveTextBuffers('idle'); + this.resetToolCallState(); + this.state.currentTurnId = undefined; + + if (this.state.queuedMessages.length > 0) { + const [next, ...rest] = this.state.queuedMessages; + this.state.queuedMessages = rest; + this.setAppState({ isStreaming: false, streamingPhase: 'idle' }); + this.resetLivePane(); + if (next !== undefined) { + setTimeout(() => { + sendQueued(next); + }, 0); + } + return; + } + + this.setAppState({ isStreaming: false, streamingPhase: 'idle' }); + this.resetLivePane(); + notifyTerminalOnce(this.state, `turn-complete:${completedTurnKey}`, { + title: 'Kimi Code task complete', + body: this.state.appState.sessionTitle ?? undefined, + }); + } + + // ========================================================================= + // State Helpers + // ========================================================================= + + // Applies app-state changes and refreshes dependent UI surfaces. + private setAppState(patch: Partial<AppState>): void { + const busyChanged = 'isStreaming' in patch || 'isCompacting' in patch; + Object.assign(this.state.appState, patch); + if ('planMode' in patch) this.updateEditorBorderHighlight(); + this.state.footer.setState(this.state.appState); + this.updateActivityPane(); + if (busyChanged) this.updateQueueDisplay(); + this.state.ui.requestRender(); + } + + // Applies live-pane changes and refreshes activity presentation. + private patchLivePane(patch: Partial<LivePaneState>): void { + Object.assign(this.state.livePane, patch); + this.updateActivityPane(); + this.state.ui.requestRender(); + } + + // Restores the live pane to its initial idle state. + private resetLivePane(): void { + this.state.livePane = { ...INITIAL_LIVE_PANE }; + this.updateActivityPane(); + this.state.ui.requestRender(); + } + + // ========================================================================= + // Session Runtime + // ========================================================================= + + // Returns the active session or raises the standard no-session error. + private requireSession(): Session { + if (this.session === undefined) { + throw new Error(NO_ACTIVE_SESSION_MESSAGE); + } + return this.session; + } + + // Creates a session using the current model, known session runtime, permission, and plan state. + private async createSessionFromCurrentState(): Promise<Session> { + const model = this.state.appState.model.trim(); + if (model.length === 0) { + throw new Error(LLM_NOT_SET_MESSAGE); + } + return this.harness.createSession({ + workDir: this.state.appState.workDir, + model, + thinking: + this.session === undefined ? undefined : this.state.appState.thinking ? 'on' : 'off', + permission: this.state.appState.permissionMode, + planMode: this.state.appState.planMode ? true : undefined, + }); + } + + // Replaces the active session and installs approval/question handlers. + private async setSession(session: Session): Promise<void> { + const previous = this.unloadCurrentSession('switching session'); + await previous?.close(); + this.session = session; + this.harness.setTelemetryContext({ sessionId: session.id }); + this.registerSessionHandlers(session); + } + + // Pulls runtime session status into the app state. + private async syncRuntimeState(session: Session = this.requireSession()): Promise<void> { + const status = await session.getStatus(); + this.setAppState({ + sessionId: session.id, + model: status.model ?? '', + thinking: status.thinkingLevel !== 'off', + permissionMode: status.permission, + yolo: status.permission === 'yolo', + planMode: status.planMode, + contextTokens: status.contextTokens, + maxContextTokens: status.maxContextTokens, + contextUsage: status.contextUsage, + sessionTitle: session.summary?.title ?? null, + }); + } + + // Applies current permission and plan settings to the active session. + private async activateRuntime(): Promise<void> { + const session = this.requireSession(); + await session.setPermission(this.state.appState.permissionMode); + if (this.state.appState.planMode) { + await session.setPlanMode(true); + } + await this.syncRuntimeState(session); + } + + // Detaches and closes the current session. + private async closeSession(reason: string): Promise<void> { + const previous = this.unloadCurrentSession(reason); + await previous?.close(); + } + + // Detaches session subscriptions and cancels pending interactive requests. + private unloadCurrentSession(reason: string): Session | undefined { + const previous = this.session; + this.sessionEventUnsubscribe?.(); + this.sessionEventUnsubscribe = undefined; + this.clearReverseRpcPanels(); + previous?.setApprovalHandler(undefined); + previous?.setQuestionHandler(undefined); + this.approvalController.cancelAll(reason); + this.questionController.cancelAll(reason); + this.session = undefined; + this.harness.setTelemetryContext({ sessionId: null }); + return previous; + } + + private clearReverseRpcPanels(): void { + for (const dispose of this.reverseRpcDisposers) { + dispose(); + } + } + + // Connects session approval and question requests to local controllers. + private registerSessionHandlers(session: Session): void { + session.setApprovalHandler( + createApprovalRequestHandler(this.approvalController, (request, response) => { + this.appendApprovalTranscriptEntry(request, response); + }), + ); + session.setQuestionHandler(createQuestionAskHandler(this.questionController)); + } + + // Loads session picker rows for the current working directory. + private async fetchSessions(): Promise<void> { + this.state.loadingSessions = true; + try { + const sessions = await this.harness.listSessions({ workDir: this.state.appState.workDir }); + this.state.sessions = sessions.map((session) => ({ + id: session.id, + title: session.title ?? null, + last_prompt: session.lastPrompt ?? null, + work_dir: session.workDir, + updated_at: session.updatedAt ?? session.createdAt ?? 0, + metadata: session.metadata, + })); + } catch { + /* silently ignore */ + } finally { + this.state.loadingSessions = false; + } + } + + // Syncs the process title with the current session title and id. + private refreshSessionTitle(): void { + setProcessTitle(this.state.appState.sessionTitle, this.state.appState.sessionId); + } + + // Resets turn, tool, queue, and background-agent state for a session switch. + private resetSessionRuntime(): void { + this.aborted = false; + this.state.queuedMessages = []; + this.harness.interactiveAgentId = MAIN_AGENT_ID; + this.resetToolCallState(); + this.resetLiveToolUiState(); + this.state.backgroundAgents.clear(); + this.state.backgroundAgentMetadata.clear(); + this.state.backgroundTasks.clear(); + this.state.backgroundTaskTranscriptedTerminal.clear(); + this.closeTasksBrowser(); + this.state.subagentParentToolCallIds.clear(); + this.state.subagentNames.clear(); + this.state.renderedSkillActivationIds.clear(); + this.state.renderedMcpServerStatusKeys.clear(); + this.stopAllMcpServerStatusSpinners(); + this.state.footer.setBackgroundCounts({ bashTasks: 0, agentTasks: 0 }); + this.setTodoList([]); + this.state.currentTurnId = undefined; + this.state.currentStep = 0; + this.resetLiveTextRuntime(); + this.updateQueueDisplay(); + } + + // Switches to an existing session and replays its transcript. + private async resumeSession(targetSessionId: string): Promise<boolean> { + if (targetSessionId === this.state.appState.sessionId) { + this.showStatus('Already on this session.'); + return true; + } + if (this.state.appState.isStreaming) { + this.showError('Cannot switch sessions while streaming — press Esc or Ctrl-C first.'); + return false; + } + if (this.state.appState.isReplaying) { + this.showError('Cannot switch sessions while history is replaying.'); + return false; + } + + let session: Session; + try { + session = await this.harness.resumeSession({ id: targetSessionId }); + } catch (error) { + const msg = formatErrorMessage(error); + this.showError(`Failed to resume session ${targetSessionId}: ${msg}`); + return false; + } + + await this.switchToSession(session, `Resumed session (${session.id}).`); + return true; + } + + // Switches to a provided session and replays its transcript. + private async switchToSession(session: Session, statusMessage: string): Promise<void> { + this.resetSessionRuntime(); + await this.setSession(session); + await this.syncRuntimeState(session); + this.refreshSessionTitle(); + try { + await this.refreshSkillCommands(this.session); + } catch { + /* keep the switched session usable even if dynamic skills fail */ + } + this.clearTranscriptAndRedraw(); + try { + await hydrateTranscriptFromReplay(this.state, this.replayHydrationHooks(), session); + } catch (error) { + const msg = formatErrorMessage(error); + this.showError(`Failed to replay session history: ${msg}`); + } finally { + this.startSessionEventSubscription(); + } + this.showStatus(statusMessage); + } + + // Creates a fresh session from current UI settings and resets the transcript. + private async createNewSession(): Promise<void> { + if (this.state.appState.isReplaying) { + this.showError('Cannot start a new session while history is replaying.'); + return; + } + + let session: Session; + try { + session = await this.createSessionFromCurrentState(); + } catch (error) { + const msg = formatErrorMessage(error); + this.showError(`Failed to start a new session: ${msg}`); + return; + } + + this.resetSessionRuntime(); + await this.setSession(session); + this.setAppState({ sessionId: session.id }); + try { + await this.activateRuntime(); + await this.syncRuntimeState(session); + } catch (error) { + const msg = formatErrorMessage(error); + this.showError(`Post-create setup failed: ${msg}`); + return; + } + try { + await this.refreshSkillCommands(this.session); + } catch { + /* keep the new session usable even if dynamic skills fail */ + } + this.startSessionEventSubscription(); + this.clearTranscriptAndRedraw(); + this.showStatus(`Started a new session (${session.id}).`); + } + + // ========================================================================= + // Session Events + // ========================================================================= + + private startSessionEventSubscription(): void { + const session = this.requireSession(); + const sendQueued = (item: QueuedMessage): void => { + this.sendQueuedMessage(session, item); + }; + this.sessionEventUnsubscribe?.(); + const mcpOAuthOpener = new McpOAuthAuthorizationUrlOpener(openUrl); + const { sessionId } = this.state.appState; + this.sessionEventUnsubscribe = session.onEvent((event) => { + if (this.aborted) return; + if (event.sessionId !== sessionId) return; + if (event.type === 'tool.progress') { + mcpOAuthOpener.handleToolProgress(event); + } + this.handleEvent(event, sendQueued); + }); + void this.syncMcpServerStatusSnapshot(session); + } + + private async syncMcpServerStatusSnapshot(session: Session): Promise<void> { + let servers: readonly McpServerStatusSnapshot[]; + try { + servers = await session.listMcpServers(); + } catch (error) { + if (this.session !== session || this.aborted) return; + const message = error instanceof Error ? error.message : String(error); + this.showError(`Failed to sync MCP server status: ${message}`); + return; + } + if (this.session !== session || this.state.appState.sessionId !== session.id) return; + + const visible = selectMcpStartupStatusRows(servers); + const visibleNames = new Set(visible.map((server) => server.name)); + for (const server of visible) { + if (this.state.renderedMcpServerStatusKeys.has(server.name)) continue; + this.renderMcpServerStatus(server); + } + + const hidden: McpServerStatusSnapshot[] = []; + for (const server of servers) { + if (visibleNames.has(server.name)) continue; + if (this.state.renderedMcpServerStatusKeys.has(server.name)) continue; + this.state.renderedMcpServerStatusKeys.set(server.name, mcpServerStatusKey(server)); + hidden.push(server); + } + if (hidden.length > 0) { + this.showStatus( + formatMcpStartupStatusSummary(hidden, visible.length), + this.state.theme.colors.textMuted, + ); + } + } + + // Routes an SDK event to the matching TUI state transition. + private handleEvent(event: Event, sendQueued: (item: QueuedMessage) => void): void { + if (this.routeSubagentEvent(event)) { + return; + } + + if ('turnId' in event && event.turnId !== undefined) { + this.state.currentTurnId = String(event.turnId); + } + + switch (event.type) { + case 'turn.started': + this.handleTurnBegin(event); + break; + case 'turn.ended': + this.handleTurnEnd(event, sendQueued); + break; + case 'turn.step.started': + this.handleStepBegin(event); + break; + case 'turn.step.interrupted': + this.handleStepInterrupted(event); + break; + case 'turn.step.completed': + this.handleStepCompleted(event); + break; + case 'turn.step.retrying': + break; + case 'tool.progress': + this.handleToolProgress(event); + break; + case 'assistant.delta': + this.handleAssistantDelta(event); + break; + case 'hook.result': + this.handleHookResult(event); + break; + case 'thinking.delta': + this.handleThinkingDelta(event); + break; + case 'tool.call.started': + this.handleToolCall(event); + break; + case 'tool.call.delta': + this.handleToolCallDelta(event); + break; + case 'tool.result': + this.handleToolResult(event); + break; + case 'agent.status.updated': + this.handleStatusUpdate(event); + break; + case 'session.meta.updated': + this.handleSessionMetaChanged(event); + break; + case 'skill.activated': + this.handleSkillActivated(event); + break; + case 'error': + this.handleSessionError(event); + break; + case 'compaction.started': + this.handleCompactionBegin(event); + break; + case 'compaction.completed': + this.handleCompactionEnd(event, sendQueued); + break; + case 'compaction.blocked': + break; + case 'compaction.cancelled': + this.handleCompactionCancel(event, sendQueued); + break; + case 'subagent.spawned': + this.handleSubagentSpawned(event); + break; + case 'subagent.completed': + this.handleSubagentCompleted(event); + break; + case 'subagent.failed': + this.handleSubagentFailed(event); + break; + case 'background.task.started': + case 'background.task.updated': + case 'background.task.terminated': + this.handleBackgroundTaskEvent(event); + break; + case 'mcp.server.status': + this.renderMcpServerStatus(event.server); + break; + case 'tool.list.updated': + break; + default: + break; + } + } + + // Routes child-agent events into their parent tool-call component. + private routeSubagentEvent(event: Event): boolean { + const subagentId = event.agentId; + if (subagentId === MAIN_AGENT_ID) return false; + + const parentToolCallId = this.state.subagentParentToolCallIds.get(subagentId); + if (parentToolCallId === undefined || parentToolCallId.length === 0) return true; + const sourceName = this.state.subagentNames.get(subagentId); + const toolCall = this.state.pendingToolComponents.get(parentToolCallId); + if (toolCall === undefined) return true; + toolCall.setSubagentMeta(subagentId, sourceName); + + switch (event.type) { + case 'hook.result': + toolCall.appendSubagentText(formatHookResultPlain(event), 'text'); + return true; + case 'assistant.delta': + toolCall.appendSubagentText(event.delta, 'text'); + return true; + case 'thinking.delta': + toolCall.appendSubagentText(event.delta, 'thinking'); + return true; + case 'tool.call.started': + toolCall.appendSubToolCall({ + id: `${subagentId}:${event.toolCallId}`, + name: event.name, + args: argsRecord(event.args), + }); + return true; + case 'tool.call.delta': + toolCall.appendSubToolCallDelta({ + id: `${subagentId}:${event.toolCallId}`, + name: event.name, + argumentsPart: event.argumentsPart ?? null, + }); + return true; + case 'tool.result': + toolCall.finishSubToolCall({ + tool_call_id: `${subagentId}:${event.toolCallId}`, + output: serializeToolResultOutput(event.output), + is_error: event.isError, + }); + return true; + case 'agent.status.updated': + case 'background.task.started': + case 'background.task.updated': + case 'background.task.terminated': + case 'compaction.blocked': + case 'compaction.cancelled': + case 'compaction.completed': + case 'compaction.started': + case 'error': + case 'session.meta.updated': + case 'skill.activated': + case 'subagent.completed': + case 'subagent.failed': + case 'subagent.spawned': + case 'tool.progress': + case 'tool.list.updated': + case 'mcp.server.status': + case 'turn.ended': + case 'turn.started': + case 'turn.step.completed': + case 'turn.step.interrupted': + case 'turn.step.retrying': + case 'turn.step.started': + return true; + default: + return true; + } + } + + // Initializes turn-scoped buffers when the SDK starts a turn. + private handleTurnBegin(_event: TurnStartedEvent): void { + void _event; + this.resetLiveToolUiState(); + this.state.currentStep = 0; + this.patchLivePane({ + mode: 'waiting', + pendingApproval: null, + pendingQuestion: null, + }); + this.setAppState({ + isStreaming: true, + streamingPhase: 'waiting', + streamingStartTime: Date.now(), + }); + } + + // Finalizes turn-scoped state when the SDK completes a turn. + private handleTurnEnd(_event: TurnEndedEvent, sendQueued: (item: QueuedMessage) => void): void { + void _event; + const todos = this.state.todoPanel.getTodos(); + if (todos.length > 0 && todos.every((t) => t.status === 'done')) { + this.setTodoList([]); + } + this.resetLiveToolUiState(); + this.finalizeTurn(sendQueued); + } + + // Resets live render state for a new turn step. + private handleStepBegin(event: TurnStepStartedEvent): void { + this.state.currentStep = event.step; + this.resetLiveToolUiState(); + this.finalizeLiveTextBuffers('waiting'); + this.patchLivePane({ + mode: 'waiting', + pendingApproval: null, + pendingQuestion: null, + }); + this.setAppState({ + streamingPhase: 'waiting', + streamingStartTime: Date.now(), + }); + } + + // Surfaces step-level outcomes the user needs to act on. The common + // case (finishReason === 'tool_use' or 'end_turn') is silent — those + // already render via tool.call.started/tool.result and assistant.delta. + // The interesting case is max_tokens: the model started a tool_use but + // ran out of budget before finalizing it, so the partial tool call is + // still pinned in 'Preparing' state with no signal that anything went + // wrong. Flip those into a visible 'Truncated' state and append a + // notice pointing at the config knob. + private handleStepCompleted(event: TurnStepCompletedEvent): void { + if (event.finishReason !== 'max_tokens') return; + + // Scope the truncation marking to tool calls that belong to the + // step that just completed. Without this guard, stale entries from + // earlier retry attempts (or unrelated still-tracked calls) would + // get relabeled and counted, producing misleading "tool call was + // truncated" notices for the wrong step. + const eventTurnId = String(event.turnId); + let truncatedCount = 0; + for (const toolCall of this.state.activeToolCalls.values()) { + if (toolCall.result !== undefined) continue; + if (toolCall.streamingArguments === undefined) continue; + if (toolCall.turnId !== eventTurnId) continue; + if (toolCall.step !== event.step) continue; + toolCall.truncated = true; + const component = this.state.pendingToolComponents.get(toolCall.id); + if (component !== undefined) { + component.updateToolCall(toolCall); + } + truncatedCount += 1; + } + this.state.streamingToolCallArguments.clear(); + + const title = + truncatedCount > 0 + ? 'Model hit max_tokens — tool call was truncated before it could run.' + : 'Model hit max_tokens — no tool call was emitted.'; + // The `max_output_size` knob is only wired through to provider + // requests for the Anthropic provider (see toKosongProviderConfig). + // For OpenAI / Kimi / Google sessions the advice would be a + // dead end, so skip the second line on those providers. + const detail = this.isAnthropicSessionActive() + ? 'If this limit is wrong for your model, set `max_output_size` on the model alias in your kimi-code config.' + : undefined; + this.showNotice(title, detail); + } + + private isAnthropicSessionActive(): boolean { + const providerKey = this.state.appState.availableModels[this.state.appState.model]?.provider; + if (providerKey === undefined) return false; + return this.state.appState.availableProviders[providerKey]?.type === 'anthropic'; + } + + // Renders user-facing status for an interrupted turn step. + private handleStepInterrupted(event: TurnStepInterruptedEvent): void { + this.resetLiveToolUiState(); + this.finalizeLiveTextBuffers('idle'); + const reason = event.reason; + if (reason === 'error') return; + if (reason === 'aborted' || reason === undefined || reason === '') { + this.showStatus('Interrupted by user', this.state.theme.colors.error); + return; + } + this.showError( + reason === 'max_steps' + ? 'reached per-turn step limit (max_steps)' + : `step interrupted (${reason})`, + ); + } + + // Appends a thinking delta to the live thinking block. + private handleThinkingDelta(event: ThinkingDeltaEvent): void { + this.state.thinkingDraft += event.delta; + this.onThinkingUpdate(this.state.thinkingDraft); + this.patchLivePane({ mode: 'idle' }); + this.setAppState({ streamingPhase: 'thinking' }); + } + + // Appends an assistant text delta to the live assistant block. + private handleAssistantDelta(event: AssistantDeltaEvent): void { + if (this.state.thinkingDraft.length > 0) { + this.flushThinkingToTranscript('idle'); + } + + if (!this.state.assistantStreamActive) { + this.state.assistantStreamActive = true; + this.onStreamingTextStart(); + } + + this.state.assistantDraft += event.delta; + this.onStreamingTextUpdate(this.state.assistantDraft); + + this.patchLivePane({ + mode: 'idle', + pendingApproval: null, + pendingQuestion: null, + }); + this.setAppState({ + streamingPhase: 'composing', + streamingStartTime: Date.now(), + }); + } + + private handleHookResult(event: HookResultEvent): void { + if (this.state.thinkingDraft.length > 0) { + this.flushThinkingToTranscript('idle'); + } + this.finalizeAssistantStream(); + this.appendTranscriptEntry({ + id: nextTranscriptId(), + kind: 'assistant', + turnId: String(event.turnId), + renderMode: 'markdown', + content: formatHookResultMarkdown(event), + }); + this.patchLivePane({ + mode: 'idle', + pendingApproval: null, + pendingQuestion: null, + }); + } + + // Starts or updates a rendered tool call from a tool-call start event. + private handleToolCall(event: ToolCallStartedEvent): void { + const toolCall: ToolCallBlockData = { + id: event.toolCallId, + name: event.name, + args: argsRecord(event.args), + description: event.description, + display: event.display, + step: this.state.currentStep, + turnId: this.state.currentTurnId, + }; + const existing = this.state.activeToolCalls.get(event.toolCallId); + this.state.activeToolCalls.set(event.toolCallId, toolCall); + this.state.streamingToolCallArguments.delete(event.toolCallId); + const existingComponent = this.state.pendingToolComponents.get(event.toolCallId); + if (existingComponent !== undefined) { + existingComponent.updateToolCall(toolCall); + } else if (existing === undefined) { + this.finalizeLiveTextBuffers('tool'); + if (event.name !== 'Agent') { + this.onToolCallStart(toolCall); + } + } + this.patchLivePane({ + mode: 'tool', + pendingApproval: null, + pendingQuestion: null, + }); + } + + // Accumulates streaming tool-call arguments and updates the rendered call. + private handleToolCallDelta(event: ToolCallDeltaEvent): void { + if (event.toolCallId.length === 0) return; + const id = event.toolCallId; + const existing = this.state.streamingToolCallArguments.get(id); + const argumentsText = `${existing?.argumentsText ?? ''}${event.argumentsPart ?? ''}`; + const name = event.name ?? existing?.name ?? this.state.activeToolCalls.get(id)?.name ?? 'Tool'; + const startedAtMs = existing?.startedAtMs ?? Date.now(); + this.state.streamingToolCallArguments.set(id, { name, argumentsText, startedAtMs }); + + const toolCall: ToolCallBlockData = { + id, + name, + args: parseStreamingArgs(argumentsText), + streamingArguments: argumentsText, + streamingStartedAtMs: startedAtMs, + step: this.state.currentStep, + turnId: this.state.currentTurnId, + }; + this.state.activeToolCalls.set(id, toolCall); + + if (this.state.thinkingDraft.length > 0 || this.state.assistantStreamActive) { + this.finalizeLiveTextBuffers('tool'); + } + + const existingComponent = this.state.pendingToolComponents.get(id); + if (existingComponent !== undefined) { + existingComponent.updateToolCall(toolCall); + } else if (name !== 'Agent') { + this.onToolCallStart(toolCall); + } + + this.patchLivePane({ + mode: 'tool', + pendingApproval: null, + pendingQuestion: null, + }); + this.setAppState({ + streamingPhase: 'composing', + streamingStartTime: Date.now(), + }); + } + + // Streams a `{kind:'status'}` progress text into the live tool box so + // long-blocking tools (e.g. the MCP synthetic `authenticate` tool whose + // 15-minute browser wait would otherwise show only a spinner) can surface + // their authorization URL. Non-status update kinds stay out of the terminal + // transcript because only status text needs persistent display. + private handleToolProgress(event: ToolProgressEvent): void { + if (event.update.kind !== 'status') return; + const text = event.update.text; + if (text === undefined || text.length === 0) return; + const tc = this.state.pendingToolComponents.get(event.toolCallId); + if (tc === undefined) return; + tc.appendProgress(text); + } + + // Completes a tool call and applies any tool-specific UI side effects. + private handleToolResult(event: ToolResultEvent): void { + const matchedCall = this.state.activeToolCalls.get(event.toolCallId); + const resultData: ToolResultBlockData = { + tool_call_id: event.toolCallId, + output: serializeToolResultOutput(event.output), + is_error: event.isError, + synthetic: event.synthetic, + }; + if (matchedCall !== undefined) { + this.onToolCallEnd(event.toolCallId, resultData); + if (matchedCall.name === 'TodoList' && !event.isError) { + const rawTodos = (matchedCall.args as { todos?: unknown }).todos; + if (Array.isArray(rawTodos)) { + const sanitized = rawTodos + .filter((todo): todo is { title: string; status: 'pending' | 'in_progress' | 'done' } => + isTodoItemShape(todo), + ) + .map((t) => ({ title: t.title, status: t.status })); + this.setTodoList(sanitized); + } + } + } + this.state.activeToolCalls.delete(event.toolCallId); + this.state.streamingToolCallArguments.delete(event.toolCallId); + this.patchLivePane({ mode: 'waiting' }); + } + + // Applies agent status updates to app state. + private handleStatusUpdate(event: AgentStatusUpdatedEvent): void { + const patch: Partial<AppState> = {}; + if (event.contextUsage !== undefined) patch.contextUsage = event.contextUsage; + if (event.contextTokens !== undefined) patch.contextTokens = event.contextTokens; + if (event.maxContextTokens !== undefined) patch.maxContextTokens = event.maxContextTokens; + if (event.planMode !== undefined) patch.planMode = event.planMode; + if (event.permission !== undefined) { + patch.permissionMode = event.permission; + patch.yolo = event.permission === 'yolo'; + } + if (event.model !== undefined) patch.model = event.model; + if (Object.keys(patch).length > 0) this.setAppState(patch); + } + + // Applies session metadata changes to the UI and process title. + private handleSessionMetaChanged(event: SessionMetaUpdatedEvent): void { + const title = event.title ?? stringValue(event.patch?.['title']); + if (title !== undefined) { + this.setAppState({ sessionTitle: title }); + setProcessTitle(title, this.state.appState.sessionId); + } + } + + // Finalizes live buffers and renders a session error. + private handleSessionError(event: ErrorEvent): void { + this.resetLiveToolUiState(); + this.finalizeLiveTextBuffers('idle'); + if (event.code === OAUTH_LOGIN_REQUIRED_CODE) { + this.showError(OAUTH_LOGIN_REQUIRED_STARTUP_NOTICE); + return; + } + this.showError(`[${event.code}] ${event.message}`); + const sessionId = this.state.appState.sessionId; + if (sessionId.length > 0) { + this.showStatus(errorReportHintLine(sessionId)); + } + } + + private renderMcpServerStatus(server: McpServerStatusSnapshot): void { + const key = mcpServerStatusKey(server); + if (this.state.renderedMcpServerStatusKeys.get(server.name) === key) return; + this.state.renderedMcpServerStatusKeys.set(server.name, key); + + const colors = this.state.theme.colors; + switch (server.status) { + case 'connected': { + const toolStr = `${server.toolCount} tool${server.toolCount === 1 ? '' : 's'}`; + const message = `MCP server "${server.name}" connected · ${toolStr} (${server.transport})`; + this.finalizeMcpServerStatusRow(server.name, message, colors.success); + return; + } + case 'failed': { + const message = `MCP server "${server.name}" failed${server.error !== undefined ? `: ${server.error}` : ''}`; + this.finalizeMcpServerStatusRow(server.name, message, colors.error); + return; + } + case 'needs-auth': { + const message = `MCP server "${server.name}" needs OAuth — run /mcp-config login ${server.name}`; + this.finalizeMcpServerStatusRow(server.name, message, colors.warning); + return; + } + case 'disabled': + this.finalizeMcpServerStatusRow( + server.name, + `MCP server "${server.name}" disabled`, + colors.textMuted, + ); + return; + case 'pending': + this.showMcpServerStatusSpinner(server.name); + return; + } + } + + private showMcpServerStatusSpinner(name: string): void { + const label = `MCP server "${name}" connecting…`; + const existing = this.state.mcpServerStatusSpinners.get(name); + if (existing !== undefined) { + existing.setLabel(label); + return; + } + const tint = (s: string): string => chalk.hex(this.state.theme.colors.textMuted)(s); + const spinner = new MoonLoader(this.state.ui, 'braille', tint, label); + this.state.transcriptContainer.addChild(spinner); + this.state.mcpServerStatusSpinners.set(name, spinner); + this.state.ui.requestRender(); + } + + private finalizeMcpServerStatusRow(name: string, message: string, color: string): void { + const spinner = this.state.mcpServerStatusSpinners.get(name); + if (spinner === undefined) { + this.showStatus(message, color); + return; + } + spinner.stop(); + const status = new StatusMessageComponent(message, this.state.theme.colors, color); + const children = this.state.transcriptContainer.children; + const idx = children.indexOf(spinner); + if (idx >= 0) { + children[idx] = status; + this.state.transcriptContainer.invalidate(); + } else { + this.state.transcriptContainer.addChild(status); + } + this.state.mcpServerStatusSpinners.delete(name); + this.state.ui.requestRender(); + } + + private stopAllMcpServerStatusSpinners(): void { + for (const spinner of this.state.mcpServerStatusSpinners.values()) { + spinner.stop(); + } + this.state.mcpServerStatusSpinners.clear(); + } + + // Adds a skill activation entry to the transcript once. + private handleSkillActivated(event: SkillActivatedEvent): void { + if (this.state.renderedSkillActivationIds.has(event.activationId)) return; + this.state.renderedSkillActivationIds.add(event.activationId); + this.appendTranscriptEntry({ + id: nextTranscriptId(), + kind: 'skill_activation', + turnId: undefined, + renderMode: 'plain', + content: `Activated skill: ${event.skillName}`, + skillActivationId: event.activationId, + skillName: event.skillName, + skillArgs: event.skillArgs, + }); + } + + // Starts the compaction UI block and marks the app as compacting. + private handleCompactionBegin(event: CompactionStartedEvent): void { + this.finalizeLiveTextBuffers('waiting'); + this.setAppState({ + isCompacting: true, + streamingPhase: 'waiting', + streamingStartTime: Date.now(), + }); + this.beginCompaction(event.instruction); + } + + // Finishes compaction and resumes queued work when possible. + private handleCompactionEnd( + event: CompactionCompletedEvent, + sendQueued: (item: QueuedMessage) => void, + ): void { + this.endCompaction(event.result.tokensBefore, event.result.tokensAfter); + this.finishCompaction(sendQueued); + } + + private handleCompactionCancel( + _event: CompactionCancelledEvent, + sendQueued: (item: QueuedMessage) => void, + ): void { + this.cancelCompactionBlock(); + this.finishCompaction(sendQueued); + } + + private finishCompaction(sendQueued: (item: QueuedMessage) => void): void { + if (!this.state.appState.isStreaming) { + this.setAppState({ + isCompacting: false, + streamingPhase: 'idle', + }); + this.resetLivePane(); + if (this.state.queuedMessages.length > 0) { + const [next, ...rest] = this.state.queuedMessages; + this.state.queuedMessages = rest; + if (next !== undefined) { + setTimeout(() => { + sendQueued(next); + }, 0); + } + } + } else { + this.setAppState({ isCompacting: false }); + } + } + + // Registers a spawned subagent and renders foreground or background status. + private handleSubagentSpawned(event: SubagentSpawnedEvent): void { + this.state.subagentParentToolCallIds.set(event.subagentId, event.parentToolCallId); + this.state.subagentNames.set(event.subagentId, event.subagentName); + + if (event.runInBackground) { + const meta = this.buildBackgroundAgentMetadata(event); + this.state.backgroundAgentMetadata.set(event.subagentId, meta); + this.state.backgroundAgents.add(event.subagentId); + this.appendBackgroundAgentEntry('started', meta); + this.syncBackgroundAgentBadge(); + return; + } + + let tc = this.state.pendingToolComponents.get(event.parentToolCallId); + if (tc === undefined) { + const toolCall = this.state.activeToolCalls.get(event.parentToolCallId); + if (toolCall !== undefined) { + this.onToolCallStart(toolCall); + tc = this.state.pendingToolComponents.get(event.parentToolCallId); + } + } + tc ??= this.createStandaloneSubagentToolCall(event); + if (tc === undefined) return; + tc.onSubagentSpawned({ + agentId: event.subagentId, + agentName: event.subagentName, + runInBackground: event.runInBackground, + }); + } + + // Completes a subagent in its parent tool call or background transcript entry. + private handleSubagentCompleted(event: SubagentCompletedEvent): void { + const backgroundMeta = this.state.backgroundAgentMetadata.get(event.subagentId); + if (this.state.backgroundAgents.delete(event.subagentId)) { + this.syncBackgroundAgentBadge(); + } + if (backgroundMeta !== undefined) { + this.state.backgroundAgentMetadata.delete(event.subagentId); + // Dedupe: if the BPM `background.task.terminated` for the + // matching agent task already pushed a terminal card, skip. + // Otherwise mark the subagent id so a later BPM event skips. + const taskId = this.findAgentTaskId(event.subagentId); + if (taskId !== undefined && this.state.backgroundTaskTranscriptedTerminal.has(taskId)) { + return; + } + if (taskId !== undefined) { + this.state.backgroundTaskTranscriptedTerminal.add(taskId); + } + const extras = + event.resultSummary === undefined ? undefined : { resultSummary: event.resultSummary }; + this.appendBackgroundAgentEntry('completed', backgroundMeta, extras); + return; + } + const tc = this.state.pendingToolComponents.get(event.parentToolCallId); + if (tc === undefined) return; + tc.onSubagentCompleted({ + usage: event.usage, + resultSummary: event.resultSummary, + }); + if (!this.state.activeToolCalls.has(event.parentToolCallId)) { + this.state.pendingToolComponents.delete(event.parentToolCallId); + } + } + + // Marks a subagent failure in its parent tool call or background transcript entry. + private handleSubagentFailed(event: SubagentFailedEvent): void { + const backgroundMeta = this.state.backgroundAgentMetadata.get(event.subagentId); + if (this.state.backgroundAgents.delete(event.subagentId)) { + this.syncBackgroundAgentBadge(); + } + if (backgroundMeta !== undefined) { + this.state.backgroundAgentMetadata.delete(event.subagentId); + const taskId = this.findAgentTaskId(event.subagentId); + if (taskId !== undefined && this.state.backgroundTaskTranscriptedTerminal.has(taskId)) { + return; + } + if (taskId !== undefined) { + this.state.backgroundTaskTranscriptedTerminal.add(taskId); + } + this.appendBackgroundAgentEntry('failed', backgroundMeta, { error: event.error }); + return; + } + const tc = this.state.pendingToolComponents.get(event.parentToolCallId); + if (tc === undefined) return; + tc.onSubagentFailed({ error: event.error }); + if (!this.state.activeToolCalls.has(event.parentToolCallId)) { + this.state.pendingToolComponents.delete(event.parentToolCallId); + } + } + + // Mounts subagents launched by session-level commands that do not originate + // from a model-issued Agent tool call. + private createStandaloneSubagentToolCall(event: SubagentSpawnedEvent): ToolCallComponent | undefined { + const description = event.description ?? `Run ${event.subagentName} agent`; + const toolCall: ToolCallBlockData = { + id: event.parentToolCallId, + name: 'Agent', + args: { + description, + subagent_type: event.subagentName, + }, + description, + step: this.state.currentStep, + turnId: this.state.currentTurnId, + }; + this.onToolCallStart(toolCall); + return this.state.pendingToolComponents.get(event.parentToolCallId); + } + + /** + * Locate the BPM `agent-*` task id whose `description` matches the + * spawned subagent's recorded description. Used only for dedupe + * between the BPM and subagent flows — best-effort: if there is no + * unique match (e.g. multiple agent tasks with the same description) + * the caller treats the dedupe as a miss, which is safe. + */ + private findAgentTaskId(subagentId: string): string | undefined { + const meta = this.state.backgroundAgentMetadata.get(subagentId); + const description = meta?.description ?? meta?.agentName; + if (description === undefined) return undefined; + let match: string | undefined; + for (const info of this.state.backgroundTasks.values()) { + if (!info.taskId.startsWith('agent-')) continue; + if (info.description !== description) continue; + if (match !== undefined) return undefined; // ambiguous + match = info.taskId; + } + return match; + } + + // Builds transcript metadata for a background subagent. + private buildBackgroundAgentMetadata(event: SubagentSpawnedEvent): BackgroundAgentMetadata { + const parent = this.state.activeToolCalls.get(event.parentToolCallId); + const description = parent?.args['description'] ?? event.description; + return { + agentId: event.subagentId, + parentToolCallId: event.parentToolCallId, + agentName: event.subagentName, + description: typeof description === 'string' ? description : undefined, + }; + } + + // Appends a background-agent status row to the transcript. + private appendBackgroundAgentEntry( + phase: 'started' | 'completed' | 'failed', + meta: BackgroundAgentMetadata, + extras: { resultSummary?: string; error?: string } | undefined = undefined, + ): void { + const status = formatBackgroundAgentTranscript(phase, meta, extras); + const entry: TranscriptEntry = { + id: nextTranscriptId(), + kind: 'status', + turnId: this.state.currentTurnId, + renderMode: 'plain', + content: status.headline, + detail: status.detail, + backgroundAgentStatus: status, + }; + this.appendTranscriptEntry(entry); + } + + // Updates the footer badge for active background agents. + private syncBackgroundAgentBadge(): void { + this.syncBackgroundTaskBadge(); + } + + // ========================================================================= + // Background task lifecycle (BPM-derived, covers both bash + agent tasks) + // ========================================================================= + + private handleBackgroundTaskEvent( + event: BackgroundTaskStartedEvent | BackgroundTaskUpdatedEvent | BackgroundTaskTerminatedEvent, + ): void { + const { info } = event; + const previous = this.state.backgroundTasks.get(info.taskId); + this.state.backgroundTasks.set(info.taskId, info); + + // If the user is currently viewing this task's output, nudge a + // refresh immediately so they see new content without waiting for + // the 1s poll. Same dedupe-by-output-equality applies inside. + const viewer = this.state.tasksBrowser?.viewer; + if (viewer !== undefined && viewer.taskId === info.taskId) { + void this.refreshTaskOutputViewer({ silent: true }); + } + + const isTerminal = + info.status === 'completed' || + info.status === 'failed' || + info.status === 'killed' || + info.status === 'lost'; + + if (event.type === 'background.task.started') { + // For agent-* tasks, the legacy subagent.spawned flow already + // pushed a 'started' transcript card; skip to avoid duplicates. + if (info.taskId.startsWith('agent-')) { + this.syncBackgroundTaskBadge(); + this.repaintTasksBrowser(); + return; + } + this.appendBackgroundTaskEntry(info); + this.syncBackgroundTaskBadge(); + this.repaintTasksBrowser(); + return; + } + + if (event.type === 'background.task.terminated' && isTerminal) { + if (!this.state.backgroundTaskTranscriptedTerminal.has(info.taskId)) { + // For agent-* tasks, the older subagent.completed/failed flow + // may also produce a terminal card; whoever wins records the + // dedupe marker first. See handleSubagentCompleted/Failed. + if (info.taskId.startsWith('bash-')) { + this.appendBackgroundTaskEntry(info); + } + this.state.backgroundTaskTranscriptedTerminal.add(info.taskId); + } + this.syncBackgroundTaskBadge(); + this.repaintTasksBrowser(); + return; + } + + // updated: status flipped between running and awaiting_approval. + // No transcript card — just sync the badge if the active count + // changed (awaiting_approval still counts as active). + if (previous?.status !== info.status) { + this.syncBackgroundTaskBadge(); + } + this.repaintTasksBrowser(); + } + + private appendBackgroundTaskEntry(info: BackgroundTaskInfo): void { + const status = formatBackgroundTaskTranscript(info); + const entry: TranscriptEntry = { + id: nextTranscriptId(), + kind: 'status', + turnId: this.state.currentTurnId, + renderMode: 'plain', + content: status.headline, + detail: status.detail, + backgroundAgentStatus: status, + }; + this.appendTranscriptEntry(entry); + } + + // Footer counts are BPM-derived: every task that is not terminal, + // split by id prefix so bash and agent badges render independently. + // awaiting_approval still counts as active; lost/killed/completed/ + // failed do not. + private syncBackgroundTaskBadge(): void { + let bashTasks = 0; + let agentTasks = 0; + for (const info of this.state.backgroundTasks.values()) { + if ( + info.status === 'completed' || + info.status === 'failed' || + info.status === 'killed' || + info.status === 'lost' + ) { + continue; + } + if (info.taskId.startsWith('agent-')) { + agentTasks += 1; + } else { + bashTasks += 1; + } + } + this.state.footer.setBackgroundCounts({ bashTasks, agentTasks }); + this.state.ui.requestRender(); + } + + // ========================================================================= + // Live Render Hooks + // ========================================================================= + + // Creates the live assistant transcript component. + private onStreamingTextStart(): void { + this.state.pendingAgentGroup = null; + this.state.pendingReadGroup = null; + const entry = { + id: nextTranscriptId(), + kind: 'assistant' as const, + turnId: this.state.currentTurnId, + renderMode: 'markdown' as const, + content: '', + }; + this.state.streamingComponent = new AssistantMessageComponent( + this.state.theme.markdownTheme, + this.state.theme.colors, + ); + this.state.streamingTranscriptEntry = entry; + this.state.transcriptEntries.push(entry); + this.state.transcriptContainer.addChild(this.state.streamingComponent); + this.state.ui.requestRender(); + } + + // Updates the live assistant transcript component. + private onStreamingTextUpdate(fullText: string): void { + if (this.state.streamingTranscriptEntry !== undefined) { + this.state.streamingTranscriptEntry.content = fullText; + } + if (this.state.streamingComponent) { + this.state.streamingComponent.updateContent(fullText); + this.state.ui.requestRender(); + } + } + + // Clears live assistant component references after streaming ends. + private onStreamingTextEnd(): void { + this.state.streamingComponent = undefined; + this.state.streamingTranscriptEntry = undefined; + } + + // Creates or updates the live thinking transcript component. + private onThinkingUpdate(fullText: string): void { + if (this.state.activeThinkingComponent === undefined) { + this.state.pendingAgentGroup = null; + this.state.pendingReadGroup = null; + this.state.activeThinkingComponent = new ThinkingComponent( + fullText, + this.state.theme.colors, + true, + 'live', + this.state.ui, + ); + if (this.state.toolOutputExpanded) this.state.activeThinkingComponent.setExpanded(true); + this.state.transcriptContainer.addChild(this.state.activeThinkingComponent); + } else { + this.state.activeThinkingComponent.setText(fullText); + } + this.state.ui.requestRender(); + } + + // Finalizes the live thinking transcript component. + private onThinkingEnd(): void { + if (this.state.activeThinkingComponent === undefined) return; + this.state.activeThinkingComponent.finalize(); + this.state.activeThinkingComponent = undefined; + this.state.ui.requestRender(); + } + + // Creates and mounts a live tool-call component. + private onToolCallStart(toolCall: ToolCallBlockData): void { + if (toolCall.name === 'AskUserQuestion') return; + + const tc = new ToolCallComponent( + toolCall, + undefined, + this.state.theme.colors, + this.state.ui, + this.state.theme.markdownTheme, + this.state.appState.workDir, + ); + if (this.state.toolOutputExpanded) tc.setExpanded(true); + if (this.state.planExpanded) tc.setPlanExpanded(true); + this.state.pendingToolComponents.set(toolCall.id, tc); + + if (toolCall.name !== 'Agent') this.state.pendingAgentGroup = null; + if (toolCall.name !== 'Read') this.state.pendingReadGroup = null; + + let handled = this.tryAttachAgentToolCall(toolCall, tc); + if (!handled) handled = this.tryAttachReadToolCall(toolCall, tc); + if (!handled) { + this.state.transcriptContainer.addChild(tc); + this.state.ui.requestRender(); + } + + if (toolCall.name === 'ExitPlanMode' && typeof toolCall.args['plan'] !== 'string') { + const session = this.requireSession(); + void (async () => { + try { + const plan = await session.getPlan(); + tc.setPlanInfo(plan === null ? {} : { plan: plan.content, path: plan.path }); + } catch { + tc.setPlanInfo({}); + } + })(); + } + } + + // Applies a tool result to a live or completed tool-call component. + private onToolCallEnd(toolCallId: string, result: ToolResultBlockData): void { + const matchedCall = this.state.activeToolCalls.get(toolCallId); + const tc = this.state.pendingToolComponents.get(toolCallId); + if (tc) { + tc.setResult(result); + this.state.pendingToolComponents.delete(toolCallId); + this.state.ui.requestRender(); + return; + } + + if (matchedCall?.name === 'AskUserQuestion') { + const completed = new ToolCallComponent( + matchedCall, + result, + this.state.theme.colors, + this.state.ui, + this.state.theme.markdownTheme, + this.state.appState.workDir, + ); + if (this.state.toolOutputExpanded) completed.setExpanded(true); + if (this.state.planExpanded) completed.setPlanExpanded(true); + this.state.transcriptContainer.addChild(completed); + this.state.ui.requestRender(); + } + } + + // Replaces the visible todo list panel. + private setTodoList(todos: readonly TodoItem[]): void { + this.state.todoPanel.setTodos(todos); + this.state.todoPanelContainer.clear(); + if (!this.state.todoPanel.isEmpty()) { + this.state.todoPanelContainer.addChild(this.state.todoPanel); + } + this.state.ui.requestRender(); + } + + // Renders a compaction block in the transcript. + private beginCompaction(instruction?: string): void { + if (this.state.activeCompactionBlock !== undefined) { + this.state.activeCompactionBlock.markDone(); + this.state.activeCompactionBlock = undefined; + } + const block = new CompactionComponent(this.state.theme.colors, this.state.ui, instruction); + this.state.activeCompactionBlock = block; + this.state.transcriptContainer.addChild(block); + this.state.ui.requestRender(); + } + + // Marks the active compaction block complete. + private endCompaction(tokensBefore?: number, tokensAfter?: number): void { + const block = this.state.activeCompactionBlock; + if (block === undefined) return; + block.markDone(tokensBefore, tokensAfter); + this.state.activeCompactionBlock = undefined; + this.state.ui.requestRender(); + } + + private cancelCompactionBlock(): void { + const block = this.state.activeCompactionBlock; + if (block === undefined) return; + block.markCanceled(); + this.state.activeCompactionBlock = undefined; + this.state.ui.requestRender(); + } + + // Groups Agent tool calls that belong to the same turn step. + private tryAttachAgentToolCall(toolCall: ToolCallBlockData, tc: ToolCallComponent): boolean { + if (toolCall.name !== 'Agent') { + this.state.pendingAgentGroup = null; + return false; + } + + const step = toolCall.step ?? this.state.currentStep; + const turnId = toolCall.turnId ?? this.state.currentTurnId; + const pending = this.state.pendingAgentGroup; + + if (pending !== null && (pending.step !== step || pending.turnId !== turnId)) { + this.state.pendingAgentGroup = null; + } + + const cur = this.state.pendingAgentGroup; + if (cur === null) { + this.state.pendingAgentGroup = { step, turnId, solo: tc }; + this.state.transcriptContainer.addChild(tc); + this.state.ui.requestRender(); + return true; + } + + if (cur.group !== undefined) { + cur.group.attach(toolCall.id, tc); + return true; + } + + const solo = cur.solo; + if (solo === undefined) { + this.state.pendingAgentGroup = { step, turnId, solo: tc }; + this.state.transcriptContainer.addChild(tc); + this.state.ui.requestRender(); + return true; + } + const group = this.upgradeSoloAgentToGroup(solo); + group.attach(toolCall.id, tc); + this.state.pendingAgentGroup = { step, turnId, group }; + this.state.ui.requestRender(); + return true; + } + + // Replaces a single Agent tool call with an Agent group component. + private upgradeSoloAgentToGroup(solo: ToolCallComponent): AgentGroupComponent { + const group = new AgentGroupComponent(this.state.theme.colors, this.state.ui); + const children = this.state.transcriptContainer.children; + const idx = children.indexOf(solo); + if (idx >= 0) { + children[idx] = group; + this.state.transcriptContainer.invalidate(); + } else { + this.state.transcriptContainer.addChild(group); + } + group.attach(solo.toolCallView.id, solo); + return group; + } + + // Groups Read tool calls that belong to the same turn step. + private tryAttachReadToolCall(toolCall: ToolCallBlockData, tc: ToolCallComponent): boolean { + if (toolCall.name !== 'Read') { + this.state.pendingReadGroup = null; + return false; + } + + const step = toolCall.step ?? this.state.currentStep; + const turnId = toolCall.turnId ?? this.state.currentTurnId; + const pending = this.state.pendingReadGroup; + + if (pending !== null && (pending.step !== step || pending.turnId !== turnId)) { + this.state.pendingReadGroup = null; + } + + const cur = this.state.pendingReadGroup; + if (cur === null) { + this.state.pendingReadGroup = { step, turnId, solo: tc }; + this.state.transcriptContainer.addChild(tc); + this.state.ui.requestRender(); + return true; + } + + if (cur.group !== undefined) { + cur.group.attach(toolCall.id, tc); + return true; + } + + const solo = cur.solo; + if (solo === undefined) { + this.state.pendingReadGroup = { step, turnId, solo: tc }; + this.state.transcriptContainer.addChild(tc); + this.state.ui.requestRender(); + return true; + } + const group = this.upgradeSoloReadToGroup(solo); + group.attach(toolCall.id, tc); + this.state.pendingReadGroup = { step, turnId, group }; + this.state.ui.requestRender(); + return true; + } + + // Replaces a single Read tool call with a Read group component. + private upgradeSoloReadToGroup(solo: ToolCallComponent): ReadGroupComponent { + const group = new ReadGroupComponent(this.state.theme.colors, this.state.ui); + const children = this.state.transcriptContainer.children; + const idx = children.indexOf(solo); + if (idx >= 0) { + children[idx] = group; + this.state.transcriptContainer.invalidate(); + } else { + this.state.transcriptContainer.addChild(group); + } + group.attach(solo.toolCallView.id, solo); + return group; + } + + // ========================================================================= + // Transcript Rendering + // ========================================================================= + + // Creates the pi-tui component that renders a transcript entry. + private createTranscriptComponent(entry: TranscriptEntry): Component | null { + if (entry.compactionData !== undefined) { + const data = entry.compactionData; + const block = new CompactionComponent( + this.state.theme.colors, + this.state.ui, + data.instruction, + ); + block.markDone(data.tokensBefore, data.tokensAfter); + return block; + } + + switch (entry.kind) { + case 'user': { + const images = entry.imageAttachmentIds + ?.map((id) => this.imageStore.get(id)) + .filter((a): a is ImageAttachment => a?.kind === 'image'); + return new UserMessageComponent(entry.content, this.state.theme.colors, images); + } + case 'skill_activation': + return new SkillActivationComponent( + entry.skillName ?? entry.content, + entry.skillArgs, + this.state.theme.colors, + ); + case 'assistant': { + const component = new AssistantMessageComponent( + this.state.theme.markdownTheme, + this.state.theme.colors, + ); + component.updateContent(entry.content); + return component; + } + case 'thinking': { + const thinking = new ThinkingComponent(entry.content, this.state.theme.colors, true); + if (this.state.toolOutputExpanded) thinking.setExpanded(true); + return thinking; + } + case 'tool_call': + if (entry.toolCallData) { + const tc = new ToolCallComponent( + entry.toolCallData, + entry.toolCallData.result, + this.state.theme.colors, + this.state.ui, + this.state.theme.markdownTheme, + this.state.appState.workDir, + ); + if (this.state.toolOutputExpanded) tc.setExpanded(true); + if (this.state.planExpanded) tc.setPlanExpanded(true); + return tc; + } + if (entry.backgroundAgentStatus !== undefined) { + return new BackgroundAgentStatusComponent( + entry.backgroundAgentStatus, + this.state.theme.colors, + ); + } + return entry.renderMode === 'notice' + ? new NoticeMessageComponent(entry.content, entry.detail, this.state.theme.colors) + : new StatusMessageComponent(entry.content, this.state.theme.colors, entry.color); + case 'status': + if (entry.backgroundAgentStatus !== undefined) { + return new BackgroundAgentStatusComponent( + entry.backgroundAgentStatus, + this.state.theme.colors, + ); + } + return entry.renderMode === 'notice' + ? new NoticeMessageComponent(entry.content, entry.detail, this.state.theme.colors) + : new StatusMessageComponent(entry.content, this.state.theme.colors, entry.color); + case 'welcome': + return null; + default: + return null; + } + } + + // Stores a transcript entry and mounts its component if renderable. + private appendTranscriptEntry(entry: TranscriptEntry): void { + this.state.transcriptEntries.push(entry); + const component = this.createTranscriptComponent(entry); + if (component) { + this.state.transcriptContainer.addChild(component); + this.state.ui.requestRender(); + } + } + + // Appends an approval-result entry to the transcript. + private appendApprovalTranscriptEntry(request: ApprovalRequest, response: ApprovalResponse): void { + const parts: string[] = []; + switch (response.decision) { + case 'approved': + parts.push(response.scope === 'session' ? 'Approved for session' : 'Approved'); + break; + case 'rejected': + parts.push('Rejected'); + break; + case 'cancelled': + parts.push('Cancelled'); + break; + } + parts.push(`: ${request.action}`); + if (response.feedback !== undefined && response.feedback.length > 0) { + parts.push(` — "${response.feedback}"`); + } + this.appendTranscriptEntry({ + id: nextTranscriptId(), + kind: 'status', + renderMode: 'notice', + content: parts.join(''), + }); + } + + // Adds the welcome component to the transcript. + private renderWelcome(): void { + const welcome = new WelcomeComponent(this.state.appState, this.state.theme.colors); + this.state.transcriptContainer.addChild(welcome); + } + + // Disposes the active compaction component if one is mounted. + private disposeActiveCompactionBlock(): void { + if (this.state.activeCompactionBlock !== undefined) { + this.state.activeCompactionBlock.dispose(); + this.state.activeCompactionBlock = undefined; + } + } + + // Disposes the active thinking component if one is mounted. + private disposeActiveThinkingComponent(): void { + if (this.state.activeThinkingComponent !== undefined) { + this.state.activeThinkingComponent.dispose(); + this.state.activeThinkingComponent = undefined; + } + } + + // Disposes and forgets all pending live tool-call components. + private disposeAndClearPendingToolComponents(): void { + for (const component of this.state.pendingToolComponents.values()) { + if (hasDispose(component)) component.dispose(); + } + this.state.pendingToolComponents.clear(); + } + + private clearTerminalInlineImages(): void { + if (getCapabilities().images !== 'kitty') return; + this.state.terminal.write(deleteAllKittyImages()); + } + + // Clears transcript-related state and redraws the welcome view. + private clearTranscriptAndRedraw(): void { + this.state.transcriptEntries = []; + this.disposeActiveCompactionBlock(); + this.resetLiveTextRuntime(); + this.resetLiveToolUiState(); + this.stopAllMcpServerStatusSpinners(); + this.state.transcriptContainer.clear(); + this.clearTerminalInlineImages(); + this.state.todoPanel.clear(); + this.state.todoPanelContainer.clear(); + this.imageStore.clear(); + this.renderWelcome(); + } + + // Appends a status message to the transcript. + private showStatus(message: string, color?: string): void { + this.state.transcriptContainer.addChild( + new StatusMessageComponent(message, this.state.theme.colors, color), + ); + this.state.ui.requestRender(); + } + + // Appends a notice message to the transcript. + private showNotice(title: string, detail?: string): void { + this.state.transcriptContainer.addChild( + new NoticeMessageComponent(title, detail, this.state.theme.colors), + ); + this.state.ui.requestRender(); + } + + // Appends an error status message to the transcript. + private showError(message: string): void { + this.showStatus(`Error: ${message}`, this.state.theme.colors.error); + } + + // Adds an animated login progress row to the transcript. + private showLoginProgressSpinner(label: string): LoginProgressSpinnerHandle { + const tint = (s: string): string => chalk.hex(this.state.theme.colors.primary)(s); + const spinner = new MoonLoader(this.state.ui, 'braille', tint, label); + this.state.transcriptContainer.addChild(new Spacer(1)); + this.state.transcriptContainer.addChild(spinner); + this.state.ui.requestRender(); + return { + stop: ({ ok, label: finalLabel }) => { + spinner.stop(); + const tone = ok ? this.state.theme.colors.success : this.state.theme.colors.error; + const symbol = ok ? '✓' : '✗'; + spinner.setText(chalk.hex(tone)(`${symbol} ${finalLabel}`)); + this.state.ui.requestRender(); + }, + }; + } + + // Opens the device-code URL and renders the login authorization prompt. + private showLoginAuthorizationPrompt(auth: DeviceAuthorization): LoginProgressSpinnerHandle { + openUrl(auth.verificationUriComplete); + this.state.transcriptContainer.addChild( + new DeviceCodeBoxComponent({ + title: 'Sign in to Kimi Code', + url: auth.verificationUriComplete, + code: auth.userCode, + hint: 'Press Ctrl-C to cancel', + colors: this.state.theme.colors, + }), + ); + this.state.ui.requestRender(); + return this.showLoginProgressSpinner('Waiting for authorization…'); + } + + // Provides UI callbacks used while hydrating transcript history. + private replayHydrationHooks(): ReplayHydrationHooks { + return { + setAppState: (patch) => { + this.setAppState(patch); + }, + appendEntry: (entry) => { + this.appendTranscriptEntry(entry); + }, + setTodoList: (todos) => { + this.setTodoList(todos); + }, + emitError: (message) => { + this.showError(message); + }, + }; + } + + // ========================================================================= + // Panes / Presentation State + // ========================================================================= + + // Rebuilds the activity pane for the current live and streaming state. + private updateActivityPane(): void { + const effectiveMode = this.resolveActivityPaneMode(); + this.syncTerminalProgress(this.shouldShowTerminalProgress(effectiveMode)); + + if ( + effectiveMode === this.state.lastActivityMode && + (effectiveMode === 'waiting' || effectiveMode === 'thinking' || effectiveMode === 'tool') + ) { + return; + } + + this.state.lastActivityMode = effectiveMode; + this.state.activityContainer.clear(); + + switch (effectiveMode) { + case 'hidden': + this.stopActivitySpinner(); + this.state.ui.requestRender(); + return; + case 'waiting': { + const spinner = this.ensureActivitySpinner('moon'); + this.state.activityContainer.addChild( + new ActivityPaneComponent({ + mode: 'waiting', + spinner, + }), + ); + break; + } + case 'thinking': { + this.stopActivitySpinner(); + break; + } + case 'composing': { + const spinner = this.ensureActivitySpinner('braille', 'working...', (s) => + chalk.hex(this.state.theme.colors.primary)(s), + ); + this.state.activityContainer.addChild( + new ActivityPaneComponent({ + mode: 'composing', + spinner, + }), + ); + break; + } + case 'tool': { + const spinner = this.ensureActivitySpinner('moon'); + this.state.activityContainer.addChild( + new ActivityPaneComponent({ + mode: 'tool', + spinner, + }), + ); + break; + } + case 'idle': + case 'session': { + this.stopActivitySpinner(); + break; + } + } + this.state.ui.requestRender(); + } + + // Computes the effective activity-pane mode from modal and streaming state. + private resolveActivityPaneMode(): EffectiveActivityPaneMode { + if (this.state.showingSessionPicker) return 'hidden'; + if (this.state.livePane.pendingApproval !== null) return 'hidden'; + if (this.state.appState.isCompacting) return 'hidden'; + if (this.state.livePane.pendingQuestion !== null) return 'hidden'; + + const streamingPhase = this.state.appState.streamingPhase; + if (this.state.livePane.mode === 'idle') { + if (streamingPhase === 'thinking' || streamingPhase === 'composing') { + return streamingPhase; + } + } + + return this.state.livePane.mode; + } + + // Re-renders the queued-message pane. + private updateQueueDisplay(): void { + this.state.queueContainer.clear(); + const queued = this.state.queuedMessages; + if (queued.length === 0) return; + + this.state.queueContainer.addChild( + new QueuePaneComponent({ + messages: queued, + colors: this.state.theme.colors, + isCompacting: this.state.appState.isCompacting, + isStreaming: this.state.appState.isStreaming, + canSteerImmediately: !this.deferUserMessages, + }), + ); + } + + // Toggles expansion for all expandable tool-output components. + private toggleToolOutputExpansion(): void { + this.state.toolOutputExpanded = !this.state.toolOutputExpanded; + for (const child of this.state.transcriptContainer.children) { + if (isExpandable(child)) { + child.setExpanded(this.state.toolOutputExpanded); + } + } + this.state.ui.requestRender(); + } + + // Toggles expansion for plan-preview cards (ExitPlanMode). Returns true + // iff at least one plan card was actually toggled so the caller can decide + // whether to consume the keystroke vs. let pi-tui's default end-of-line run. + private togglePlanExpansion(): boolean { + const next = !this.state.planExpanded; + let toggled = false; + for (const child of this.state.transcriptContainer.children) { + if (isPlanExpandable(child) && child.setPlanExpanded(next)) { + toggled = true; + } + } + if (!toggled) return false; + this.state.planExpanded = next; + this.state.ui.requestRender(); + return true; + } + + // Updates the editor border color for slash command and plan-mode context. + private updateEditorBorderHighlight(text?: string): void { + const trimmed = (text ?? this.state.editor.getText()).trimStart(); + const colorToken = + this.state.appState.planMode || trimmed.startsWith('/') + ? this.state.theme.colors.primary + : this.state.theme.colors.border; + this.state.editor.borderColor = (s: string) => chalk.hex(colorToken)(s); + this.state.ui.requestRender(); + } + + // Applies a theme bundle to all stateful UI theme references. + private applyTheme(theme: Theme, resolved?: ResolvedTheme): void { + const nextTheme = createKimiTUIThemeBundle(theme, resolved); + Object.assign(this.state.theme.colors, nextTheme.colors); + this.state.theme.resolvedTheme = nextTheme.resolvedTheme; + this.state.theme.styles = nextTheme.styles; + this.state.theme.markdownTheme = nextTheme.markdownTheme; + this.setAppState({ theme }); + this.updateEditorBorderHighlight(); + this.state.ui.requestRender(true); + } + + // Starts or stops terminal theme notifications according to the user preference. + private refreshTerminalThemeTracking(): void { + this.stopTerminalThemeTracking(); + if (this.state.appState.theme !== 'auto') return; + + this.terminalThemeTrackingDispose = installTerminalThemeTracking(this.state, (resolved) => { + this.applyResolvedAutoTheme(resolved); + }); + } + + // Stops terminal theme notifications if they were enabled for auto mode. + private stopTerminalThemeTracking(): void { + this.terminalThemeTrackingDispose?.(); + this.terminalThemeTrackingDispose = undefined; + } + + // Applies a concrete terminal-reported theme while keeping the preference as auto. + private applyResolvedAutoTheme(resolved: ResolvedTheme): void { + if (this.state.appState.theme !== 'auto') return; + if (this.state.theme.resolvedTheme === resolved) return; + this.applyTheme('auto', resolved); + } + + // Determines whether the terminal should expose progress state. + private shouldShowTerminalProgress(effectiveMode: EffectiveActivityPaneMode): boolean { + if (this.state.appState.isCompacting) return true; + return ( + effectiveMode === 'waiting' || + effectiveMode === 'thinking' || + effectiveMode === 'composing' || + effectiveMode === 'tool' + ); + } + + // Syncs terminal progress only when the active flag changes. + private syncTerminalProgress(active: boolean): void { + if (this.state.terminalState.progressActive === active) return; + this.state.terminal.setProgress(active); + this.state.terminalState.progressActive = active; + } + + // Returns an activity spinner with the requested style and presentation. + private ensureActivitySpinner( + style: SpinnerStyle, + label = '', + colorFn?: (s: string) => string, + ): MoonLoader { + if (this.state.activitySpinnerStyle !== style) { + this.stopActivitySpinner(); + } + + if (this.state.activitySpinner === undefined) { + this.state.activitySpinner = new MoonLoader(this.state.ui, style, colorFn, label); + this.state.activitySpinnerStyle = style; + return this.state.activitySpinner; + } + + this.state.activitySpinner.setLabel(label); + if (colorFn !== undefined) { + this.state.activitySpinner.setColorFn(colorFn); + } + return this.state.activitySpinner; + } + + // Stops and clears the activity spinner. + private stopActivitySpinner(): void { + if (this.state.activitySpinner) { + this.state.activitySpinner.stop(); + this.state.activitySpinner = undefined; + } + this.state.activitySpinnerStyle = undefined; + } + + // ========================================================================= + // Dialogs / Selectors + // ========================================================================= + + // Replaces the editor with a focusable dialog or selector panel. + private mountEditorReplacement(panel: Component & Focusable): void { + this.state.editorContainer.clear(); + this.state.editorContainer.addChild(panel); + this.state.ui.setFocus(panel); + this.state.ui.requestRender(); + } + + // Restores the main editor after a dialog or selector closes. + private restoreEditor(): void { + this.state.editorContainer.clear(); + this.state.editorContainer.addChild(this.state.editor); + this.state.ui.setFocus(this.state.editor); + this.state.ui.requestRender(); + } + + // Runs the first-launch migration screen, if a plan was detected pre-TUI. + // Resolves with the screen's result when the user dismisses it; the editor + // is then restored. + private async runMigrationScreen(plan: MigrationPlan): Promise<MigrationScreenResult> { + const result = await new Promise<MigrationScreenResult>((resolve) => { + const screen = new MigrationScreenComponent({ + plan, + // Reuse the source path detection already resolved — the single source + // of truth — rather than re-deriving it here. + sourceHome: plan.sourceHome, + targetHome: this.harness.homeDir, + colors: this.state.theme.colors, + skipDecisionStep: this.migrateOnly, + requestRender: () => { + this.state.ui.requestRender(); + }, + onComplete: (r) => { + resolve(r); + }, + }); + this.mountEditorReplacement(screen); + }); + this.restoreEditor(); + if (result.decision === 'never') { + // Persist the skip marker `detectPendingMigration` checks, so "Never ask + // again" actually stops the prompt from reappearing every launch. + try { + writeFileSync( + join(this.harness.homeDir, '.skip-migration-from-kimi-cli'), + '', + 'utf-8', + ); + } catch { + // Non-blocking: a failed marker write must never crash startup. + } + } + return result; + } + + // Shows the help panel with the current slash command list. + private showHelpPanel(): void { + this.state.showingHelpPanel = true; + this.mountEditorReplacement( + new HelpPanelComponent({ + commands: this.getSlashCommands(), + colors: this.state.theme.colors, + onClose: () => { + this.hideHelpPanel(); + }, + }), + ); + } + + // Hides the help panel and returns focus to the editor. + private hideHelpPanel(): void { + this.state.showingHelpPanel = false; + this.restoreEditor(); + } + + // Loads sessions and shows the session picker. + private async showSessionPicker(): Promise<void> { + await this.fetchSessions(); + this.mountSessionPicker(() => { + this.hideSessionPicker(); + }); + } + + // Shows the startup session picker and exits when it is cancelled. + private async bootstrapFromPicker(): Promise<void> { + await this.fetchSessions(); + this.mountSessionPicker(() => { + this.hideSessionPicker(); + void this.stop(); + }); + } + + // Hides the session picker and restores the editor. + private hideSessionPicker(): void { + this.state.showingSessionPicker = false; + this.restoreEditor(); + } + + // Mounts a session picker with shared selection behavior. + private mountSessionPicker(onCancel: () => void): void { + this.state.showingSessionPicker = true; + this.mountEditorReplacement( + new SessionPickerComponent({ + sessions: this.state.sessions, + loading: this.state.loadingSessions, + currentSessionId: this.state.appState.sessionId, + colors: this.state.theme.colors, + onSelect: (sessionId: string) => { + void this.resumeSession(sessionId).then((switched) => { + if (switched) { + this.hideSessionPicker(); + } + }); + }, + onCancel, + }), + ); + } + + // ========================================================================= + // Background tasks browser (`/tasks`) + // ========================================================================= + + /** + * Open the `/tasks` overlay. Idempotent: a second `/tasks` while the + * panel is already open is a no-op (the focus stays on the existing + * overlay) — prevents accidental stacking. + */ + private async showTasksBrowser(): Promise<void> { + if (this.state.tasksBrowser !== undefined) return; + const session = this.session; + if (session === undefined) { + this.showError('No active session.'); + return; + } + + let tasks: readonly BackgroundTaskInfo[] = []; + try { + tasks = await session.listBackgroundTasks({ activeOnly: false }); + } catch (error) { + this.showError( + `Failed to load tasks: ${error instanceof Error ? error.message : String(error)}`, + ); + return; + } + // Race: panel might have been opened then immediately closed by + // another path while the await above was in flight. Bail out then. + if (this.state.tasksBrowser !== undefined) return; + + const filter: TasksFilter = 'all'; + const selectedTaskId = this.pickInitialSelection(tasks, filter); + const component = new TasksBrowserApp( + { + tasks, + filter, + selectedTaskId, + tailOutput: undefined, + tailLoading: false, + flashMessage: undefined, + colors: this.state.theme.colors, + ...this.buildTasksBrowserCallbacks(), + }, + this.state.terminal, + ); + + // Alt-screen takeover: save the main TUI's children, then replace + // them with this single full-screen component. `closeTasksBrowser` + // restores the original layout. Mirrors the Python `Application( + // full_screen=True, erase_when_done=True)` pattern. + const savedChildren = [...this.state.ui.children]; + this.state.ui.clear(); + this.state.ui.addChild(component); + this.state.ui.setFocus(component); + this.state.ui.requestRender(true); + + const pollTimer = setInterval(() => { + void this.refreshTasksBrowser({ silent: true }); + }, 1000); + + this.state.tasksBrowser = { + component, + savedChildren, + filter, + selectedTaskId, + tailOutput: undefined, + tailLoading: false, + tailRequestId: 0, + flashMessage: undefined, + flashTimer: undefined, + pollTimer, + viewer: undefined, + }; + + if (selectedTaskId !== undefined) { + this.loadTasksBrowserTail(selectedTaskId); + } + } + + private pickInitialSelection( + tasks: readonly BackgroundTaskInfo[], + filter: TasksFilter, + ): string | undefined { + const candidates = + filter === 'all' + ? tasks + : tasks.filter( + (t) => + t.status !== 'completed' && + t.status !== 'failed' && + t.status !== 'killed' && + t.status !== 'lost', + ); + if (candidates.length === 0) return undefined; + // Prefer the first non-terminal task; fall back to the first one. + return ( + candidates.find( + (t) => t.status === 'running' || t.status === 'awaiting_approval', + )?.taskId ?? candidates[0]!.taskId + ); + } + + private async refreshTasksBrowser(opts: { silent?: boolean } = {}): Promise<void> { + const browser = this.state.tasksBrowser; + if (browser === undefined) return; + const session = this.session; + if (session === undefined) return; + + let tasks: readonly BackgroundTaskInfo[]; + try { + tasks = await session.listBackgroundTasks({ activeOnly: false }); + } catch (error) { + if (!opts.silent) { + this.flashTasksBrowser( + `Refresh failed: ${error instanceof Error ? error.message : String(error)}`, + ); + } + return; + } + if (this.state.tasksBrowser !== browser) return; + this.pushTasksBrowserProps(tasks); + } + + private pushTasksBrowserProps(tasks: readonly BackgroundTaskInfo[]): void { + const browser = this.state.tasksBrowser; + if (browser === undefined) return; + browser.component.setProps({ + tasks, + filter: browser.filter, + selectedTaskId: browser.selectedTaskId, + tailOutput: browser.tailOutput, + tailLoading: browser.tailLoading, + flashMessage: browser.flashMessage, + colors: this.state.theme.colors, + ...this.buildTasksBrowserCallbacks(), + }); + this.state.ui.requestRender(); + } + + /** Callback bundle for `TasksBrowserComponent`. Single source of truth. */ + private buildTasksBrowserCallbacks(): { + onSelect: (taskId: string) => void; + onToggleFilter: () => void; + onRefresh: () => void; + onCancel: () => void; + onStopConfirmed: (taskId: string) => void; + onOpenOutput: (taskId: string) => void; + onStopIgnored: (taskId: string, reason: 'terminal') => void; + } { + return { + onSelect: (taskId) => { + this.handleTasksBrowserSelect(taskId); + }, + onToggleFilter: () => { + this.handleTasksBrowserToggleFilter(); + }, + onRefresh: () => { + this.handleTasksBrowserRefresh(); + }, + onCancel: () => { + this.closeTasksBrowser(); + }, + onStopConfirmed: (taskId) => { + void this.handleTasksBrowserStop(taskId); + }, + onOpenOutput: (taskId) => { + void this.handleTasksBrowserOpenOutput(taskId); + }, + onStopIgnored: (taskId, reason) => { + if (reason === 'terminal') { + this.flashTasksBrowser(`${taskId} is already terminal — nothing to stop.`); + } + }, + }; + } + + private handleTasksBrowserSelect(taskId: string): void { + const browser = this.state.tasksBrowser; + if (browser === undefined) return; + if (browser.selectedTaskId === taskId) return; + browser.selectedTaskId = taskId; + browser.tailOutput = undefined; + browser.tailLoading = true; + this.repaintTasksBrowser(); + this.loadTasksBrowserTail(taskId); + } + + private handleTasksBrowserToggleFilter(): void { + const browser = this.state.tasksBrowser; + if (browser === undefined) return; + browser.filter = browser.filter === 'all' ? 'active' : 'all'; + this.repaintTasksBrowser(); + } + + private handleTasksBrowserRefresh(): void { + this.flashTasksBrowser('Refreshing…', 600); + void this.refreshTasksBrowser(); + } + + /** + * Re-render the `/tasks` panel from the in-memory BPM store (no RPC + * fetch). Safe to call when the panel is closed (no-op). Use this + * after any local state change — selection, filter, flash message, + * or an incoming `background.task.*` event — so the UI stays in sync. + * Use `refreshTasksBrowser` instead when you also want fresh data + * from the agent (e.g. a manual `R refresh`). + */ + private repaintTasksBrowser(): void { + const browser = this.state.tasksBrowser; + if (browser === undefined) return; + const tasks = [...this.state.backgroundTasks.values()]; + this.pushTasksBrowserProps(tasks); + } + + private loadTasksBrowserTail(taskId: string): void { + const browser = this.state.tasksBrowser; + if (browser === undefined) return; + const session = this.session; + if (session === undefined) { + browser.tailLoading = false; + this.repaintTasksBrowser(); + return; + } + const requestId = ++browser.tailRequestId; + void session + .getBackgroundTaskOutput(taskId, { tail: 4000 }) + .then((output) => { + const current = this.state.tasksBrowser; + if (current === undefined) return; + if (current !== browser || current.tailRequestId !== requestId) return; + if (current.selectedTaskId !== taskId) return; + current.tailOutput = output; + current.tailLoading = false; + this.repaintTasksBrowser(); + }) + .catch(() => { + const current = this.state.tasksBrowser; + if (current === undefined) return; + if (current !== browser || current.tailRequestId !== requestId) return; + if (current.selectedTaskId !== taskId) return; + current.tailOutput = ''; + current.tailLoading = false; + this.repaintTasksBrowser(); + }); + } + + private flashTasksBrowser(message: string, durationMs = 2500): void { + const browser = this.state.tasksBrowser; + if (browser === undefined) return; + if (browser.flashTimer !== undefined) clearTimeout(browser.flashTimer); + browser.flashMessage = message; + browser.flashTimer = setTimeout(() => { + const current = this.state.tasksBrowser; + if (current !== browser) return; + current.flashMessage = undefined; + current.flashTimer = undefined; + this.repaintTasksBrowser(); + }, durationMs); + this.repaintTasksBrowser(); + } + + private async handleTasksBrowserStop(taskId: string): Promise<void> { + const browser = this.state.tasksBrowser; + if (browser === undefined) return; + const session = this.session; + if (session === undefined) { + this.flashTasksBrowser('No active session.'); + return; + } + this.flashTasksBrowser(`Stopping ${taskId}…`, 1500); + try { + await session.stopBackgroundTask(taskId, { reason: 'stopped from /tasks' }); + // Force a refresh so the row flips to `killed` immediately. The + // `background.task.terminated` event will repaint again shortly. + await this.refreshTasksBrowser({ silent: true }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + this.flashTasksBrowser(`Stop failed: ${message}`); + } + } + + private async handleTasksBrowserOpenOutput(taskId: string): Promise<void> { + const browser = this.state.tasksBrowser; + if (browser === undefined) return; + if (browser.viewer !== undefined) return; // already viewing + const session = this.session; + if (session === undefined) { + this.flashTasksBrowser('No active session.'); + return; + } + + let output: string; + try { + output = await session.getBackgroundTaskOutput(taskId); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + this.flashTasksBrowser(`Cannot open output: ${message}`); + return; + } + // Race: panel might have been closed while the await was in flight. + const current = this.state.tasksBrowser; + if (current === undefined || current !== browser) return; + + const info = this.state.backgroundTasks.get(taskId); + const viewer = new TaskOutputViewer( + { + taskId, + info, + output, + colors: this.state.theme.colors, + onClose: () => { + this.closeTaskOutputViewer(); + }, + }, + this.state.terminal, + ); + + // Nested takeover: save the TasksBrowser layer (which itself is a + // single-child swap of the main TUI), then put the viewer in its + // place. `closeTaskOutputViewer` reverses this without touching the + // outer "main TUI ↔ TasksBrowser" swap state. + const savedBrowserChildren = [...this.state.ui.children]; + this.state.ui.clear(); + this.state.ui.addChild(viewer); + this.state.ui.setFocus(viewer); + this.state.ui.requestRender(true); + + // Live-tail: keep re-fetching the output every second so the viewer + // shows new content as the task writes it. The viewer itself decides + // whether to follow the tail or preserve scroll position. + const pollTimer = setInterval(() => { + void this.refreshTaskOutputViewer({ silent: true }); + }, 1000); + + browser.viewer = { + component: viewer, + savedChildren: savedBrowserChildren, + taskId, + output, + refreshId: 0, + pollTimer, + }; + } + + /** + * Re-fetch the current viewer task's output and push it into the + * component. Safe to call when the viewer is closed (no-op). Stale + * responses (issued before a more recent call) are discarded via the + * monotonically-increasing `refreshId`. + */ + private async refreshTaskOutputViewer(opts: { silent?: boolean } = {}): Promise<void> { + const browser = this.state.tasksBrowser; + const viewer = browser?.viewer; + if (browser === undefined || viewer === undefined) return; + const session = this.session; + if (session === undefined) return; + + const myRefreshId = ++viewer.refreshId; + let output: string; + try { + output = await session.getBackgroundTaskOutput(viewer.taskId); + } catch (error) { + if (!opts.silent) { + const message = error instanceof Error ? error.message : String(error); + this.flashTasksBrowser(`Output refresh failed: ${message}`); + } + return; + } + // If the viewer was closed or another refresh raced ahead, drop this result. + const current = this.state.tasksBrowser?.viewer; + if (current === undefined || current !== viewer || current.refreshId !== myRefreshId) { + return; + } + // Skip the setProps round-trip when nothing changed — keeps the + // differential renderer from re-emitting the same frame. + if (output === viewer.output) return; + viewer.output = output; + const info = this.state.backgroundTasks.get(viewer.taskId); + viewer.component.setProps({ + taskId: viewer.taskId, + info, + output, + colors: this.state.theme.colors, + onClose: () => { + this.closeTaskOutputViewer(); + }, + }); + this.state.ui.requestRender(); + } + + private closeTaskOutputViewer(): void { + const browser = this.state.tasksBrowser; + if (browser === undefined || browser.viewer === undefined) return; + const viewer = browser.viewer; + clearInterval(viewer.pollTimer); + browser.viewer = undefined; + this.state.ui.clear(); + for (const child of viewer.savedChildren) { + this.state.ui.addChild(child); + } + this.state.ui.setFocus(browser.component); + this.state.ui.requestRender(true); + } + + private closeTasksBrowser(): void { + const browser = this.state.tasksBrowser; + if (browser === undefined) return; + // If the output viewer is open, fold it back before tearing down + // the browser so the saved-children stack stays consistent. + if (browser.viewer !== undefined) this.closeTaskOutputViewer(); + if (browser.pollTimer !== undefined) clearInterval(browser.pollTimer); + if (browser.flashTimer !== undefined) clearTimeout(browser.flashTimer); + + // Restore the main TUI's children we saved when opening. After + // clearing, re-add in original order, then return focus to the + // editor so the user is back at the prompt. + this.state.ui.clear(); + for (const child of browser.savedChildren) { + this.state.ui.addChild(child); + } + this.state.tasksBrowser = undefined; + this.state.ui.setFocus(this.state.editor); + this.state.ui.requestRender(true); + } + + // Shows the editor command selector. + private showEditorPicker(): void { + const currentValue = this.state.appState.editorCommand ?? ''; + this.mountEditorReplacement( + new EditorSelectorComponent({ + currentValue, + colors: this.state.theme.colors, + onSelect: (value) => { + this.restoreEditor(); + void this.applyEditorChoice(value); + }, + onCancel: () => { + this.restoreEditor(); + }, + }), + ); + } + + // Persists and applies the selected external editor command. + private async applyEditorChoice(value: string): Promise<void> { + const previous = this.state.appState.editorCommand ?? ''; + if (value === previous && value.length > 0) { + this.showStatus(`Editor unchanged: ${value.length > 0 ? value : 'auto-detect'}`); + return; + } + + const editorCommand = value.length > 0 ? value : null; + try { + await saveTuiConfig({ + theme: this.state.appState.theme, + editorCommand, + notifications: this.state.appState.notifications, + }); + } catch (error) { + this.showStatus( + `Failed to save editor: ${formatErrorMessage(error)}`, + this.state.theme.colors.error, + ); + return; + } + + this.setAppState({ editorCommand }); + this.showStatus( + value.length > 0 + ? `Editor set to "${value}".` + : 'Editor set to auto-detect ($VISUAL / $EDITOR).', + ); + } + + // Shows the model selector when models are available. + private showModelPicker(selectedValue: string = this.state.appState.model): void { + const entries = Object.entries(this.state.appState.availableModels); + if (entries.length === 0) { + this.showError('No models configured.'); + return; + } + this.mountEditorReplacement( + new ModelSelectorComponent({ + models: this.state.appState.availableModels, + currentValue: this.state.appState.model, + selectedValue, + currentThinking: this.state.appState.thinking, + colors: this.state.theme.colors, + onSelect: ({ alias, thinking }) => { + this.restoreEditor(); + void this.performModelSwitch(alias, thinking); + }, + onCancel: () => { + this.restoreEditor(); + }, + }), + ); + } + + // Applies model and thinking changes to the active or newly created session. + private async performModelSwitch(alias: string, thinking: boolean): Promise<void> { + if (this.state.appState.isStreaming) { + this.showError('Cannot switch models while streaming — press Esc or Ctrl-C first.'); + return; + } + + if (alias === this.state.appState.model && thinking === this.state.appState.thinking) { + this.showStatus(`Already using ${alias} with thinking ${thinking ? 'on' : 'off'}.`); + return; + } + + const level = thinking ? 'on' : 'off'; + const prevModel = this.state.appState.model; + const prevThinking = this.state.appState.thinking; + + try { + const session = this.session; + if (session === undefined) { + await this.activateModelAfterLogin(alias, thinking); + } else { + if (alias !== prevModel) { + await session.setModel(alias); + } + if (thinking !== prevThinking) { + await session.setThinking(level); + } + } + this.setAppState({ model: alias, thinking }); + if (session === undefined) { + if (alias !== prevModel) { + this.track('model_switch', { model: alias }); + } + if (thinking !== prevThinking) { + this.track('thinking_toggle', { enabled: thinking }); + } + } + this.showStatus( + `Switched to ${alias} with thinking ${level}.`, + this.state.theme.colors.success, + ); + } catch (error) { + const msg = formatErrorMessage(error); + this.showError(`Failed to switch model: ${msg}`); + } + } + + // Shows the theme selector. + private showThemePicker(): void { + this.mountEditorReplacement( + new ThemeSelectorComponent({ + currentValue: this.state.appState.theme, + colors: this.state.theme.colors, + onSelect: (value) => { + this.restoreEditor(); + void this.applyThemeChoice(value); + }, + onCancel: () => { + this.restoreEditor(); + }, + }), + ); + } + + // Shows the permission mode selector. + private showPermissionPicker(): void { + this.mountEditorReplacement( + new PermissionSelectorComponent({ + currentValue: this.state.appState.permissionMode, + colors: this.state.theme.colors, + onSelect: (value) => { + this.restoreEditor(); + void this.applyPermissionChoice(value); + }, + onCancel: () => { + this.restoreEditor(); + }, + }), + ); + } + + // Shows the settings selector entry point. + private showSettingsSelector(): void { + this.mountEditorReplacement( + new SettingsSelectorComponent({ + colors: this.state.theme.colors, + onSelect: (value) => { + this.handleSettingsSelection(value); + }, + onCancel: () => { + this.restoreEditor(); + }, + }), + ); + } + + // Routes a settings selection to the matching selector or panel. + private handleSettingsSelection(value: SettingsSelection): void { + this.restoreEditor(); + switch (value) { + case 'model': + this.showModelPicker(); + return; + case 'permission': + this.showPermissionPicker(); + return; + case 'theme': + this.showThemePicker(); + return; + case 'editor': + this.showEditorPicker(); + return; + case 'usage': + void this.showUsage(); + return; + } + } + + // Applies a permission mode choice to the active session and app state. + private async applyPermissionChoice(mode: PermissionMode): Promise<void> { + if (mode === this.state.appState.permissionMode) { + this.showStatus(`Permission mode unchanged: ${mode}.`); + return; + } + + try { + await this.requireSession().setPermission(mode); + } catch (error) { + const msg = formatErrorMessage(error); + this.showError(`Failed to set permission mode: ${msg}`); + return; + } + + this.setAppState({ permissionMode: mode, yolo: mode === 'yolo' }); + this.showNotice(`Permission mode: ${mode}`); + } + + // Persists and applies a theme choice. + private async applyThemeChoice(theme: Theme): Promise<void> { + if (theme === this.state.appState.theme) { + if (theme === 'auto') this.refreshTerminalThemeTracking(); + this.showStatus(`Theme unchanged: "${theme}".`); + return; + } + + try { + await saveTuiConfig({ + theme, + editorCommand: this.state.appState.editorCommand, + notifications: this.state.appState.notifications, + }); + } catch (error) { + this.showStatus( + `Failed to save theme: ${formatErrorMessage(error)}`, + this.state.theme.colors.error, + ); + return; + } + + const resolved = theme === 'auto' ? this.state.theme.resolvedTheme : theme; + this.applyTheme(theme, resolved); + this.refreshTerminalThemeTracking(); + this.track('theme_switch', { theme }); + const detail = theme === 'auto' ? ` (tracking terminal; current: ${resolved})` : ''; + this.showStatus(`Theme set to "${theme}"${detail}.`); + } + + // Loads and renders current usage information. + private async showUsage(): Promise<void> { + const sessionUsage = await this.loadSessionUsageReport(); + const managedUsage = await this.loadManagedUsageReport(); + const lines = buildUsageReportLines({ + colors: this.state.theme.colors, + sessionUsage: sessionUsage.usage, + sessionUsageError: sessionUsage.error, + contextUsage: this.state.appState.contextUsage, + contextTokens: this.state.appState.contextTokens, + maxContextTokens: this.state.appState.maxContextTokens, + managedUsage: managedUsage?.usage, + managedUsageError: managedUsage?.error, + }); + const panel = new UsagePanelComponent(lines, this.state.theme.colors.primary); + this.state.transcriptContainer.addChild(panel); + this.state.ui.requestRender(); + } + + // Loads and renders current runtime status. + private async showStatusReport(): Promise<void> { + const [runtimeStatus, managedUsage] = await Promise.all([ + this.loadRuntimeStatusReport(), + this.loadManagedUsageReport(), + ]); + const appState = this.state.appState; + const lines = buildStatusReportLines({ + colors: this.state.theme.colors, + version: appState.version, + model: appState.model, + workDir: appState.workDir, + sessionId: appState.sessionId, + sessionTitle: appState.sessionTitle, + thinking: appState.thinking, + permissionMode: appState.permissionMode, + planMode: appState.planMode, + contextUsage: appState.contextUsage, + contextTokens: appState.contextTokens, + maxContextTokens: appState.maxContextTokens, + availableModels: appState.availableModels, + status: runtimeStatus.status, + statusError: runtimeStatus.error, + managedUsage: managedUsage?.usage, + managedUsageError: managedUsage?.error, + }); + const panel = new UsagePanelComponent(lines, this.state.theme.colors.primary, ' Status '); + this.state.transcriptContainer.addChild(panel); + this.state.ui.requestRender(); + } + + // Loads and renders current MCP server status. + private async showMcpServers(): Promise<void> { + let servers: readonly McpServerInfo[]; + try { + servers = await this.requireSession().listMcpServers(); + } catch (error) { + this.showError(`Failed to load MCP servers: ${formatErrorMessage(error)}`); + return; + } + + const lines = buildMcpStatusReportLines({ + colors: this.state.theme.colors, + servers, + }); + const title = servers.length > 0 ? ` MCP (${servers.length}) ` : ' MCP '; + const panel = new UsagePanelComponent(lines, this.state.theme.colors.primary, title); + this.state.transcriptContainer.addChild(panel); + this.state.ui.requestRender(); + } + + // Loads per-session usage and captures displayable errors. + private async loadSessionUsageReport(): Promise<SessionUsageResult> { + try { + return { usage: await this.requireSession().getUsage() }; + } catch (error) { + return { error: formatErrorMessage(error) }; + } + } + + // Loads per-session runtime status and captures displayable errors. + private async loadRuntimeStatusReport(): Promise<RuntimeStatusResult> { + try { + return { status: await this.requireSession().getStatus() }; + } catch (error) { + return { error: error instanceof Error ? error.message : String(error) }; + } + } + + // Loads managed-provider usage when the active model supports it. + private async loadManagedUsageReport(): Promise<ManagedUsageResult | undefined> { + const alias = this.state.appState.model; + const providerKey = this.state.appState.availableModels[alias]?.provider; + if (!isManagedUsageProvider(providerKey)) return undefined; + + let res; + try { + res = await this.harness.auth.getManagedUsage(providerKey); + } catch (error) { + return { error: formatErrorMessage(error) }; + } + if (res.kind === 'error') { + return { error: res.message }; + } + return { usage: { summary: res.summary, limits: res.limits } }; + } + + // Shows an approval panel and connects its response callback. + private showApprovalPanel(payload: ApprovalPanelData): void { + this.patchLivePane({ pendingApproval: { data: payload } }); + notifyTerminalOnce(this.state, `approval:${payload.id}`, { + title: 'Kimi Code approval required', + body: payload.tool_name, + }); + const panel = new ApprovalPanelComponent( + { data: payload }, + (response: ApprovalPanelResponse) => { + this.approvalController.respond(adaptPanelResponse(response)); + }, + this.state.theme.colors, + () => { + this.toggleToolOutputExpansion(); + }, + () => { + this.togglePlanExpansion(); + }, + ); + this.mountEditorReplacement(panel); + } + + // Hides the active approval panel. + private hideApprovalPanel(): void { + this.patchLivePane({ pendingApproval: null }); + this.restoreEditor(); + } + + // Shows a question dialog and connects its response callback. + private showQuestionDialog(payload: QuestionPanelData): void { + this.patchLivePane({ pendingQuestion: { data: payload } }); + notifyTerminalOnce(this.state, `question:${payload.id}`, { + title: 'Kimi Code needs your answer', + body: payload.questions[0]?.question, + }); + const dialog = new QuestionDialogComponent( + { data: payload }, + (response) => { + this.questionController.respond(response); + }, + this.state.theme.colors, + undefined, + () => { + this.toggleToolOutputExpansion(); + }, + () => { + this.togglePlanExpansion(); + }, + ); + this.mountEditorReplacement(dialog); + } + + // Hides the active question dialog. + private hideQuestionDialog(): void { + this.patchLivePane({ pendingQuestion: null }); + this.restoreEditor(); + } + + // ========================================================================= + // Slash Command Handlers + // ========================================================================= + + // Applies plan mode through the session and mirrors it into UI state. + private async applyPlanMode(session: Session, enabled: boolean): Promise<void> { + try { + await session.setPlanMode(enabled); + this.setAppState({ planMode: enabled }); + if (enabled) { + const plan = await session.getPlan().catch(() => null); + this.showNotice( + 'Plan mode: ON', + plan?.path !== undefined ? `Plan will be created here: ${plan.path}` : undefined, + ); + return; + } + this.showNotice('Plan mode: OFF'); + } catch (error) { + const msg = formatErrorMessage(error); + this.showError(`Failed to set plan mode: ${msg}`); + } + } + + // Handles the /editor command. + private async handleEditorCommand(args: string, _eCtx: {}): Promise<void> { + const command = args.trim(); + if (command.length === 0) { + this.showEditorPicker(); + return; + } + await this.applyEditorChoice(command); + } + + // Handles the /theme command. + private async handleThemeCommand(args: string): Promise<void> { + const theme = args.trim(); + if (theme.length === 0) { + this.showThemePicker(); + return; + } + if (!isTheme(theme)) { + this.showError(`Unknown theme: ${theme}`); + return; + } + await this.applyThemeChoice(theme); + } + + // Handles the /model command. + private handleModelCommand(args: string): void { + const alias = args.trim(); + if (alias.length === 0) { + this.showModelPicker(); + return; + } + if (this.state.appState.availableModels[alias] === undefined) { + this.showError(`Unknown model alias: ${alias}`); + return; + } + this.showModelPicker(alias); + } + + // Handles the /title command. + private async handleTitleCommand(args: string): Promise<void> { + const title = args.trim(); + if (title.length === 0) { + const current = this.state.appState.sessionTitle; + this.showStatus( + current !== null && current.length > 0 + ? `Session title: ${current}` + : `Session title: (not set) — id: ${this.state.appState.sessionId}`, + ); + return; + } + + const session = this.session; + if (session === undefined) { + this.showError(NO_ACTIVE_SESSION_MESSAGE); + return; + } + + const newTitle = title.slice(0, 200); + try { + await this.harness.renameSession({ id: session.id, title: newTitle }); + } catch (error) { + const msg = formatErrorMessage(error); + this.showError(`Failed to set title: ${msg}`); + return; + } + this.showStatus(`Session title set to: ${newTitle}`); + } + + // Handles the /fork command. + private async handleForkCommand(args: string): Promise<void> { + void args; + + const session = this.session; + if (session === undefined) { + this.showError(NO_ACTIVE_SESSION_MESSAGE); + return; + } + + const sourceTitle = this.forkSourceTitle(session); + let forked: Session; + try { + forked = await this.harness.forkSession({ + id: session.id, + title: `Fork: ${sourceTitle}`, + }); + } catch (error) { + const msg = formatErrorMessage(error); + this.showError(`Failed to fork session: ${msg}`); + return; + } + + try { + await this.switchToSession(forked, `Session forked (${forked.id}).`); + } catch (error) { + const msg = formatErrorMessage(error); + this.showError(`Failed to switch to forked session: ${msg}`); + } + } + + private forkSourceTitle(session: Session): string { + const currentTitle = this.state.appState.sessionTitle?.trim(); + if (currentTitle !== undefined && currentTitle.length > 0) return currentTitle; + + const summaryTitle = + typeof session.summary?.title === 'string' ? session.summary.title.trim() : ''; + return summaryTitle.length > 0 ? summaryTitle : session.id; + } + + // Handles the /yolo command. + private async handleYoloCommand(args: string): Promise<void> { + const session = this.session; + if (session === undefined) { + this.showError(NO_ACTIVE_SESSION_MESSAGE); + return; + } + + let enabled: boolean; + if (args === 'on') enabled = true; + else if (args === 'off') enabled = false; + else enabled = !this.state.appState.yolo; + + await session.setPermission(enabled ? 'yolo' : 'manual'); + this.setAppState({ yolo: enabled, permissionMode: enabled ? 'yolo' : 'manual' }); + if (enabled) { + this.showNotice( + 'YOLO mode: ON', + 'All actions will be approved automatically. Use with caution.', + ); + return; + } + this.showNotice('YOLO mode: OFF'); + } + + // Handles the /plan command. + private async handlePlanCommand(args: string): Promise<void> { + const session = this.session; + if (session === undefined) { + this.showError(NO_ACTIVE_SESSION_MESSAGE); + return; + } + + const subcmd = args.trim().toLowerCase(); + if (subcmd === 'clear') { + await session.clearPlan(); + this.showNotice('Plan cleared'); + return; + } + + let enabled: boolean; + if (subcmd.length === 0) enabled = !this.state.appState.planMode; + else if (subcmd === 'on') enabled = true; + else if (subcmd === 'off') enabled = false; + else { + this.showError(`Unknown plan subcommand: ${subcmd}`); + return; + } + + await this.applyPlanMode(session, enabled); + } + + // Handles the /compact command. + private async handleCompactCommand(args: string): Promise<void> { + const session = this.session; + if (session === undefined) { + this.showError(NO_ACTIVE_SESSION_MESSAGE); + return; + } + + const customInstruction = args.trim() || undefined; + await session.compact({ instruction: customInstruction }); + } + + // Handles the /init command. + private async handleInitCommand(): Promise<void> { + const session = this.session; + if (this.state.appState.model.trim().length === 0 || session === undefined) { + this.showError(LLM_NOT_SET_MESSAGE); + return; + } + + this.deferUserMessages = true; + this.beginSessionRequest(); + try { + await session.init(); + this.track('init_complete'); + this.finalizeTurn((item) => { + this.sendQueuedMessage(session, item); + }); + } catch (error) { + if (isAbortError(error)) { + this.setAppState({ isStreaming: false, streamingPhase: 'idle' }); + this.resetLivePane(); + return; + } + const msg = error instanceof Error ? error.message : String(error); + this.failSessionRequest(`Init failed: ${msg}`); + } finally { + this.deferUserMessages = false; + } + } + + // Handles the /login command. + private async handleLoginCommand(): Promise<void> { + const platformId = await this.promptPlatformSelection(); + if (platformId === undefined) return; + + if (platformId === 'kimi-code') { + await this.handleKimiCodeOAuthLogin(); + return; + } + + const platform = getOpenPlatformById(platformId); + if (platform === undefined) return; + await this.handleOpenPlatformLogin(platform); + } + + // Kimi Code OAuth login flow. + private async handleKimiCodeOAuthLogin(): Promise<void> { + const status = await this.harness.auth.status(DEFAULT_OAUTH_PROVIDER_NAME); + const alreadyLoggedIn = status.providers.some( + (provider) => provider.providerName === DEFAULT_OAUTH_PROVIDER_NAME && provider.hasToken, + ); + + let spinner: LoginProgressSpinnerHandle | undefined; + const controller = new AbortController(); + const cancelLogin = (): void => { + controller.abort(); + }; + this.cancelInFlight = cancelLogin; + try { + await this.harness.auth.login(DEFAULT_OAUTH_PROVIDER_NAME, { + signal: controller.signal, + onDeviceCode: (data) => { + spinner = this.showLoginAuthorizationPrompt(data); + }, + }); + spinner?.stop({ ok: true, label: 'Logged in.' }); + spinner = undefined; + try { + await this.refreshConfigAfterLogin(); + } catch (refreshError) { + const message = formatErrorMessage(refreshError); + this.showError(`Authentication successful, but failed to refresh config: ${message}`); + return; + } + this.track('login', { + provider: DEFAULT_OAUTH_PROVIDER_NAME, + already_logged_in: alreadyLoggedIn, + }); + if (alreadyLoggedIn) { + this.showStatus('Already logged in. Model configuration refreshed.'); + } + } catch (error) { + const cancelled = controller.signal.aborted; + spinner?.stop({ + ok: false, + label: cancelled ? 'Login cancelled.' : 'Login failed.', + }); + spinner = undefined; + if (cancelled) return; + log.warn('login failed', { + providerName: DEFAULT_OAUTH_PROVIDER_NAME, + alreadyLoggedIn, + sessionId: this.session?.id, + error, + }); + const message = formatErrorMessage(error); + this.showError(`Login failed: ${message}`); + } finally { + if (this.cancelInFlight === cancelLogin) { + this.cancelInFlight = undefined; + } + } + } + + // Open platform API key login flow. + private async handleOpenPlatformLogin( + platform: OpenPlatformDefinition, + ): Promise<void> { + const apiKey = await this.promptApiKey(platform.name); + if (apiKey === undefined) return; + + const controller = new AbortController(); + const cancelLogin = (): void => { + controller.abort(); + }; + this.cancelInFlight = cancelLogin; + + let models: ManagedKimiCodeModelInfo[]; + try { + models = await fetchOpenPlatformModels(platform, apiKey, fetch, controller.signal); + models = filterModelsByPrefix(models, platform); + } catch (error) { + if (controller.signal.aborted) return; + const msg = formatErrorMessage(error); + this.showError(`Failed to verify API key: ${msg}`); + if ( + error instanceof OpenPlatformApiError && + error.status === 401 + ) { + this.showStatus( + 'Hint: If your API key was obtained from Kimi Code, please select "Kimi Code" instead.', + ); + } + return; + } finally { + if (this.cancelInFlight === cancelLogin) { + this.cancelInFlight = undefined; + } + } + + if (models.length === 0) { + this.showError('No models available for this platform.'); + return; + } + + const selection = await this.promptModelSelectionForOpenPlatform(models, platform); + if (selection === undefined) return; + + // Remove stale provider config first so old model aliases are fully + // cleared (setConfig patch merge cannot delete nested keys). + const existingConfig = await this.harness.getConfig(); + if (existingConfig.providers[platform.id] !== undefined) { + await this.harness.removeProvider(platform.id); + } + + const config = await this.harness.getConfig(); + applyOpenPlatformConfig(config as ManagedKimiConfigShape, { + platform, + models, + selectedModel: selection.model, + thinking: selection.thinking, + apiKey, + }); + + await this.harness.setConfig({ + providers: config.providers, + models: config.models, + defaultModel: config.defaultModel, + defaultThinking: config.defaultThinking, + }); + + await this.refreshConfigAfterLogin(); + this.track('login', { provider: platform.id, method: 'api_key' }); + this.showStatus(`Setup complete: ${platform.name} · ${selection.model.id}`); + } + + // Handles the /feedback command — opens an inline input dialog and POSTs + // the result to the managed Kimi Code platform. Falls back to the GitHub + // Issues page when the user is not signed in or the request fails. + private async handleFeedbackCommand(): Promise<void> { + const fallback = (reason: string): void => { + this.showStatus(reason); + this.showStatus(FEEDBACK_ISSUE_URL); + openUrl(FEEDBACK_ISSUE_URL); + }; + + const providerKey = this.state.appState.availableModels[this.state.appState.model]?.provider; + if (!isManagedUsageProvider(providerKey)) { + fallback(FEEDBACK_STATUS_NOT_SIGNED_IN); + return; + } + + const content = await this.promptFeedbackInput(); + if (content === undefined) { + this.showStatus(FEEDBACK_STATUS_CANCELLED); + return; + } + + const spinner = this.showLoginProgressSpinner(FEEDBACK_STATUS_SUBMITTING); + const res = await this.harness.auth.submitFeedback({ + content, + sessionId: this.state.appState.sessionId, + version: withFeedbackVersionPrefix(this.state.appState.version), + os: `${osType()} ${osRelease()}`, + model: this.state.appState.model.length > 0 ? this.state.appState.model : null, + }); + + if (res.kind === 'ok') { + spinner.stop({ ok: true, label: FEEDBACK_STATUS_SUCCESS }); + this.showStatus(feedbackSessionLine(this.state.appState.sessionId)); + this.track(FEEDBACK_TELEMETRY_EVENT); + return; + } + + const failLabel = + res.status !== undefined + ? feedbackHttpErrorMessage(res.status) + : FEEDBACK_STATUS_NETWORK_ERROR; + spinner.stop({ ok: false, label: failLabel }); + fallback(FEEDBACK_STATUS_FALLBACK); + } + + // Mounts the feedback input dialog and resolves with the trimmed value + // when submitted, or undefined when the user cancels. + private promptFeedbackInput(): Promise<string | undefined> { + return new Promise((resolve) => { + const dialog = new FeedbackInputDialogComponent((result: FeedbackInputDialogResult) => { + this.restoreEditor(); + resolve(result.kind === 'ok' ? result.value : undefined); + }, this.state.theme.colors); + this.mountEditorReplacement(dialog); + }); + } + + // Handles the /logout command. + private async handleLogoutCommand(): Promise<void> { + const currentModel = this.state.appState.model.trim(); + const currentProvider = this.state.appState.availableModels[currentModel]?.provider; + + if (currentProvider === undefined || currentProvider === DEFAULT_OAUTH_PROVIDER_NAME) { + await this.harness.auth.logout(DEFAULT_OAUTH_PROVIDER_NAME); + await this.refreshConfigAfterLogout(); + await this.clearActiveSessionAfterLogout(); + this.track('logout', { provider: DEFAULT_OAUTH_PROVIDER_NAME }); + this.showStatus('Logged out.'); + return; + } + + if (isOpenPlatformId(currentProvider)) { + await this.harness.removeProvider(currentProvider); + await this.refreshConfigAfterLogout(); + await this.clearActiveSessionAfterLogout(); + this.track('logout', { provider: currentProvider }); + this.showStatus(`Logged out from ${currentProvider}.`); + return; + } + + this.showStatus('Nothing to logout.'); + } + + // --------------------------------------------------------------------------- + // Login / setup prompts + // --------------------------------------------------------------------------- + + private promptPlatformSelection(): Promise<string | undefined> { + return new Promise((resolve) => { + const selector = new PlatformSelectorComponent({ + colors: this.state.theme.colors, + onSelect: (platformId) => { + this.restoreEditor(); + resolve(platformId); + }, + onCancel: () => { + this.restoreEditor(); + resolve(undefined); + }, + }); + this.mountEditorReplacement(selector); + }); + } + + private promptApiKey(platformName: string): Promise<string | undefined> { + return new Promise((resolve) => { + const dialog = new ApiKeyInputDialogComponent( + platformName, + (result: ApiKeyInputResult) => { + this.restoreEditor(); + resolve(result.kind === 'ok' ? result.value : undefined); + }, + this.state.theme.colors, + ); + this.mountEditorReplacement(dialog); + }); + } + + private promptModelSelectionForOpenPlatform( + models: ManagedKimiCodeModelInfo[], + platform: OpenPlatformDefinition, + ): Promise<{ model: ManagedKimiCodeModelInfo; thinking: boolean } | undefined> { + return new Promise((resolve) => { + const modelDict: Record<string, ModelAlias> = {}; + for (const m of models) { + const alias = `${platform.id}/${m.id}`; + modelDict[alias] = { + provider: platform.id, + model: m.id, + maxContextSize: m.contextLength, + capabilities: capabilitiesForModel(m), + displayName: m.displayName, + }; + } + + const firstAlias = Object.keys(modelDict)[0] ?? ''; + const firstModel = modelDict[firstAlias]; + const initialThinking = (() => { + const caps = firstModel?.capabilities ?? []; + return caps.includes('always_thinking') || caps.includes('thinking'); + })(); + + const selector = new ModelSelectorComponent({ + models: modelDict, + currentValue: firstAlias, + currentThinking: initialThinking, + colors: this.state.theme.colors, + onSelect: ({ alias, thinking }) => { + this.restoreEditor(); + const model = models.find((m) => `${platform.id}/${m.id}` === alias); + resolve(model ? { model, thinking } : undefined); + }, + onCancel: () => { + this.restoreEditor(); + resolve(undefined); + }, + }); + this.mountEditorReplacement(selector); + }); + } +} + +function formatHookResultMarkdown(event: HookResultEvent): string { + return `*${formatHookResultTitle(event)}*\n\n${formatHookResultBody(event)}`; +} + +function formatHookResultPlain(event: HookResultEvent): string { + return `${formatHookResultTitle(event)}\n\n${formatHookResultBody(event)}`; +} + +function formatHookResultTitle(event: HookResultEvent): string { + return `${event.hookEvent} hook${event.blocked === true ? ' blocked' : ''}`; +} + +function formatHookResultBody(event: HookResultEvent): string { + const content = event.content.trim(); + return content.length === 0 ? '(empty)' : content; +} diff --git a/apps/kimi-code/src/tui/reverse-rpc/approval/adapter.ts b/apps/kimi-code/src/tui/reverse-rpc/approval/adapter.ts new file mode 100644 index 000000000..c53cce753 --- /dev/null +++ b/apps/kimi-code/src/tui/reverse-rpc/approval/adapter.ts @@ -0,0 +1,339 @@ +import type { ApprovalRequest, ApprovalResponse, ToolInputDisplay } from '@moonshot-ai/kimi-code-sdk'; + +import type { ApprovalPanelResponse } from '#/tui/components/dialogs/approval-panel'; +import type { ApprovalPanelChoice, ApprovalPanelData, DisplayBlock } from '#/tui/reverse-rpc/types'; + +const DEFAULT_APPROVAL_CHOICES: ApprovalPanelChoice[] = [ + { label: 'Approve once', response: 'approved' }, + { label: 'Approve for this session', response: 'approved_for_session' }, + { label: 'Reject', response: 'rejected' }, + { label: 'Reject with feedback', response: 'rejected', requires_feedback: true }, +]; + +const PLAN_REJECT_CHOICES: ApprovalPanelChoice[] = [ + { label: 'Reject', response: 'rejected', selected_label: 'Reject' }, + { label: 'Revise', response: 'rejected', selected_label: 'Revise', requires_feedback: true }, +]; + +export function adaptApprovalRequest(event: ApprovalRequest): ApprovalPanelData { + const resolved = resolveDisplay(event.toolName, event.display, event.action); + return { + id: event.toolCallId, + tool_call_id: event.toolCallId, + tool_name: event.toolName, + action: event.action, + description: resolved.description, + display: resolved.blocks, + choices: adaptChoices(event.toolName, event.display), + }; +} + +interface ResolvedDisplay { + blocks: DisplayBlock[]; + description: string; +} + +function resolveDisplay( + toolName: string, + display: ToolInputDisplay, + action: string, +): ResolvedDisplay { + if (display.kind === 'generic' && isRecord(display.detail)) { + const extracted = extractFromArgs(toolName, display.detail); + if (extracted !== null) return extracted; + } + return { + blocks: adaptDisplay(display), + description: describeApproval(display, action), + }; +} + +function isRecord(value: unknown): value is Record<string, unknown> { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function stringField(detail: Record<string, unknown>, key: string): string | undefined { + const value = detail[key]; + return typeof value === 'string' ? value : undefined; +} + +function extractFromArgs( + toolName: string, + detail: Record<string, unknown>, +): ResolvedDisplay | null { + const command = stringField(detail, 'command'); + if (command !== undefined) { + const cwd = stringField(detail, 'cwd'); + const toolDescription = stringField(detail, 'description'); + const danger = detectDanger(command); + const language = stringField(detail, 'language') ?? 'bash'; + return { + blocks: [ + { + type: 'shell', + language, + command, + cwd, + description: toolDescription, + danger, + }, + ], + description: toolDescription ?? '', + }; + } + + const oldString = stringField(detail, 'old_string'); + const newString = stringField(detail, 'new_string'); + if (oldString !== undefined && newString !== undefined) { + const path = stringField(detail, 'file_path') ?? stringField(detail, 'path') ?? ''; + // Diff block carries its own `+N -M path` header — no separate + // file_op title row needed. + return { + blocks: [{ type: 'diff', path, old_text: oldString, new_text: newString }], + description: '', + }; + } + + const filePath = stringField(detail, 'file_path') ?? stringField(detail, 'path'); + const content = stringField(detail, 'content'); + if (filePath !== undefined && content !== undefined) { + // Write is a brand-new file: render the content as a syntax- + // highlighted code block, not a diff full of `+` markers. + return { + blocks: [{ type: 'file_content', path: filePath, content }], + description: '', + }; + } + + const url = stringField(detail, 'url'); + if (url !== undefined) { + const method = stringField(detail, 'method'); + return { + blocks: [{ type: 'url_fetch', url, method }], + description: '', + }; + } + + const query = stringField(detail, 'query'); + if (query !== undefined) { + return { + blocks: [{ type: 'search', query }], + description: '', + }; + } + + const pattern = stringField(detail, 'pattern'); + if (pattern !== undefined) { + const scope = stringField(detail, 'path'); + return { + blocks: [{ type: 'search', query: pattern, scope }], + description: '', + }; + } + + if (filePath !== undefined) { + const operation = inferFileOp(toolName); + return { + blocks: [{ type: 'file_op', operation, path: filePath }], + description: '', + }; + } + + return null; +} + +function inferFileOp(toolName: string): 'read' | 'write' | 'edit' | 'glob' | 'grep' { + const lower = toolName.toLowerCase(); + if (lower.includes('glob')) return 'glob'; + if (lower.includes('grep')) return 'grep'; + if (lower.includes('edit')) return 'edit'; + if (lower.includes('write')) return 'write'; + return 'read'; +} + +export function adaptPanelResponse(response: ApprovalPanelResponse): ApprovalResponse { + if (response.response === 'approved_for_session') { + return { + decision: 'approved', + scope: 'session', + feedback: response.feedback, + selectedLabel: response.selected_label, + }; + } + return { + decision: + response.response === 'approved' + ? 'approved' + : response.response === 'rejected' + ? 'rejected' + : 'cancelled', + feedback: response.feedback, + selectedLabel: response.selected_label, + }; +} + +function describeApproval(display: ToolInputDisplay, action: string): string { + switch (display.kind) { + case 'plan_review': + return ''; + case 'generic': + if (typeof display.detail === 'string' && display.detail.length > 0) { + return display.detail; + } + return display.summary ?? action; + case 'command': + return display.description ?? display.command ?? action; + case 'diff': + return `edit ${display.path ?? ''}`.trim(); + case 'file_io': + return `${display.operation ?? 'file'} ${display.path ?? ''}`.trim(); + case 'task_stop': + return `stop task: ${display.task_description ?? display.task_id ?? ''}`.trim(); + case 'agent_call': + return `spawn ${display.agent_name ?? 'agent'}`; + case 'skill_call': + return `invoke skill ${display.skill_name ?? ''}`.trim(); + case 'url_fetch': + return `fetch ${display.url ?? ''}`.trim(); + case 'search': + return `search: ${display.query ?? ''}`.trim(); + case 'todo_list': + return `update todo list (${String(display.items?.length ?? 0)} items)`; + case 'background_task': + return `${display.status ?? 'background'} task ${display.task_id ?? ''}: ${ + display.description ?? '' + }`.trim(); + default: + return action; + } +} + +const DANGER_PATTERNS: Array<{ pattern: RegExp; label: string }> = [ + { pattern: /\brm\s+(-[a-zA-Z]*[rRfF][a-zA-Z]*|--recursive|--force)/i, label: 'recursive delete' }, + { pattern: /\bsudo\b/i, label: 'sudo' }, + { pattern: /\b(curl|wget)\b[^|]*\|\s*(sh|bash|zsh)\b/i, label: 'pipe to shell' }, + { pattern: /\bdd\b[^|]*\bof=/i, label: 'dd write' }, + { pattern: /\bmkfs\b/i, label: 'mkfs' }, + { pattern: />\s*\/dev\/(sd|nvme|disk|hd)/i, label: 'write to raw device' }, + { pattern: /\bchmod\s+-R?\s*777\b/i, label: 'chmod 777' }, + { pattern: /:\(\)\s*\{\s*:\|:&\s*\}/i, label: 'fork bomb' }, +]; + +function detectDanger(command: string): string | undefined { + for (const { pattern, label } of DANGER_PATTERNS) { + if (pattern.test(command)) return label; + } + return undefined; +} + +function adaptDisplay(display: ToolInputDisplay): DisplayBlock[] { + switch (display.kind) { + case 'command': { + const command = display.command ?? ''; + const danger = detectDanger(command); + return [ + { + type: 'shell', + language: display.language ?? 'bash', + command, + cwd: display.cwd, + description: display.description, + danger, + }, + ]; + } + case 'diff': + return [ + { + type: 'diff', + path: display.path ?? '', + old_text: display.before ?? '', + new_text: display.after ?? '', + }, + ]; + case 'file_io': + return [ + { + type: 'file_op', + operation: display.operation, + path: display.path ?? '', + detail: display.detail, + }, + ]; + case 'url_fetch': + return [ + { + type: 'url_fetch', + url: display.url ?? '', + method: display.method, + }, + ]; + case 'search': + return [ + { + type: 'search', + query: display.query ?? '', + scope: display.scope, + }, + ]; + case 'agent_call': + return [ + { + type: 'invocation', + kind: 'agent', + name: display.agent_name ?? '', + description: display.prompt, + }, + ]; + case 'skill_call': + return [ + { + type: 'invocation', + kind: 'skill', + name: display.skill_name ?? '', + description: display.args, + }, + ]; + case 'task_stop': + return [ + { + type: 'brief', + text: `Stop task ${display.task_id ?? ''}: ${display.task_description ?? ''}`, + }, + ]; + case 'plan_review': + return []; + case 'generic': + return []; + case 'todo_list': + return []; + case 'background_task': + return []; + default: + return []; + } +} + +function adaptChoices(toolName: string, display: ToolInputDisplay): ApprovalPanelChoice[] { + if (toolName === 'ExitPlanMode' || display.kind === 'plan_review') { + return adaptPlanReviewChoices(display); + } + + return DEFAULT_APPROVAL_CHOICES.map((choice) => cloneChoice(choice)); +} + +function adaptPlanReviewChoices(display: ToolInputDisplay): ApprovalPanelChoice[] { + const optionChoices = + display.kind === 'plan_review' && display.options !== undefined && display.options.length >= 2 + ? display.options.map((option) => ({ + label: option.label, + response: 'approved' as const, + selected_label: option.label, + })) + : [{ label: 'Approve', response: 'approved' as const, selected_label: 'Approve' }]; + return [...optionChoices, ...PLAN_REJECT_CHOICES].map((choice) => cloneChoice(choice)); +} + +function cloneChoice(choice: ApprovalPanelChoice): ApprovalPanelChoice { + return { ...choice }; +} diff --git a/apps/kimi-code/src/tui/reverse-rpc/approval/controller.ts b/apps/kimi-code/src/tui/reverse-rpc/approval/controller.ts new file mode 100644 index 000000000..5578a5189 --- /dev/null +++ b/apps/kimi-code/src/tui/reverse-rpc/approval/controller.ts @@ -0,0 +1,27 @@ +import type { ApprovalResponse } from '@moonshot-ai/kimi-code-sdk'; + +import { ReverseRpcController } from '#/tui/reverse-rpc/base-controller'; +import type { ApprovalPanelData } from '#/tui/reverse-rpc/types'; + +export class ApprovalController extends ReverseRpcController< + ApprovalPanelData, + ApprovalResponse +> { + protected createCancelResponse(reason: string): ApprovalResponse { + return { decision: 'cancelled', feedback: reason }; + } + + protected override autoResolveFor( + resolvedPayload: ApprovalPanelData, + response: ApprovalResponse, + queuedPayload: ApprovalPanelData, + ): ApprovalResponse | undefined { + if (response.decision !== 'approved') return undefined; + if (response.scope !== 'session') return undefined; + if (resolvedPayload.action !== queuedPayload.action) return undefined; + // Inherit the session-scoped approval. Drop `feedback` and + // `selectedLabel` — those described the user's interaction with the + // first request only and would be misleading on auto-resolved ones. + return { decision: 'approved', scope: 'session' }; + } +} diff --git a/apps/kimi-code/src/tui/reverse-rpc/approval/handler.ts b/apps/kimi-code/src/tui/reverse-rpc/approval/handler.ts new file mode 100644 index 000000000..8640dd5d9 --- /dev/null +++ b/apps/kimi-code/src/tui/reverse-rpc/approval/handler.ts @@ -0,0 +1,24 @@ +import type { ApprovalHandler, ApprovalRequest, ApprovalResponse } from '@moonshot-ai/kimi-code-sdk'; + +import { adaptApprovalRequest } from './adapter'; +import type { ApprovalController } from './controller'; + +export function createApprovalRequestHandler( + controller: ApprovalController, + onResponse?: (request: ApprovalRequest, response: ApprovalResponse) => void, +): ApprovalHandler { + return async (event): Promise<ApprovalResponse> => { + try { + const response = await controller.show(adaptApprovalRequest(event)); + onResponse?.(event, response); + return response; + } catch { + const response: ApprovalResponse = { + decision: 'cancelled', + feedback: 'approval handler failed', + }; + onResponse?.(event, response); + return response; + } + }; +} diff --git a/apps/kimi-code/src/tui/reverse-rpc/base-controller.ts b/apps/kimi-code/src/tui/reverse-rpc/base-controller.ts new file mode 100644 index 000000000..a32dcb047 --- /dev/null +++ b/apps/kimi-code/src/tui/reverse-rpc/base-controller.ts @@ -0,0 +1,111 @@ +/** + * Base class for promise-based reverse RPC dialog controllers. + * + * Approval and question flows wait for a UI action before returning a response. + * Subclasses only need to define the default cancellation response. + * + * When concurrent requests arrive (e.g. multiple parallel subagents each + * needing approval), only one panel is shown at a time; additional requests + * are queued in arrival order and advance after the current one resolves. + */ + +export interface ReverseRpcUIHooks<TPayload> { + showPanel(payload: TPayload): void; + hidePanel(): void; +} + +interface Pending<TPayload, TResponse> { + readonly payload: TPayload; + readonly resolve: (data: TResponse) => void; +} + +export abstract class ReverseRpcController<TPayload, TResponse> { + private uiHooks: ReverseRpcUIHooks<TPayload> | null = null; + private current: Pending<TPayload, TResponse> | null = null; + private queue: Array<Pending<TPayload, TResponse>> = []; + + setUIHooks(hooks: ReverseRpcUIHooks<TPayload>): void { + this.uiHooks = hooks; + } + + /** + * Called when a reverse RPC request arrives from core. The returned promise + * resolves after the user responds or `cancelAll` forces cancellation. + */ + show(payload: TPayload): Promise<TResponse> { + return new Promise<TResponse>((resolve) => { + const entry: Pending<TPayload, TResponse> = { payload, resolve }; + if (this.current === null) { + this.current = entry; + this.uiHooks?.showPanel(payload); + } else { + this.queue.push(entry); + } + }); + } + + /** Called by the UI after the user makes a panel choice. */ + respond(data: TResponse): void { + const pending = this.current; + this.current = null; + pending?.resolve(data); + if (pending !== null) { + this.drainAutoResolved(pending.payload, data); + } + this.advanceOrHide(); + } + + /** Cancels all pending requests during shutdown or session switches. */ + cancelAll(reason: string): void { + const all = [...(this.current === null ? [] : [this.current]), ...this.queue]; + this.current = null; + this.queue = []; + this.uiHooks?.hidePanel(); + for (const entry of all) { + entry.resolve(this.createCancelResponse(reason)); + } + } + + hasPending(): boolean { + return this.current !== null || this.queue.length > 0; + } + + private advanceOrHide(): void { + const next = this.queue.shift(); + if (next === undefined) { + this.uiHooks?.hidePanel(); + return; + } + this.current = next; + this.uiHooks?.showPanel(next.payload); + } + + private drainAutoResolved(resolvedPayload: TPayload, response: TResponse): void { + const remaining: Array<Pending<TPayload, TResponse>> = []; + for (const entry of this.queue) { + const auto = this.autoResolveFor(resolvedPayload, response, entry.payload); + if (auto === undefined) { + remaining.push(entry); + } else { + entry.resolve(auto); + } + } + this.queue = remaining; + } + + /** + * Subclasses override to short-circuit queued requests when an answer to the + * just-resolved one (e.g. an approve-for-session) implies the same answer + * for matching queued requests. Return `undefined` to leave the queued + * request waiting for its own panel turn. + */ + protected autoResolveFor( + _resolvedPayload: TPayload, + _response: TResponse, + _queuedPayload: TPayload, + ): TResponse | undefined { + return undefined; + } + + protected abstract createCancelResponse(reason: string): TResponse; +} diff --git a/apps/kimi-code/src/tui/reverse-rpc/index.ts b/apps/kimi-code/src/tui/reverse-rpc/index.ts new file mode 100644 index 000000000..3a00ae889 --- /dev/null +++ b/apps/kimi-code/src/tui/reverse-rpc/index.ts @@ -0,0 +1,44 @@ +import type { ApprovalController } from './approval/controller'; +import type { QuestionController } from './question/controller'; +import { ReverseRpcModalCoordinator } from './modal-coordinator'; +import type { ApprovalPanelData, QuestionPanelData } from './types'; + +export interface ReverseRPCUIHooks { + readonly showApprovalPanel: (payload: ApprovalPanelData) => void; + readonly hideApprovalPanel: () => void; + readonly showQuestionDialog: (payload: QuestionPanelData) => void; + readonly hideQuestionDialog: () => void; +} + +export function registerReverseRPCHandlers( + approvalController: ApprovalController, + questionController: QuestionController, + uiHooks: ReverseRPCUIHooks, +): Array<() => void> { + const modalCoordinator = new ReverseRpcModalCoordinator(uiHooks); + + // Setup UI hooks for controllers + approvalController.setUIHooks({ + showPanel: (payload) => { + modalCoordinator.showApproval(payload); + }, + hidePanel: () => { + modalCoordinator.hide('approval'); + }, + }); + + questionController.setUIHooks({ + showPanel: (payload) => { + modalCoordinator.showQuestion(payload); + }, + hidePanel: () => { + modalCoordinator.hide('question'); + }, + }); + + return [ + () => { + modalCoordinator.clear(); + }, + ]; +} diff --git a/apps/kimi-code/src/tui/reverse-rpc/modal-coordinator.ts b/apps/kimi-code/src/tui/reverse-rpc/modal-coordinator.ts new file mode 100644 index 000000000..623ec761a --- /dev/null +++ b/apps/kimi-code/src/tui/reverse-rpc/modal-coordinator.ts @@ -0,0 +1,97 @@ +import type { ApprovalPanelData, QuestionPanelData } from './types'; + +export type ReverseRpcModalOwner = 'approval' | 'question'; + +export interface ReverseRpcModalUIHooks { + readonly showApprovalPanel: (payload: ApprovalPanelData) => void; + readonly hideApprovalPanel: () => void; + readonly showQuestionDialog: (payload: QuestionPanelData) => void; + readonly hideQuestionDialog: () => void; +} + +interface ReverseRpcModalEntry { + readonly owner: ReverseRpcModalOwner; + readonly show: () => void; + readonly hide: () => void; +} + +export class ReverseRpcModalCoordinator { + private active: ReverseRpcModalEntry | null = null; + private readonly queued: ReverseRpcModalEntry[] = []; + + constructor(private readonly hooks: ReverseRpcModalUIHooks) {} + + showApproval(payload: ApprovalPanelData): void { + this.show({ + owner: 'approval', + show: () => { + this.hooks.showApprovalPanel(payload); + }, + hide: () => { + this.hooks.hideApprovalPanel(); + }, + }); + } + + showQuestion(payload: QuestionPanelData): void { + this.show({ + owner: 'question', + show: () => { + this.hooks.showQuestionDialog(payload); + }, + hide: () => { + this.hooks.hideQuestionDialog(); + }, + }); + } + + hide(owner: ReverseRpcModalOwner): void { + if (this.active?.owner === owner) { + const active = this.active; + this.active = null; + active.hide(); + this.showNext(); + return; + } + + const queuedIndex = this.queued.findIndex((entry) => entry.owner === owner); + if (queuedIndex >= 0) this.queued.splice(queuedIndex, 1); + } + + clear(): void { + const active = this.active; + this.active = null; + this.queued.length = 0; + active?.hide(); + } + + private show(entry: ReverseRpcModalEntry): void { + const active = this.active; + if (active === null) { + this.active = entry; + entry.show(); + return; + } + + if (active.owner === entry.owner) { + this.active = entry; + entry.show(); + return; + } + + const queuedIndex = this.queued.findIndex((queued) => queued.owner === entry.owner); + if (queuedIndex >= 0) { + this.queued[queuedIndex] = entry; + return; + } + + this.queued.push(entry); + } + + private showNext(): void { + const next = this.queued.shift(); + if (next === undefined) return; + this.active = next; + next.show(); + } +} diff --git a/apps/kimi-code/src/tui/reverse-rpc/question/controller.ts b/apps/kimi-code/src/tui/reverse-rpc/question/controller.ts new file mode 100644 index 000000000..a87c24daf --- /dev/null +++ b/apps/kimi-code/src/tui/reverse-rpc/question/controller.ts @@ -0,0 +1,11 @@ +import { ReverseRpcController } from '#/tui/reverse-rpc/base-controller'; +import type { QuestionPanelData, QuestionPanelResponse } from '#/tui/reverse-rpc/types'; + +export class QuestionController extends ReverseRpcController< + QuestionPanelData, + QuestionPanelResponse +> { + protected createCancelResponse(_reason: string): QuestionPanelResponse { + return { answers: [] }; + } +} diff --git a/apps/kimi-code/src/tui/reverse-rpc/question/handler.ts b/apps/kimi-code/src/tui/reverse-rpc/question/handler.ts new file mode 100644 index 000000000..3bf564b02 --- /dev/null +++ b/apps/kimi-code/src/tui/reverse-rpc/question/handler.ts @@ -0,0 +1,57 @@ +import type { QuestionHandler, QuestionRequest, QuestionResult } from '@moonshot-ai/kimi-code-sdk'; + +import type { + QuestionPanelData, + QuestionPanelResponse, +} from '#/tui/reverse-rpc/types'; + +import type { QuestionController } from './controller'; + +export function createQuestionAskHandler(controller: QuestionController): QuestionHandler { + return async (event): Promise<QuestionResult> => { + try { + const answers = await controller.show(adaptQuestionRequest(event)); + return adaptQuestionAnswers(event, answers); + } catch { + return null; + } + }; +} + +export function adaptQuestionRequest(event: QuestionRequest): QuestionPanelData { + const id = + event.toolCallId ?? + (event.turnId === undefined ? 'question' : `question-${String(event.turnId)}`); + return { + id, + tool_call_id: id, + questions: event.questions.map((question) => ({ + question: question.question, + header: question.header, + body: question.body, + multi_select: question.multiSelect ?? false, + other_label: question.otherLabel, + other_description: question.otherDescription, + options: question.options.map((option) => ({ + label: option.label, + description: option.description, + })), + })), + }; +} + +export function adaptQuestionAnswers( + event: QuestionRequest, + response: QuestionPanelResponse, +): QuestionResult { + const result: Record<string, string | true> = {}; + for (let i = 0; i < event.questions.length; i++) { + const question = event.questions[i]; + const answer = response.answers[i]; + if (question === undefined || typeof answer !== 'string' || answer.length === 0) continue; + result[question.question] = answer; + } + return Object.keys(result).length > 0 + ? { answers: result, method: response.method } + : null; +} diff --git a/apps/kimi-code/src/tui/reverse-rpc/types.ts b/apps/kimi-code/src/tui/reverse-rpc/types.ts new file mode 100644 index 000000000..c23c938f0 --- /dev/null +++ b/apps/kimi-code/src/tui/reverse-rpc/types.ts @@ -0,0 +1,151 @@ +/** + * Reverse RPC view-layer types. + * + * These types are the contract between the UI layer and reverse RPC + * controllers, not SDK event payloads. Approval and question adapters convert + * core payloads into these shapes for panel components. + */ + +import type { QuestionAnswerMethod } from '@moonshot-ai/kimi-code-sdk'; + +// ── Display blocks (approval panel) ────────────────────────────────── + +export interface BriefDisplayBlock { + type: 'brief'; + text: string; +} + +export interface DiffDisplayBlock { + type: 'diff'; + path: string; + old_text: string; + new_text: string; + old_start?: number | undefined; + new_start?: number | undefined; + is_summary?: boolean | undefined; +} + +export interface ShellDisplayBlock { + type: 'shell'; + language: string; + command: string; + cwd?: string | undefined; + description?: string | undefined; + danger?: string | undefined; +} + +export interface FileOpDisplayBlock { + type: 'file_op'; + operation: 'read' | 'write' | 'edit' | 'glob' | 'grep'; + path: string; + detail?: string | undefined; +} + +/** Full file content preview for Write — a code block, not a diff. */ +export interface FileContentDisplayBlock { + type: 'file_content'; + path: string; + content: string; + language?: string | undefined; +} + +export interface UrlFetchDisplayBlock { + type: 'url_fetch'; + url: string; + method?: string | undefined; +} + +export interface SearchDisplayBlock { + type: 'search'; + query: string; + scope?: string | undefined; +} + +export interface InvocationDisplayBlock { + type: 'invocation'; + kind: 'agent' | 'skill'; + name: string; + description?: string | undefined; +} + +export interface TodoDisplayItem { + title: string; + status: 'pending' | 'in_progress' | 'done'; +} + +export interface TodoDisplayBlock { + type: 'todo'; + items: TodoDisplayItem[]; +} + +export interface BackgroundTaskDisplayBlock { + type: 'background_task'; + task_id: string; + kind: string; + status: string; + description: string; +} + +export type DisplayBlock = + | BriefDisplayBlock + | DiffDisplayBlock + | ShellDisplayBlock + | FileOpDisplayBlock + | FileContentDisplayBlock + | UrlFetchDisplayBlock + | SearchDisplayBlock + | InvocationDisplayBlock + | TodoDisplayBlock + | BackgroundTaskDisplayBlock; + +export interface ApprovalPanelChoice { + label: string; + response: 'approved' | 'approved_for_session' | 'rejected' | 'cancelled'; + selected_label?: string | undefined; + requires_feedback?: boolean | undefined; +} + +// ── Approval / Question view payloads ──────────────────────────────── + +export interface ApprovalPanelData { + id: string; + tool_call_id: string; + tool_name: string; + action: string; + description: string; + display: DisplayBlock[]; + choices: ApprovalPanelChoice[]; +} + +export interface QuestionPanelItem { + question: string; + header?: string; + body?: string; + multi_select: boolean; + other_label?: string; + other_description?: string; + options: Array<{ label: string; description?: string }>; +} + +export interface QuestionPanelData { + id: string; + tool_call_id: string; + questions: QuestionPanelItem[]; +} + +export type QuestionSubmissionMethod = QuestionAnswerMethod; + +export interface QuestionPanelResponse { + readonly answers: string[]; + readonly method?: QuestionSubmissionMethod | undefined; +} + +// ── Pending state wrappers ─────────────────────────────────────────── + +export interface PendingApproval { + data: ApprovalPanelData; +} + +export interface PendingQuestion { + data: QuestionPanelData; +} diff --git a/apps/kimi-code/src/tui/theme/bundle.ts b/apps/kimi-code/src/tui/theme/bundle.ts new file mode 100644 index 000000000..8cfd0e921 --- /dev/null +++ b/apps/kimi-code/src/tui/theme/bundle.ts @@ -0,0 +1,27 @@ +import type { MarkdownTheme } from '@earendil-works/pi-tui'; + +import { getColorPalette, type ColorPalette, type ResolvedTheme } from './colors'; +import { createMarkdownTheme } from './pi-tui-theme'; +import { createThemeStyles, type ThemeStyles } from './styles'; +import { resolveThemeSync, type Theme } from './index'; + +export interface KimiTUIThemeBundle { + resolvedTheme: ResolvedTheme; + colors: ColorPalette; + styles: ThemeStyles; + markdownTheme: MarkdownTheme; +} + +export function createKimiTUIThemeBundle( + theme: Theme, + resolvedTheme?: ResolvedTheme, +): KimiTUIThemeBundle { + const actualTheme = resolvedTheme ?? resolveThemeSync(theme); + const colors = { ...getColorPalette(actualTheme) }; + return { + resolvedTheme: actualTheme, + colors, + styles: createThemeStyles(colors), + markdownTheme: createMarkdownTheme(colors), + }; +} diff --git a/apps/kimi-code/src/tui/theme/colors.ts b/apps/kimi-code/src/tui/theme/colors.ts new file mode 100644 index 000000000..4a9b37bad --- /dev/null +++ b/apps/kimi-code/src/tui/theme/colors.ts @@ -0,0 +1,148 @@ +/** + * Color palette definitions for dark and light themes. + * + * Two layers: + * - private `dark` / `light` raw palettes — unsemantic constants reused + * across multiple semantic tokens to avoid hex literal duplication. + * - exported `darkColors` / `lightColors` — the semantic `ColorPalette` + * consumed by every UI component via chalk.hex(...). + * + * Light palette values are tuned for ≥ 4.5:1 contrast against #FFFFFF + * for text tokens and ≥ 3:1 for chrome (border / large text), matching + * WCAG AA. + */ + +const dark = { + blue400: '#4FA8FF', + cyan400: '#5BC0BE', + gray50: '#F5F5F5', + gray100: '#E0E0E0', + gray500: '#888888', + gray600: '#6B6B6B', + gray700: '#5A5A5A', + green400: '#4EC87E', + green300: '#7AD99B', + red400: '#E85454', + red300: '#F08585', + amber400: '#E8A838', + orange300: '#FFCB6B', +} as const; + +const light = { + blue600: '#1565C0', + cyan700: '#00838F', + gray900: '#1A1A1A', + gray700: '#454545', + gray600: '#5F5F5F', + gray500: '#737373', + green700: '#0E7A38', + red700: '#B91C1C', + amber800: '#92660A', + orange700: '#9A4A00', +} as const; + +export interface ColorPalette { + // Brand + primary: string; + accent: string; + + // Text + text: string; + textStrong: string; + textDim: string; + textMuted: string; + + // Surface + border: string; + borderFocus: string; + + // State + success: string; + warning: string; + error: string; + + // Diff + diffAdded: string; + diffRemoved: string; + diffAddedStrong: string; + diffRemovedStrong: string; + diffGutter: string; + diffMeta: string; + + // Roles + roleUser: string; + roleAssistant: string; + roleThinking: string; + roleTool: string; + + // Status + status: string; +} + +export const darkColors: ColorPalette = { + primary: dark.blue400, + accent: dark.cyan400, + + text: dark.gray100, + textStrong: dark.gray50, + textDim: dark.gray500, + textMuted: dark.gray600, + + border: dark.gray700, + borderFocus: dark.amber400, + + success: dark.green400, + warning: dark.amber400, + error: dark.red400, + + diffAdded: dark.green400, + diffRemoved: dark.red400, + diffAddedStrong: dark.green300, + diffRemovedStrong: dark.red300, + diffGutter: dark.gray600, + diffMeta: dark.gray500, + + roleUser: dark.orange300, + roleAssistant: dark.gray100, + roleThinking: dark.gray500, + roleTool: dark.amber400, + + status: dark.gray500, +}; + +export const lightColors: ColorPalette = { + primary: light.blue600, + accent: light.cyan700, + + text: light.gray900, + textStrong: light.gray900, + textDim: light.gray700, + textMuted: light.gray600, + + border: light.gray500, + borderFocus: light.amber800, + + success: light.green700, + warning: light.amber800, + error: light.red700, + + diffAdded: light.green700, + diffRemoved: light.red700, + diffAddedStrong: light.green700, + diffRemovedStrong: light.red700, + diffGutter: light.gray500, + diffMeta: light.gray600, + + roleUser: light.orange700, + roleAssistant: light.gray900, + roleThinking: light.gray700, + roleTool: light.amber800, + + status: light.gray700, +}; + +export type ResolvedTheme = 'dark' | 'light'; + +export function getColorPalette(theme: ResolvedTheme): ColorPalette { + return theme === 'dark' ? darkColors : lightColors; +} diff --git a/apps/kimi-code/src/tui/theme/detect.ts b/apps/kimi-code/src/tui/theme/detect.ts new file mode 100644 index 000000000..0f4d40953 --- /dev/null +++ b/apps/kimi-code/src/tui/theme/detect.ts @@ -0,0 +1,121 @@ +/** + * Terminal background detection. + * + * Strategy, in priority order: + * 1. Reject — non-TTY, NO_COLOR, FORCE_COLOR=0, CI → safe `'dark'`. + * 2. OSC 11 — write `ESC ] 11 ; ? BEL`, parse `ESC ] 11 ; rgb:RR/GG/BB BEL`, + * compute relative luminance. Capped at `timeoutMs` so unsupported + * terminals don't hang. + * 3. COLORFGBG — VT100 / xterm fallback exposing `"fg;bg"`. + * 4. Default — `'dark'`. + * + * Must run before pi-tui enters raw mode; once the framework owns stdin + * the OSC reply gets eaten by the input loop. + */ + +import { OSC11_QUERY, TERMINAL_THEME_DETECT_TIMEOUT_MS } from "#/tui/constant/terminal"; + +import type { ResolvedTheme } from "./colors"; +import { parseOsc11BackgroundTheme } from "./terminal-background"; + +export interface DetectOptions { + readonly timeoutMs?: number; +} + +export async function detectTerminalTheme(opts: DetectOptions = {}): Promise<ResolvedTheme> { + if (!isInteractiveTerminal()) return "dark"; + if (isColorOptOut()) return "dark"; + + const fromOsc = await queryOsc11({ + timeoutMs: opts.timeoutMs ?? TERMINAL_THEME_DETECT_TIMEOUT_MS, + }); + if (fromOsc !== null) return fromOsc; + + const fromColorFgBg = parseColorFgBg(process.env["COLORFGBG"]); + if (fromColorFgBg !== null) return fromColorFgBg; + + return "dark"; +} + +function isInteractiveTerminal(): boolean { + return (process.stdin.isTTY ?? false) && (process.stdout.isTTY ?? false); +} + +function isColorOptOut(): boolean { + const env = process.env; + if (env["NO_COLOR"] !== undefined && env["NO_COLOR"] !== "") return true; + if (env["FORCE_COLOR"] === "0") return true; + if (env["CI"] !== undefined && env["CI"] !== "" && env["CI"] !== "0") return true; + return false; +} + +interface RawModeStdin { + isRaw?: boolean; + setRawMode(mode: boolean): NodeJS.ReadStream; + on(event: "data", listener: (data: Buffer) => void): NodeJS.ReadStream; + off(event: "data", listener: (data: Buffer) => void): NodeJS.ReadStream; +} + +async function queryOsc11(opts: { timeoutMs: number }): Promise<ResolvedTheme | null> { + const stdin = process.stdin as unknown as RawModeStdin; + if (typeof stdin.setRawMode !== "function") return null; + // If something else is already listening on stdin (e.g. another raw-mode + // consumer), don't fight for it — punt to COLORFGBG instead. + if (process.stdin.listenerCount("data") > 0) return null; + + const wasRaw = stdin.isRaw === true; + let buffer = ""; + let listener: ((data: Buffer) => void) | null = null; + let timer: NodeJS.Timeout | null = null; + + try { + if (!wasRaw) stdin.setRawMode(true); + + const result = await new Promise<ResolvedTheme | null>((resolve) => { + listener = (chunk: Buffer): void => { + buffer += chunk.toString("utf8"); + const theme = parseOsc11BackgroundTheme(buffer); + if (theme !== null) resolve(theme); + }; + stdin.on("data", listener); + timer = setTimeout(() => { + resolve(null); + }, opts.timeoutMs); + try { + process.stdout.write(OSC11_QUERY); + } catch { + resolve(null); + } + }); + + return result; + } catch { + return null; + } finally { + if (timer !== null) clearTimeout(timer); + if (listener !== null) stdin.off("data", listener); + if (!wasRaw) { + try { + stdin.setRawMode(false); + } catch { + /* ignore — raw mode restoration best-effort */ + } + } + } +} + +/** + * COLORFGBG is `"fg;bg"` (sometimes `"fg;default;bg"`). The last token is + * the background ANSI 16-color index; 0–6 and 8 are dark, the rest light. + */ +export function parseColorFgBg(value: string | undefined): ResolvedTheme | null { + if (value === undefined || value === "") return null; + const parts = value.split(";"); + const bgRaw = parts.at(-1); + if (bgRaw === undefined) return null; + const bg = parseInt(bgRaw, 10); + if (!Number.isInteger(bg)) return null; + // ANSI 0=black, 1=red, 2=green, 3=yellow, 4=blue, 5=magenta, 6=cyan, 8=bright black. + const darkBgs = new Set([0, 1, 2, 3, 4, 5, 6, 8]); + return darkBgs.has(bg) ? "dark" : "light"; +} diff --git a/apps/kimi-code/src/tui/theme/index.ts b/apps/kimi-code/src/tui/theme/index.ts new file mode 100644 index 000000000..a9a143633 --- /dev/null +++ b/apps/kimi-code/src/tui/theme/index.ts @@ -0,0 +1,46 @@ +/** + * Theme system public API. + */ + +import type { ResolvedTheme } from './colors'; +import { detectTerminalTheme } from './detect'; + +export { darkColors, lightColors, getColorPalette } from './colors'; +export type { ColorPalette, ResolvedTheme } from './colors'; +export { createThemeStyles } from './styles'; +export type { ThemeStyles } from './styles'; +export { createMarkdownTheme, createEditorTheme } from './pi-tui-theme'; +export { detectTerminalTheme } from './detect'; + +/** + * User-facing theme preference. `'auto'` defers to terminal background + * detection at startup; `'dark'` / `'light'` are explicit overrides that + * never trigger detection. The persisted value in `tui.toml` is always + * one of these three; the detected `ResolvedTheme` is computed at + * startup and held only in memory. + */ +export type Theme = 'dark' | 'light' | 'auto'; + +export function isTheme(value: string): value is Theme { + return value === 'dark' || value === 'light' || value === 'auto'; +} + +/** + * Resolve a user preference to a concrete palette key. `'auto'` triggers + * terminal background detection (OSC 11 with COLORFGBG / dark fallback); + * explicit choices pass through. + */ +export async function resolveTheme(theme: Theme): Promise<ResolvedTheme> { + if (theme === 'auto') return detectTerminalTheme(); + return theme; +} + +/** + * Synchronous fallback used by paths that cannot wait on terminal probes + * (initial state construction, in-TUI theme switches). `'auto'` collapses + * to `'dark'`; explicit choices pass through. + */ +export function resolveThemeSync(theme: Theme): ResolvedTheme { + if (theme === 'auto') return 'dark'; + return theme; +} diff --git a/apps/kimi-code/src/tui/theme/pi-tui-theme.ts b/apps/kimi-code/src/tui/theme/pi-tui-theme.ts new file mode 100644 index 000000000..dc3b1b9ad --- /dev/null +++ b/apps/kimi-code/src/tui/theme/pi-tui-theme.ts @@ -0,0 +1,71 @@ +/** + * Pi-tui theme adapters — MarkdownTheme and EditorTheme from our ColorPalette. + * + * All chalk calls route through `ColorPalette` tokens so themes flip + * cleanly. No raw `chalk.gray` / `chalk.dim` / `chalk.white` here. + */ + +import type { MarkdownTheme, EditorTheme } from '@earendil-works/pi-tui'; +import chalk from 'chalk'; +import { highlight, supportsLanguage } from 'cli-highlight'; + +import type { ColorPalette } from './colors'; + +// pi-tui's renderer emits literal "### " / "#### " / ... markers for h3-h6 +// headings (h1/h2 are rendered without the `#` prefix). The prefix arrives +// here already wrapped in bold SGR codes, so we strip it — after any leading +// ANSI sequences — before re-styling. Without this, h3+ renders as raw +// "### Title" and reads like unparsed markdown. +// eslint-disable-next-line no-control-regex -- intentionally matches the ESC byte that opens ANSI SGR sequences. +const HEADING_HASH_PREFIX = /^((?:\u001B\[[0-9;]*m)*)#{1,6}[ \t]+/; + +export function createMarkdownTheme(colors: ColorPalette): MarkdownTheme { + const stripHash = (text: string): string => text.replace(HEADING_HASH_PREFIX, '$1'); + const muted = chalk.hex(colors.textMuted); + const dim = chalk.hex(colors.textDim); + const border = chalk.hex(colors.border); + return { + heading: (text) => chalk.bold.hex(colors.text)(stripHash(text)), + link: (text) => chalk.hex(colors.primary)(text), + linkUrl: (text) => muted(text), + code: (text) => chalk.hex(colors.primary)(text), + codeBlock: (text) => text, + codeBlockBorder: (text) => muted(text), + quote: (text) => dim(text), + quoteBorder: (text) => dim(text), + hr: (text) => border(text), + // Match the assistant-message bullet so list markers read like a reply + // prefix. Ordered lists arrive as `"1. "` / `"2. "` and are left + // untouched by the leading-dash anchor. + listBullet: (text) => chalk.hex(colors.roleAssistant)(text.replace(/^-/, '•')), + bold: (text) => chalk.bold(text), + italic: (text) => chalk.italic(text), + strikethrough: (text) => chalk.strikethrough(text), + underline: (text) => chalk.underline(text), + highlightCode: (code: string, lang?: string) => { + const normalizedLang = lang?.trim().toLowerCase(); + const language = + normalizedLang !== undefined && supportsLanguage(normalizedLang) ? normalizedLang : 'text'; + try { + const highlighted = highlight(code, { language, ignoreIllegals: true }); + return highlighted.split('\n'); + } catch { + return code.split('\n'); + } + }, + }; +} + +export function createEditorTheme(colors: ColorPalette): EditorTheme { + const muted = chalk.hex(colors.textMuted); + return { + borderColor: (s) => chalk.hex(colors.border)(s), + selectList: { + selectedPrefix: (s) => chalk.hex(colors.primary)(s), + selectedText: (s) => chalk.hex(colors.primary)(s), + description: (s) => muted(s), + scrollInfo: (s) => muted(s), + noMatch: (s) => muted(s), + }, + }; +} diff --git a/apps/kimi-code/src/tui/theme/styles.ts b/apps/kimi-code/src/tui/theme/styles.ts new file mode 100644 index 000000000..625302e45 --- /dev/null +++ b/apps/kimi-code/src/tui/theme/styles.ts @@ -0,0 +1,66 @@ +/** + * Theme-aware style helpers built on chalk. Components hold a reference + * to a `ThemeStyles` instance via `state.theme.styles` and never reach into + * raw chalk color names — that keeps theme switches consistent and lets + * every visual token route through `ColorPalette`. + */ + +import chalk from 'chalk'; + +import type { ColorPalette } from './colors'; + +export interface ThemeStyles { + colors: ColorPalette; + + /** Brand primary (links, focus, slash highlight). */ + primary(text: string): string; + /** Secondary brand accent (command operators, approval labels). */ + accent(text: string): string; + /** Dimmed text — secondary but still readable. */ + dim(text: string): string; + /** Muted text — most faded; for unchanged-line counters, scroll info. */ + muted(text: string): string; + /** Body text — same color as default but explicit for theming. */ + text(text: string): string; + /** Strong / emphasized text — paths, URLs, command bodies. */ + strong(text: string): string; + + error(text: string): string; + warning(text: string): string; + success(text: string): string; + + /** Bold + dim, for label cells. */ + label(text: string): string; + /** Body color, for value cells. */ + value(text: string): string; + + diffAdd(text: string): string; + diffDel(text: string): string; + diffAddBold(text: string): string; + diffDelBold(text: string): string; + diffGutter(text: string): string; + diffMeta(text: string): string; +} + +export function createThemeStyles(colors: ColorPalette): ThemeStyles { + return { + colors, + primary: (s) => chalk.hex(colors.primary)(s), + accent: (s) => chalk.hex(colors.accent)(s), + dim: (s) => chalk.hex(colors.textDim)(s), + muted: (s) => chalk.hex(colors.textMuted)(s), + text: (s) => chalk.hex(colors.text)(s), + strong: (s) => chalk.hex(colors.textStrong)(s), + error: (s) => chalk.hex(colors.error)(s), + warning: (s) => chalk.hex(colors.warning)(s), + success: (s) => chalk.hex(colors.success)(s), + label: (s) => chalk.bold.hex(colors.textDim)(s), + value: (s) => chalk.hex(colors.text)(s), + diffAdd: (s) => chalk.hex(colors.diffAdded)(s), + diffDel: (s) => chalk.hex(colors.diffRemoved)(s), + diffAddBold: (s) => chalk.bold.hex(colors.diffAddedStrong)(s), + diffDelBold: (s) => chalk.bold.hex(colors.diffRemovedStrong)(s), + diffGutter: (s) => chalk.hex(colors.diffGutter)(s), + diffMeta: (s) => chalk.hex(colors.diffMeta)(s), + }; +} diff --git a/apps/kimi-code/src/tui/theme/terminal-background.ts b/apps/kimi-code/src/tui/theme/terminal-background.ts new file mode 100644 index 000000000..ba9803fdb --- /dev/null +++ b/apps/kimi-code/src/tui/theme/terminal-background.ts @@ -0,0 +1,28 @@ +import { OSC11_RESPONSE } from "#/tui/constant/terminal"; + +import type { ResolvedTheme } from "./colors"; + +export function parseOsc11BackgroundTheme(data: string): ResolvedTheme | null { + const match = OSC11_RESPONSE.exec(data); + if (match === null) return null; + const [, r, g, b] = match; + if (r === undefined || g === undefined || b === undefined) return null; + return themeFromHexChannels(r, g, b); +} + +export function themeFromHexChannels(rHex: string, gHex: string, bHex: string): ResolvedTheme { + const r = normalizeChannel(rHex); + const g = normalizeChannel(gHex); + const b = normalizeChannel(bHex); + // Relative luminance, sRGB-linearised. Threshold 0.5 splits dark/light + // backgrounds reliably for both pure-black (#000) and pure-white (#fff). + const luma = 0.2126 * r + 0.7152 * g + 0.0722 * b; + return luma > 0.5 ? "light" : "dark"; +} + +function normalizeChannel(hex: string): number { + // OSC 11 channels can be 1-4 hex digits. Scale into [0,1] regardless. + const max = (1 << (hex.length * 4)) - 1; + const value = parseInt(hex, 16); + return Number.isFinite(value) ? value / max : 0; +} diff --git a/apps/kimi-code/src/tui/types.ts b/apps/kimi-code/src/tui/types.ts new file mode 100644 index 000000000..16be35503 --- /dev/null +++ b/apps/kimi-code/src/tui/types.ts @@ -0,0 +1,149 @@ +import type { + ModelAlias, + PermissionMode, + ProviderConfig, + PromptPart, + ToolInputDisplay, +} from '@moonshot-ai/kimi-code-sdk'; + +import type { NotificationsConfig } from './config'; +import type { PendingApproval, PendingQuestion } from './reverse-rpc/types'; +import type { Theme } from './theme'; + +export interface AppState { + model: string; + workDir: string; + sessionId: string; + yolo: boolean; + permissionMode: PermissionMode; + planMode: boolean; + thinking: boolean; + contextUsage: number; + contextTokens: number; + maxContextTokens: number; + isStreaming: boolean; + isCompacting: boolean; + isReplaying: boolean; + streamingPhase: 'idle' | 'waiting' | 'thinking' | 'composing'; + streamingStartTime: number; + theme: Theme; + version: string; + editorCommand: string | null; + notifications: NotificationsConfig; + availableModels: Record<string, ModelAlias>; + availableProviders: Record<string, ProviderConfig>; + sessionTitle: string | null; +} + +export interface ToolCallBlockData { + id: string; + name: string; + args: Record<string, unknown>; + description?: string; + display?: ToolInputDisplay; + streamingArguments?: string; + streamingStartedAtMs?: number; + result?: ToolResultBlockData; + subagent?: SubagentReplayBlockData; + step?: number; + turnId?: string; + /** Set when the step ended (e.g. max_tokens) before the tool call's + * arguments finished streaming. Renderer flips the header verb to + * "Truncated" and stops showing the in-progress argument preview. */ + truncated?: boolean; +} + +export interface ToolResultBlockData { + tool_call_id: string; + output: string; + is_error?: boolean; + synthetic?: boolean; +} + +export interface SubagentReplayToolCallData { + id: string; + name: string; + args: Record<string, unknown>; + description?: string; + result?: ToolResultBlockData; +} + +export interface SubagentReplayBlockData { + id: string; + name?: string; + text?: string; + toolCalls?: readonly SubagentReplayToolCallData[]; +} + +export interface BackgroundAgentMetadata { + readonly agentId: string; + readonly parentToolCallId: string; + readonly agentName?: string; + readonly description?: string; +} + +export type BackgroundAgentStatusPhase = 'started' | 'completed' | 'failed'; + +export interface BackgroundAgentStatusData { + readonly phase: BackgroundAgentStatusPhase; + readonly headline: string; + readonly detail?: string; +} + +export interface CompactionTranscriptData { + readonly tokensBefore?: number; + readonly tokensAfter?: number; + readonly instruction?: string; +} + +export type TranscriptEntryKind = + | 'welcome' + | 'user' + | 'assistant' + | 'tool_call' + | 'thinking' + | 'status' + | 'skill_activation'; + +export interface TranscriptEntry { + id: string; + kind: TranscriptEntryKind; + turnId?: string; + renderMode: 'markdown' | 'plain' | 'notice'; + content: string; + color?: string; + detail?: string; + toolCallData?: ToolCallBlockData; + backgroundAgentStatus?: BackgroundAgentStatusData; + compactionData?: CompactionTranscriptData; + imageAttachmentIds?: readonly number[]; + skillActivationId?: string; + skillName?: string; + skillArgs?: string; +} + +export type LivePaneMode = + | 'idle' + | 'waiting' + | 'thinking' + | 'tool' + | 'session'; + +export interface LivePaneState { + mode: LivePaneMode; + pendingApproval: PendingApproval | null; + pendingQuestion: PendingQuestion | null; +} + +export interface QueuedMessage { + readonly text: string; + readonly agentId?: string; + readonly parts?: readonly PromptPart[]; + readonly imageAttachmentIds?: readonly number[]; +} + +export const INITIAL_LIVE_PANE: LivePaneState = { + mode: 'idle', + pendingApproval: null, + pendingQuestion: null, +}; diff --git a/apps/kimi-code/src/tui/utils/background-agent-status.ts b/apps/kimi-code/src/tui/utils/background-agent-status.ts new file mode 100644 index 000000000..a54257a97 --- /dev/null +++ b/apps/kimi-code/src/tui/utils/background-agent-status.ts @@ -0,0 +1,40 @@ +import type { + BackgroundAgentMetadata, + BackgroundAgentStatusData, + BackgroundAgentStatusPhase, +} from '#/tui/types'; + +const MAX_BACKGROUND_FIELD_LENGTH = 240; + +function normalizeBackgroundField(value: string | undefined): string | undefined { + if (value === undefined) return undefined; + const collapsed = value.trim().replaceAll(/\s+/g, ' '); + if (collapsed.length === 0) return undefined; + if (collapsed.length <= MAX_BACKGROUND_FIELD_LENGTH) return collapsed; + return `${collapsed.slice(0, MAX_BACKGROUND_FIELD_LENGTH - 3)}...`; +} + +export function formatBackgroundAgentTranscript( + phase: BackgroundAgentStatusPhase, + meta: BackgroundAgentMetadata, + extras: { resultSummary?: string; error?: string } | undefined = undefined, +): BackgroundAgentStatusData { + const normalizedAgentName = normalizeBackgroundField(meta.agentName); + const subject = normalizedAgentName !== undefined ? `${normalizedAgentName} agent` : 'agent'; + const headline = + phase === 'started' + ? `${subject} started in background` + : phase === 'completed' + ? `${subject} completed in background` + : `${subject} failed in background`; + const tail = phase === 'failed' ? normalizeBackgroundField(extras?.error) : undefined; + const detailParts = [normalizeBackgroundField(meta.description), tail].filter( + (part): part is string => part !== undefined, + ); + + return { + phase, + headline, + detail: detailParts.length > 0 ? detailParts.join(' · ') : undefined, + }; +} diff --git a/apps/kimi-code/src/tui/utils/background-task-status.ts b/apps/kimi-code/src/tui/utils/background-task-status.ts new file mode 100644 index 000000000..def7a39c5 --- /dev/null +++ b/apps/kimi-code/src/tui/utils/background-task-status.ts @@ -0,0 +1,104 @@ +/** + * Format a `BackgroundTaskInfo` snapshot into the transcript card data + * consumed by `BackgroundAgentStatusComponent`. + * + * Background tasks have six statuses (running / awaiting_approval / + * completed / failed / killed / lost) but the transcript card only + * renders three visual phases (started / completed / failed). The + * mapping packs the extra nuance — exit code, kill reason, lost-reason + * — into the dim detail line so the user still sees it. + */ + +import type { BackgroundTaskInfo, BackgroundTaskStatus } from '@moonshot-ai/kimi-code-sdk'; + +import type { BackgroundAgentStatusData, BackgroundAgentStatusPhase } from '@/tui/types'; + +const MAX_DETAIL_LENGTH = 240; + +function truncate(value: string | undefined): string | undefined { + if (value === undefined) return undefined; + const collapsed = value.trim().replaceAll(/\s+/g, ' '); + if (collapsed.length === 0) return undefined; + if (collapsed.length <= MAX_DETAIL_LENGTH) return collapsed; + return `${collapsed.slice(0, MAX_DETAIL_LENGTH - 3)}...`; +} + +export type BackgroundTaskTranscriptPhase = 'started' | 'updated' | 'terminal'; + +function phaseFromStatus(status: BackgroundTaskStatus): BackgroundAgentStatusPhase { + switch (status) { + case 'running': + case 'awaiting_approval': + return 'started'; + case 'completed': + return 'completed'; + case 'failed': + case 'killed': + case 'lost': + return 'failed'; + } +} + +function subjectFor(taskId: string): string { + return taskId.startsWith('agent-') ? 'agent task' : 'bash task'; +} + +function headlineFor(info: BackgroundTaskInfo): string { + const subject = subjectFor(info.taskId); + switch (info.status) { + case 'running': + return `${subject} started in background`; + case 'awaiting_approval': + return `${subject} awaiting approval`; + case 'completed': + return `${subject} completed in background`; + case 'failed': + return `${subject} failed in background`; + case 'killed': + return `${subject} stopped`; + case 'lost': + return `${subject} lost`; + } +} + +function detailFor(info: BackgroundTaskInfo): string | undefined { + const parts: string[] = []; + const description = truncate(info.description); + if (description !== undefined) parts.push(description); + + if (info.status === 'completed' || info.status === 'failed') { + if (info.exitCode !== null && info.exitCode !== undefined) { + parts.push(`exit ${info.exitCode}`); + } + } + if (info.status === 'killed') { + const reason = truncate(info.stopReason); + parts.push(reason !== undefined ? `stopped — ${reason}` : 'stopped'); + } + if (info.status === 'awaiting_approval') { + const reason = truncate(info.approvalReason); + if (reason !== undefined) parts.push(`awaiting: ${reason}`); + } + if (info.status === 'lost') { + parts.push('session restarted before completion'); + } + if (info.timedOut === true) parts.push('timed out'); + + return parts.length > 0 ? parts.join(' · ') : undefined; +} + +/** + * Build a transcript card payload for a background task lifecycle + * snapshot. The returned phase drives bullet color in the renderer + * (`BackgroundAgentStatusComponent`); the detail line carries the extra + * status nuance (exit code, kill reason, etc.). + */ +export function formatBackgroundTaskTranscript( + info: BackgroundTaskInfo, +): BackgroundAgentStatusData { + return { + phase: phaseFromStatus(info.status), + headline: headlineFor(info), + detail: detailFor(info), + }; +} diff --git a/apps/kimi-code/src/tui/utils/component-capabilities.ts b/apps/kimi-code/src/tui/utils/component-capabilities.ts new file mode 100644 index 000000000..5f1f0ba97 --- /dev/null +++ b/apps/kimi-code/src/tui/utils/component-capabilities.ts @@ -0,0 +1,40 @@ +export interface Expandable { + setExpanded(expanded: boolean): void; +} + +export interface PlanExpandable { + // Returns true iff the component actually owns a plan preview and + // applied the new state. + setPlanExpanded(expanded: boolean): boolean; +} + +export interface Disposable { + dispose(): void; +} + +export function isExpandable(obj: unknown): obj is Expandable { + return ( + typeof obj === 'object' && + obj !== null && + 'setExpanded' in obj && + typeof (obj as Expandable).setExpanded === 'function' + ); +} + +export function isPlanExpandable(obj: unknown): obj is PlanExpandable { + return ( + typeof obj === 'object' && + obj !== null && + 'setPlanExpanded' in obj && + typeof (obj as PlanExpandable).setPlanExpanded === 'function' + ); +} + +export function hasDispose(value: unknown): value is Disposable { + return ( + typeof value === 'object' && + value !== null && + 'dispose' in value && + typeof (value as Disposable).dispose === 'function' + ); +} diff --git a/apps/kimi-code/src/tui/utils/errors.ts b/apps/kimi-code/src/tui/utils/errors.ts new file mode 100644 index 000000000..f0fd60a2b --- /dev/null +++ b/apps/kimi-code/src/tui/utils/errors.ts @@ -0,0 +1,14 @@ +function isAbortMessage(message: string): boolean { + return message === 'Aborted' || message.endsWith(': Aborted'); +} + +export function isAbortError(error: unknown): boolean { + if (error instanceof Error) { + return error.name === 'AbortError' || isAbortMessage(error.message); + } + if (typeof error === 'object' && error !== null) { + const message = (error as { readonly message?: unknown }).message; + return typeof message === 'string' && isAbortMessage(message); + } + return isAbortMessage(String(error)); +} diff --git a/apps/kimi-code/src/tui/utils/event-payload.ts b/apps/kimi-code/src/tui/utils/event-payload.ts new file mode 100644 index 000000000..195f7d392 --- /dev/null +++ b/apps/kimi-code/src/tui/utils/event-payload.ts @@ -0,0 +1,81 @@ +import { isKimiError } from '@moonshot-ai/kimi-code-sdk'; + +import { STREAMING_ARGS_FIELD_RE } from '#/tui/constant/streaming'; + +function unescapeJsonString(s: string): string { + return s.replaceAll(/\\(["\\/bfnrt])/g, (_, ch: string) => { + switch (ch) { + case 'n': + return '\n'; + case 't': + return '\t'; + case 'r': + return '\r'; + case 'b': + return '\b'; + case 'f': + return '\f'; + case '"': + return '"'; + case '\\': + return '\\'; + case '/': + return '/'; + default: + return ch; + } + }); +} + +export function parseStreamingArgs(argumentsText: string): Record<string, unknown> { + if (argumentsText.trim().length === 0) return {}; + if (argumentsText.trimEnd().endsWith('}')) { + try { + const parsed = JSON.parse(argumentsText) as unknown; + if (typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)) { + return parsed as Record<string, unknown>; + } + } catch { + // fall through to partial scan + } + } + const result: Record<string, unknown> = {}; + for (const match of argumentsText.matchAll(STREAMING_ARGS_FIELD_RE)) { + const key = match[1]; + const rawValue = match[2]; + if (key === undefined || rawValue === undefined) continue; + if (!(key in result)) { + result[key] = unescapeJsonString(rawValue); + } + } + return result; +} + +export function argsRecord(args: unknown): Record<string, unknown> { + return typeof args === 'object' && args !== null && !Array.isArray(args) + ? (args as Record<string, unknown>) + : {}; +} + +export function serializeToolResultOutput(output: unknown): string { + if (typeof output === 'string') return output; + return JSON.stringify(output, null, 2); +} + +export function isTodoItemShape( + value: unknown, +): value is { title: string; status: 'pending' | 'in_progress' | 'done' } { + if (typeof value !== 'object' || value === null) return false; + const rec = value as { title?: unknown; status?: unknown }; + if (typeof rec.title !== 'string' || rec.title.length === 0) return false; + return rec.status === 'pending' || rec.status === 'in_progress' || rec.status === 'done'; +} + +export function formatErrorMessage(error: unknown): string { + if (isKimiError(error)) return `[${error.code}] ${error.message}`; + return error instanceof Error ? error.message : String(error); +} + +export function stringValue(value: unknown): string | undefined { + return typeof value === 'string' ? value : undefined; +} diff --git a/apps/kimi-code/src/tui/utils/image-attachment-store.ts b/apps/kimi-code/src/tui/utils/image-attachment-store.ts new file mode 100644 index 000000000..bac4ab8fb --- /dev/null +++ b/apps/kimi-code/src/tui/utils/image-attachment-store.ts @@ -0,0 +1,120 @@ +/** + * Registry for media pasted into the input box. + * + * Each paste produces an `ImageAttachment` with an auto-incrementing id + * or `VideoAttachment` with a human-readable placeholder (`[image #1 + * (640×480)]` / `[video #2 sample.mov]`). The placeholder is what the + * user sees in the input field; on submit, `extractMediaAttachments` + * walks the text and expands image placeholders to image content parts + * and video placeholders to file-path tags for `ReadMediaFile`. + * + * Scope is per-`KimiTUI` instance. Reloads (`/new`, `/clear`, + * session switch) call `clear()` so ids restart from 1 and stale + * prompt attachments are dropped. We intentionally do NOT persist + * attachments across sessions — coding-agent doesn't either, and + * `--resume` wouldn't know how to materialize the files anyway. + */ + +export interface ImageAttachment { + readonly id: number; + readonly kind: 'image'; + readonly bytes: Uint8Array; + readonly mime: string; + readonly width: number; + readonly height: number; + /** Rendered placeholder string, e.g. `[image #1 (640×480)]`. */ + readonly placeholder: string; +} + +export interface VideoAttachment { + readonly id: number; + readonly kind: 'video'; + readonly mime: string; + readonly filename: string; + readonly sourcePath: string; + readonly label: string; + /** Rendered placeholder string, e.g. `[video #1 sample.mov]`. */ + readonly placeholder: string; +} + +export type MediaAttachment = ImageAttachment | VideoAttachment; + +export class ImageAttachmentStore { + private nextId = 1; + private readonly byId = new Map<number, MediaAttachment>(); + + addImage(bytes: Uint8Array, mime: string, width: number, height: number): ImageAttachment { + const id = this.nextId; + this.nextId += 1; + const attachment: ImageAttachment = { + id, + kind: 'image', + bytes, + mime, + width, + height, + placeholder: formatPlaceholder(id, width, height), + }; + this.byId.set(id, attachment); + return attachment; + } + + addVideo(mime: string, sourcePath: string, filename?: string | undefined): VideoAttachment { + const id = this.nextId; + this.nextId += 1; + const normalizedFilename = basenameLike( + filename !== undefined && filename !== '' ? filename : sourcePath, + ); + const label = sanitizeVideoLabel(normalizedFilename.length > 0 ? normalizedFilename : mime); + const attachment: VideoAttachment = { + id, + kind: 'video', + mime, + filename: normalizedFilename, + sourcePath, + label, + placeholder: formatVideoPlaceholder(id, label), + }; + this.byId.set(id, attachment); + return attachment; + } + + get(id: number): MediaAttachment | undefined { + return this.byId.get(id); + } + + clear(): void { + this.byId.clear(); + this.nextId = 1; + } + + size(): number { + return this.byId.size; + } +} + +export function formatPlaceholder(id: number, width: number, height: number): string { + return `[image #${String(id)} (${String(width)}×${String(height)})]`; +} + +export function formatVideoPlaceholder(id: number, label: string): string { + return `[video #${String(id)} ${sanitizeVideoLabel(label)}]`; +} + +function sanitizeVideoLabel(raw: string): string { + let label = ''; + for (const char of raw) { + const code = char.codePointAt(0); + label += + code === undefined || code < 0x20 || code === 0x7f || char === '[' || char === ']' + ? '_' + : char; + } + label = label.trim(); + return label.length > 0 ? label : 'video'; +} + +function basenameLike(raw: string): string { + const parts = raw.split(/[\\/]/).filter((part) => part.length > 0); + return parts.at(-1) ?? raw; +} diff --git a/apps/kimi-code/src/tui/utils/image-placeholder.ts b/apps/kimi-code/src/tui/utils/image-placeholder.ts new file mode 100644 index 000000000..11c401f2f --- /dev/null +++ b/apps/kimi-code/src/tui/utils/image-placeholder.ts @@ -0,0 +1,126 @@ +/** + * Scan submitted text for media placeholders and produce + * the `PromptPart[]` we'll send to the SDK prompt endpoint. + * + * Rules: + * - Only placeholders that resolve against `store` get extracted. + * A literal `[image #999 ...]` the user typed themselves stays in + * the text (we can't hallucinate files for it). + * - Order is preserved for text/image/video segments. Image placeholders + * expand to image content parts so the prompt reaches the provider + * without relying on a model tool call. Video placeholders still expand + * to file-path tags so `ReadMediaFile` can own video upload behavior. + * - Adjacent text segments are flattened — empty / whitespace-only + * segments drop out so we never emit `{type:'text', text:' '}` + * noise between two media parts. + */ + +import type { PromptPart } from '@moonshot-ai/kimi-code-sdk'; + +import type { + ImageAttachment, + ImageAttachmentStore, + VideoAttachment, +} from './image-attachment-store'; + +const PLACEHOLDER_REGEX = /\[(image|video) #(\d+) (?:(\(\d+×\d+\))|([^\]]+))\]/g; + +export interface ExtractionResult { + /** Flat list of parts in input order; empty array when no media matched. */ + parts: PromptPart[]; + /** + * Did we find at least one matching attachment? When false, callers + * should keep the prompt on the plain text path. + */ + hasMedia: boolean; + /** Image attachment ids matched, in the order they appeared. */ + imageAttachmentIds: number[]; + /** Video attachment ids matched, in the order they appeared. */ + videoAttachmentIds: number[]; +} + +export function extractMediaAttachments( + text: string, + store: ImageAttachmentStore, +): ExtractionResult { + const parts: PromptPart[] = []; + const imageAttachmentIds: number[] = []; + const videoAttachmentIds: number[] = []; + let cursor = 0; + let hasMedia = false; + + PLACEHOLDER_REGEX.lastIndex = 0; + let match: RegExpExecArray | null; + while ((match = PLACEHOLDER_REGEX.exec(text)) !== null) { + const [literal, kind, idStr] = match; + if (kind !== 'image' && kind !== 'video') continue; + if (idStr === undefined) continue; + const id = Number.parseInt(idStr, 10); + const attachment = store.get(id); + if (attachment === undefined) continue; // stale / user-typed — leave as text + if (attachment.kind !== kind) continue; + const before = text.slice(cursor, match.index); + pushText(parts, before); + if (attachment.kind === 'video') { + const mediaText = tagTextForVideo(attachment); + pushText(parts, mediaText); + videoAttachmentIds.push(id); + } else { + parts.push(imagePartForAttachment(attachment)); + imageAttachmentIds.push(id); + } + hasMedia = true; + cursor = match.index + literal.length; + } + const tail = text.slice(cursor); + pushText(parts, tail); + + return { + // Text-only submissions drop the synthesised parts array — the + // caller's contract is "parts is meaningful iff hasMedia", and + // emitting a stray TextPart confuses consumers that branch on + // `parts.length > 0`. + parts: hasMedia ? parts : [], + hasMedia, + imageAttachmentIds, + videoAttachmentIds, + }; +} + +function pushText(parts: PromptPart[], segment: string): void { + if (segment.length === 0) return; + // Keep whitespace-only segments only when they sit between non-empty + // text elsewhere — the simpler rule "drop everything whitespace-only" + // is fine here because the LLM doesn't care about inter-image spaces. + if (segment.trim().length === 0) return; + const last = parts.at(-1); + if (last?.type === 'text') { + parts[parts.length - 1] = { type: 'text', text: last.text + segment }; + return; + } + parts.push({ type: 'text', text: segment }); +} + +function imagePartForAttachment(att: ImageAttachment): PromptPart { + const base64 = Buffer.from(att.bytes).toString('base64'); + return { + type: 'image_url', + imageUrl: { url: `data:${att.mime};base64,${base64}` }, + }; +} + +function tagTextForVideo(att: VideoAttachment): string { + return formatMediaTag('video', att.sourcePath); +} + +function formatMediaTag(tag: 'image' | 'video', path: string): string { + return `<${tag} path="${escapeAttribute(path)}"></${tag}>`; +} + +function escapeAttribute(value: string): string { + return value + .replaceAll('&', '&') + .replaceAll('"', '"') + .replaceAll('<', '<') + .replaceAll('>', '>'); +} diff --git a/apps/kimi-code/src/tui/utils/mcp-oauth.ts b/apps/kimi-code/src/tui/utils/mcp-oauth.ts new file mode 100644 index 000000000..e0c17b043 --- /dev/null +++ b/apps/kimi-code/src/tui/utils/mcp-oauth.ts @@ -0,0 +1,51 @@ +import { + MCP_OAUTH_AUTHORIZATION_URL_TOOL_UPDATE, + type McpOAuthAuthorizationUrlUpdateData, + type ToolProgressEvent, + type ToolUpdate, +} from '@moonshot-ai/kimi-code-sdk'; + +export type OpenUrl = (url: string) => void; + +export class McpOAuthAuthorizationUrlOpener { + private readonly openedAuthorizationUrls = new Set<string>(); + + constructor(private readonly openUrl: OpenUrl) {} + + handleToolProgress(event: Pick<ToolProgressEvent, 'toolCallId' | 'update'>): void { + const update = parseMcpOAuthAuthorizationUrlUpdate(event.update); + if (update === undefined) return; + const key = `${event.toolCallId}\0${update.authorizationUrl}`; + if (this.openedAuthorizationUrls.has(key)) return; + this.openedAuthorizationUrls.add(key); + this.openUrl(update.authorizationUrl); + } +} + +export function parseMcpOAuthAuthorizationUrlUpdate( + update: ToolUpdate, +): McpOAuthAuthorizationUrlUpdateData | undefined { + if (update.kind !== 'custom') return undefined; + if (update.customKind !== MCP_OAUTH_AUTHORIZATION_URL_TOOL_UPDATE) return undefined; + const data = update.customData; + if (!isRecord(data)) return undefined; + const serverName = data['serverName']; + const authorizationUrl = data['authorizationUrl']; + if (typeof serverName !== 'string' || serverName.length === 0) return undefined; + if (typeof authorizationUrl !== 'string' || authorizationUrl.length === 0) return undefined; + if (!isHttpUrl(authorizationUrl)) return undefined; + return { serverName, authorizationUrl }; +} + +function isRecord(value: unknown): value is Record<string, unknown> { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function isHttpUrl(value: string): boolean { + try { + const url = new URL(value); + return url.protocol === 'http:' || url.protocol === 'https:'; + } catch { + return false; + } +} diff --git a/apps/kimi-code/src/tui/utils/mcp-server-status.ts b/apps/kimi-code/src/tui/utils/mcp-server-status.ts new file mode 100644 index 000000000..09fda3989 --- /dev/null +++ b/apps/kimi-code/src/tui/utils/mcp-server-status.ts @@ -0,0 +1,73 @@ +import type { McpServerInfo, McpServerStatusEvent } from '@moonshot-ai/kimi-code-sdk'; + +export type McpServerStatusSnapshot = McpServerInfo | McpServerStatusEvent['server']; + +export const MCP_STARTUP_STATUS_ROW_LIMIT = 4; + +function mcpStartupStatusPriority(status: McpServerStatusSnapshot['status']): number { + switch (status) { + case 'failed': + return 0; + case 'needs-auth': + return 1; + case 'pending': + return 2; + case 'connected': + return 3; + case 'disabled': + return 4; + } +} + +export function selectMcpStartupStatusRows( + servers: readonly McpServerStatusSnapshot[], +): McpServerStatusSnapshot[] { + return [...servers] + .filter((server) => server.status !== 'disabled') + .toSorted((a, b) => mcpStartupStatusPriority(a.status) - mcpStartupStatusPriority(b.status)) + .slice(0, MCP_STARTUP_STATUS_ROW_LIMIT); +} + +export function formatMcpStartupStatusSummary( + hidden: readonly McpServerStatusSnapshot[], + visibleCount: number, +): string { + let failed = 0; + let needsAuth = 0; + let connecting = 0; + let connected = 0; + let disabled = 0; + for (const server of hidden) { + switch (server.status) { + case 'failed': + failed++; + break; + case 'needs-auth': + needsAuth++; + break; + case 'pending': + connecting++; + break; + case 'connected': + connected++; + break; + case 'disabled': + disabled++; + break; + } + } + + const parts: string[] = []; + if (failed > 0) parts.push(`${failed} failed`); + if (needsAuth > 0) parts.push(`${needsAuth} need auth`); + if (connecting > 0) parts.push(`${connecting} connecting`); + if (connected > 0) parts.push(`${connected} connected`); + if (disabled > 0) parts.push(`${disabled} disabled`); + const detail = parts.join(', '); + if (visibleCount === 0) return `MCP servers: ${detail}`; + return `MCP servers: ${hidden.length} more (${detail})`; +} + +export function mcpServerStatusKey(server: McpServerStatusSnapshot): string { + return JSON.stringify([server.status, server.transport, server.toolCount, server.error]); +} diff --git a/apps/kimi-code/src/tui/utils/mcp-tool-name.ts b/apps/kimi-code/src/tui/utils/mcp-tool-name.ts new file mode 100644 index 000000000..dd5998a82 --- /dev/null +++ b/apps/kimi-code/src/tui/utils/mcp-tool-name.ts @@ -0,0 +1,16 @@ +// Decodes the `mcp__<server>__<tool>` qualified names produced by kimi-core's +// `qualifyMcpToolName`. Returns null for non-MCP tools and for hash-truncated +// qualified names (where the trailing `__<tool>` segment has been collapsed). +export function decodeMcpToolName( + name: string, +): { readonly serverName: string; readonly toolName: string } | null { + const PREFIX = 'mcp__'; + if (!name.startsWith(PREFIX)) return null; + const rest = name.slice(PREFIX.length); + const sep = rest.indexOf('__'); + if (sep <= 0 || sep === rest.length - 2) return null; + return { + serverName: rest.slice(0, sep), + toolName: rest.slice(sep + 2), + }; +} diff --git a/apps/kimi-code/src/tui/utils/media-url.ts b/apps/kimi-code/src/tui/utils/media-url.ts new file mode 100644 index 000000000..f04edeb29 --- /dev/null +++ b/apps/kimi-code/src/tui/utils/media-url.ts @@ -0,0 +1,51 @@ +export type MediaUrlKind = 'audio' | 'image' | 'video'; + +export function mediaUrlPartToText(kind: MediaUrlKind, url: string): string { + const summary = summarizeDataUrl(url); + if (summary !== undefined) { + const size = summary.bytes !== undefined ? `, ${formatByteSize(summary.bytes)}` : ''; + return `[${kind} ${summary.mime}${size}]`; + } + return `<${kind} url="${escapeAttribute(url)}">`; +} + +export function summarizeDataUrl(url: string): { mime: string; bytes?: number } | undefined { + if (!url.startsWith('data:')) return undefined; + const commaIndex = url.indexOf(','); + const header = + commaIndex >= 0 ? url.slice('data:'.length, commaIndex) : url.slice('data:'.length); + const data = commaIndex >= 0 ? url.slice(commaIndex + 1) : ''; + const [rawMime, ...params] = header.split(';'); + const mime = rawMime !== undefined && rawMime.length > 0 ? rawMime : 'application/octet-stream'; + const isBase64 = params.some((param) => param.toLowerCase() === 'base64'); + return { + mime, + bytes: isBase64 ? estimateBase64Bytes(data) : undefined, + }; +} + +function estimateBase64Bytes(data: string): number { + const compact = data.replaceAll(/\s/g, ''); + if (compact.length === 0) return 0; + const padding = compact.endsWith('==') ? 2 : compact.endsWith('=') ? 1 : 0; + return Math.max(0, Math.floor((compact.length * 3) / 4) - padding); +} + +function formatByteSize(bytes: number): string { + if (bytes < 1024) return `${String(bytes)} B`; + const kib = bytes / 1024; + if (kib < 1024) return `${formatOneDecimal(kib)} KB`; + return `${formatOneDecimal(kib / 1024)} MB`; +} + +function formatOneDecimal(value: number): string { + return value >= 10 ? value.toFixed(0) : value.toFixed(1); +} + +function escapeAttribute(value: string): string { + return value + .replaceAll('&', '&') + .replaceAll('"', '"') + .replaceAll('<', '<') + .replaceAll('>', '>'); +} diff --git a/apps/kimi-code/src/tui/utils/open-url.ts b/apps/kimi-code/src/tui/utils/open-url.ts new file mode 100644 index 000000000..4112d9c6f --- /dev/null +++ b/apps/kimi-code/src/tui/utils/open-url.ts @@ -0,0 +1,11 @@ +import { execFile } from 'node:child_process'; + +export function openUrl(url: string): void { + const command: [string, string[]] = + process.platform === 'darwin' + ? ['open', [url]] + : process.platform === 'win32' + ? ['cmd', ['/c', 'start', '', url]] + : ['xdg-open', [url]]; + execFile(command[0], command[1], () => {}); +} diff --git a/apps/kimi-code/src/tui/utils/printable-key.ts b/apps/kimi-code/src/tui/utils/printable-key.ts new file mode 100644 index 000000000..1b0977369 --- /dev/null +++ b/apps/kimi-code/src/tui/utils/printable-key.ts @@ -0,0 +1,27 @@ +/** + * Decode raw stdin bytes into a comparable printable character. + * + * When a terminal (e.g. the VSCode integrated terminal) enables the Kitty + * keyboard protocol disambiguate flag, ordinary printable keys are sent as + * CSI-u sequences: pressing `r` arrives as "\x1b[114u", pressing `q` as + * "\x1b[113u". A bare `data === 'q'` comparison inside a Container's + * `handleInput` therefore never matches under Kitty-mode terminals. + * + * Rules: + * - Every bare-literal printable-character comparison (letters, digits, + * space, punctuation) must go through this function first. + * - Functional keys (arrows, Enter, Tab, Esc, ...) continue to use + * `matchesKey(data, Key.*)`; pi-tui's `matchesKey` already handles Kitty. + * - Control characters (codepoint < 32, e.g. ctrl-b, ctrl-f) may still + * compare against the raw `data` — `decodeKittyPrintable` rejects them. + * + * The module's existence is itself the "don't forget to decode" constraint: + * `test/tui/printable-key-guard.test.ts` scans every `handleInput` under + * `tui/components/**` and rejects bare-literal comparisons. + */ + +import { decodeKittyPrintable } from '@earendil-works/pi-tui'; + +export function printableChar(data: string): string { + return decodeKittyPrintable(data) ?? data; +} diff --git a/apps/kimi-code/src/tui/utils/proctitle.ts b/apps/kimi-code/src/tui/utils/proctitle.ts new file mode 100644 index 000000000..c9c30d823 --- /dev/null +++ b/apps/kimi-code/src/tui/utils/proctitle.ts @@ -0,0 +1,29 @@ +/** + * Terminal window title synchronization. + * + * Uses the session title when present, capped at 80 characters to keep tabs + * readable. New or unnamed sessions fall back to `Kimi Code`. + * + * Writes both `process.title`, for process listings, and OSC 0/2 escape + * sequences, which most terminals use for window/tab titles. Non-TTY stdout + * skips the OSC write. + */ +import { PRODUCT_NAME } from '#/constant/app'; +import { MAX_PROCESS_TITLE_LENGTH } from '#/tui/constant/terminal'; + +export function setProcessTitle(title: string | null, _sessionId: string): void { + const trimmed = title?.trim() ?? ''; + const label = trimmed.length > 0 ? trimmed.slice(0, MAX_PROCESS_TITLE_LENGTH) : PRODUCT_NAME; + try { + process.title = label; + } catch { + /* noop */ + } + try { + if (process.stdout.isTTY) { + process.stdout.write(`\u001B]0;${label}\u0007`); + } + } catch { + /* noop */ + } +} diff --git a/apps/kimi-code/src/tui/utils/terminal-focus.ts b/apps/kimi-code/src/tui/utils/terminal-focus.ts new file mode 100644 index 000000000..3a3559e60 --- /dev/null +++ b/apps/kimi-code/src/tui/utils/terminal-focus.ts @@ -0,0 +1,44 @@ +import { + DISABLE_TERMINAL_FOCUS_REPORTING, + ENABLE_TERMINAL_FOCUS_REPORTING, + TERMINAL_FOCUS_IN, + TERMINAL_FOCUS_OUT, +} from '#/tui/constant/terminal'; +import type { TUIState } from '#/tui/kimi-tui'; +import type { TerminalState } from '#/tui/utils/terminal-state'; + +export { + DISABLE_TERMINAL_FOCUS_REPORTING, + ENABLE_TERMINAL_FOCUS_REPORTING, + TERMINAL_FOCUS_IN, + TERMINAL_FOCUS_OUT, +} from '#/tui/constant/terminal'; + +export function installTerminalFocusTracking(state: TUIState): () => void { + state.terminalState.focused = true; + const disposeInputListener = state.ui.addInputListener((data) => + handleTerminalFocusInput(state.terminalState, data), + ); + state.terminal.write(ENABLE_TERMINAL_FOCUS_REPORTING); + + return () => { + disposeInputListener(); + state.terminal.write(DISABLE_TERMINAL_FOCUS_REPORTING); + state.terminalState.focused = true; + }; +} + +export function handleTerminalFocusInput( + state: Pick<TerminalState, 'focused'>, + data: string, +): { consume: true } | undefined { + if (data === TERMINAL_FOCUS_IN) { + state.focused = true; + return { consume: true }; + } + if (data === TERMINAL_FOCUS_OUT) { + state.focused = false; + return { consume: true }; + } + return undefined; +} diff --git a/apps/kimi-code/src/tui/utils/terminal-notification.ts b/apps/kimi-code/src/tui/utils/terminal-notification.ts new file mode 100644 index 000000000..a71da9347 --- /dev/null +++ b/apps/kimi-code/src/tui/utils/terminal-notification.ts @@ -0,0 +1,129 @@ +import type { Terminal } from '@earendil-works/pi-tui'; + +import { BEL, ESC, MAX_TERMINAL_NOTIFICATION_MESSAGE_LENGTH, ST } from '#/tui/constant/terminal'; +import type { TUIState } from '#/tui/kimi-tui'; + +export interface TerminalNotification { + readonly title: string; + readonly body?: string | undefined; +} + +export interface EmitOptions { + readonly supportsOsc9?: boolean; + readonly insideTmux?: boolean; +} + +export interface BuildOptions { + readonly supportsOsc9: boolean; + readonly insideTmux: boolean; +} + +export function notifyTerminalOnce( + state: TUIState, + key: string, + notification: TerminalNotification, +): void { + const { enabled, condition } = state.appState.notifications; + if (!enabled) return; + if (state.terminalState.notificationKeys.has(key)) return; + state.terminalState.notificationKeys.add(key); + if (condition === 'unfocused' && state.terminalState.focused) return; + emitTerminalNotification(state.terminal, notification, { + supportsOsc9: state.terminalState.supportsOsc9, + insideTmux: state.terminalState.insideTmux, + }); +} + +export function emitTerminalNotification( + terminal: Pick<Terminal, 'write'>, + notification: TerminalNotification, + options: EmitOptions = {}, +): void { + const sequences = buildTerminalNotificationSequences(notification, { + supportsOsc9: options.supportsOsc9 ?? supportsOsc9Notification(), + insideTmux: options.insideTmux ?? isInsideTmux(), + }); + for (const sequence of sequences) { + terminal.write(sequence); + } +} + +export function formatNotification(notification: TerminalNotification): string { + const title = sanitizeNotificationText(notification.title); + const body = sanitizeNotificationText(notification.body ?? ''); + const message = + title.length > 0 && body.length > 0 ? `${title}: ${body}` : title.length > 0 ? title : body; + return message.slice(0, MAX_TERMINAL_NOTIFICATION_MESSAGE_LENGTH); +} + +/** + * Build the OSC/BEL bytes for a terminal notification. + * + * - `supportsOsc9 === true`: emit a single OSC 9 sequence — the modern + * desktop-notification path used by iTerm2, WezTerm, Kitty, Ghostty + * and Warp. + * - `supportsOsc9 === false`: fall back to a bare BEL so the user still + * gets the system bell on terminals that don't recognize OSC 9. + * + * When `insideTmux === true` and we're emitting OSC 9, wrap the sequence + * in a tmux DCS passthrough (`ESC P tmux ; <payload> ESC \`) and double + * any `ESC` bytes inside the payload — otherwise tmux swallows the OSC. + * BEL is single-byte and passes through tmux unchanged, so no wrap is + * needed in the fallback path. + */ +export function buildTerminalNotificationSequences( + notification: TerminalNotification, + options: BuildOptions, +): string[] { + const message = formatNotification(notification); + if (message.length === 0) return []; + if (!options.supportsOsc9) { + return [BEL]; + } + const osc9 = `${ESC}]9;${message}${BEL}`; + if (options.insideTmux) { + const escaped = osc9.replaceAll(ESC, `${ESC}${ESC}`); + return [`${ESC}Ptmux;${escaped}${ESC}${ST}`]; + } + return [osc9]; +} + +/** + * Best-effort detection of OSC 9 desktop-notification support, driven + * entirely off well-known environment variables. The allow-list is + * intentionally short and conservative because BEL is safe everywhere, + * while shipping OSC 9 to a terminal that doesn't grok it would print + * escape garbage on screen. + */ +export function supportsOsc9Notification(env: NodeJS.ProcessEnv = process.env): boolean { + const termProgram = env['TERM_PROGRAM'] ?? ''; + if ( + termProgram === 'iTerm.app' || + termProgram === 'WezTerm' || + termProgram === 'ghostty' || + termProgram === 'WarpTerminal' + ) { + return true; + } + const term = env['TERM'] ?? ''; + if (term === 'xterm-kitty' || term === 'xterm-ghostty') return true; + return false; +} + +export function isInsideTmux(env: NodeJS.ProcessEnv = process.env): boolean { + const tmux = env['TMUX'] ?? ''; + return tmux.length > 0; +} + +function sanitizeNotificationText(value: string): string { + return Array.from(value) + .map((ch) => (isControlCharacter(ch) ? ' ' : ch)) + .join('') + .replaceAll(/\s+/g, ' ') + .trim(); +} + +function isControlCharacter(ch: string): boolean { + const code = ch.codePointAt(0) ?? 0; + return (code >= 0x00 && code <= 0x1f) || (code >= 0x7f && code <= 0x9f); +} diff --git a/apps/kimi-code/src/tui/utils/terminal-state.ts b/apps/kimi-code/src/tui/utils/terminal-state.ts new file mode 100644 index 000000000..86d2bab9f --- /dev/null +++ b/apps/kimi-code/src/tui/utils/terminal-state.ts @@ -0,0 +1,19 @@ +import { isInsideTmux, supportsOsc9Notification } from './terminal-notification'; + +export interface TerminalState { + notificationKeys: Set<string>; + focused: boolean; + supportsOsc9: boolean; + insideTmux: boolean; + progressActive: boolean; +} + +export function createTerminalState(): TerminalState { + return { + notificationKeys: new Set<string>(), + focused: true, + supportsOsc9: supportsOsc9Notification(), + insideTmux: isInsideTmux(), + progressActive: false, + }; +} diff --git a/apps/kimi-code/src/tui/utils/terminal-theme.ts b/apps/kimi-code/src/tui/utils/terminal-theme.ts new file mode 100644 index 000000000..71c6b9bbf --- /dev/null +++ b/apps/kimi-code/src/tui/utils/terminal-theme.ts @@ -0,0 +1,153 @@ +import { + DISABLE_TERMINAL_THEME_REPORTING, + ENABLE_TERMINAL_THEME_REPORTING, + OSC11_QUERY, + OSC11_RESPONSE, + OSC11_RESPONSE_PREFIX, + OSC11_RESPONSE_PREFIX_NO_ESC, + QUERY_TERMINAL_THEME, + TERMINAL_THEME_INPUT_BUFFER_MAX_LENGTH, + TERMINAL_THEME_DARK, + TERMINAL_THEME_LIGHT, +} from "#/tui/constant/terminal"; +import type { TUIState } from "#/tui/kimi-tui"; +import type { ResolvedTheme } from "#/tui/theme/colors"; +import { parseOsc11BackgroundTheme } from "#/tui/theme/terminal-background"; + +export { + DISABLE_TERMINAL_THEME_REPORTING, + ENABLE_TERMINAL_THEME_REPORTING, + OSC11_QUERY, + QUERY_TERMINAL_THEME, + TERMINAL_THEME_DARK, + TERMINAL_THEME_LIGHT, +} from "#/tui/constant/terminal"; + +export function hasTerminalThemeReport(data: string): boolean { + return data.includes(TERMINAL_THEME_DARK) || data.includes(TERMINAL_THEME_LIGHT); +} + +export interface TerminalThemeInputState { + osc11Buffer: string; +} + +export type TerminalThemeInputResult = + | { + consume?: boolean; + data?: string; + } + | undefined; + +export function createTerminalThemeInputState(): TerminalThemeInputState { + return { osc11Buffer: "" }; +} + +export function handleTerminalThemeInput( + data: string, + terminal: Pick<TUIState["terminal"], "write">, + onTheme: (theme: ResolvedTheme) => void, + inputState: TerminalThemeInputState = createTerminalThemeInputState(), +): TerminalThemeInputResult { + let remaining = data; + + if (inputState.osc11Buffer !== "") { + const candidate = `${inputState.osc11Buffer}${data}`; + const stripped = stripOsc11Reports(candidate, onTheme); + if (stripped !== candidate) { + inputState.osc11Buffer = ""; + return resultFromRemaining(stripped); + } + + inputState.osc11Buffer = + candidate.length > TERMINAL_THEME_INPUT_BUFFER_MAX_LENGTH ? "" : candidate; + return { consume: true }; + } + + remaining = stripOsc11Reports(remaining, onTheme); + remaining = stripTerminalThemeReports(remaining, terminal); + + const partialOsc11Start = findPartialOsc11Start(remaining); + if (partialOsc11Start !== -1) { + inputState.osc11Buffer = remaining.slice(partialOsc11Start); + return resultFromRemaining(remaining.slice(0, partialOsc11Start)); + } + + if (remaining !== data) return resultFromRemaining(remaining); + + return undefined; +} + +function stripOsc11Reports(data: string, onTheme: (theme: ResolvedTheme) => void): string { + let remaining = data; + + for (;;) { + const match = OSC11_RESPONSE.exec(remaining); + if (match === null) return remaining; + + const theme = parseOsc11BackgroundTheme(match[0]); + if (theme !== null) onTheme(theme); + + remaining = `${remaining.slice(0, match.index)}${remaining.slice(match.index + match[0].length)}`; + } +} + +function stripTerminalThemeReports( + data: string, + terminal: Pick<TUIState["terminal"], "write">, +): string { + let remaining = data; + let strippedReport = false; + + for (const report of [TERMINAL_THEME_DARK, TERMINAL_THEME_LIGHT]) { + if (!remaining.includes(report)) continue; + remaining = remaining.split(report).join(""); + strippedReport = true; + } + + if (strippedReport) { + terminal.write(OSC11_QUERY); + } + + return remaining; +} + +function findPartialOsc11Start(data: string): number { + const fullPrefixIndex = data.indexOf(OSC11_RESPONSE_PREFIX); + if (fullPrefixIndex !== -1) return fullPrefixIndex; + + const noEscPrefixIndex = data.indexOf(OSC11_RESPONSE_PREFIX_NO_ESC); + if (noEscPrefixIndex !== -1) return noEscPrefixIndex; + + for (let i = 0; i < data.length; i++) { + const suffix = data.slice(i); + if (OSC11_RESPONSE_PREFIX.startsWith(suffix) && suffix.length > 1) return i; + if (OSC11_RESPONSE_PREFIX_NO_ESC.startsWith(suffix) && suffix.startsWith("]11;")) { + return i; + } + } + + return -1; +} + +function resultFromRemaining(data: string): TerminalThemeInputResult { + if (data.length === 0) return { consume: true }; + return { data }; +} + +export function installTerminalThemeTracking( + state: Pick<TUIState, "terminal" | "ui">, + onTheme: (theme: ResolvedTheme) => void, +): () => void { + const inputState = createTerminalThemeInputState(); + const disposeInputListener = state.ui.addInputListener((data) => + handleTerminalThemeInput(data, state.terminal, onTheme, inputState), + ); + state.terminal.write(ENABLE_TERMINAL_THEME_REPORTING); + state.terminal.write(OSC11_QUERY); + state.terminal.write(QUERY_TERMINAL_THEME); + + return () => { + disposeInputListener(); + state.terminal.write(DISABLE_TERMINAL_THEME_REPORTING); + }; +} diff --git a/apps/kimi-code/src/tui/utils/transcript-id.ts b/apps/kimi-code/src/tui/utils/transcript-id.ts new file mode 100644 index 000000000..149378a9b --- /dev/null +++ b/apps/kimi-code/src/tui/utils/transcript-id.ts @@ -0,0 +1,6 @@ +let transcriptIdCounter = 0; + +export function nextTranscriptId(): string { + transcriptIdCounter += 1; + return `entry-${String(transcriptIdCounter)}`; +} diff --git a/apps/kimi-code/src/utils/clipboard/clipboard-image.ts b/apps/kimi-code/src/utils/clipboard/clipboard-image.ts new file mode 100644 index 000000000..0bb8d3c76 --- /dev/null +++ b/apps/kimi-code/src/utils/clipboard/clipboard-image.ts @@ -0,0 +1,516 @@ +/** + * Read media from the system clipboard with graceful platform fallbacks. + * + * kimi-core's LLM pipeline only accepts PNG/JPEG/GIF/WebP, and the + * clipboard sources we query already emit those formats on supported + * platforms — so we deliberately do not include a BMP→PNG converter. + * + * Lookup order: + * macOS file clipboard -> osascript/AppKit file URLs + * macOS / Windows -> native `@mariozechner/clipboard` + * Linux Wayland -> wl-paste + * Linux X11 -> xclip + * WSL (image not on Linux cb) -> PowerShell fallback via wslpath + * + * Returns `null` when no supported media is available, the format isn't + * supported, or every fallback fails. + */ + +import { spawnSync } from 'node:child_process'; +import { randomUUID } from 'node:crypto'; +import { readFileSync, statSync, unlinkSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { basename, isAbsolute, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { parseImageMeta } from '#/utils/image/image-mime'; + +import { clipboard, type ClipboardModule } from './clipboard-native'; + +export interface ClipboardImage { + kind: 'image'; + bytes: Uint8Array; + mimeType: string; +} + +export interface ClipboardVideo { + kind: 'video'; + mimeType: string; + filename: string; + sourcePath: string; +} + +export type ClipboardMedia = ClipboardImage | ClipboardVideo; + +export class ClipboardMediaError extends Error { + constructor(message: string) { + super(message); + this.name = 'ClipboardMediaError'; + } +} + +type RunCommandOptions = { timeoutMs?: number; env?: NodeJS.ProcessEnv }; +type RunCommand = ( + command: string, + args: string[], + options?: RunCommandOptions, +) => { stdout: Buffer; ok: boolean }; + +const SUPPORTED_IMAGE_MIME_TYPES = ['image/png', 'image/jpeg', 'image/webp', 'image/gif'] as const; +const MAX_VIDEO_BYTES = 100 * 1024 * 1024; + +const VIDEO_MIME_BY_SUFFIX: Readonly<Record<string, string>> = Object.freeze({ + '.mp4': 'video/mp4', + '.mpg': 'video/mpeg', + '.mpeg': 'video/mpeg', + '.mkv': 'video/x-matroska', + '.avi': 'video/x-msvideo', + '.mov': 'video/quicktime', + '.ogv': 'video/ogg', + '.wmv': 'video/x-ms-wmv', + '.webm': 'video/webm', + '.m4v': 'video/x-m4v', + '.flv': 'video/x-flv', + '.3gp': 'video/3gpp', + '.3g2': 'video/3gpp2', +}); + +const DEFAULT_LIST_TIMEOUT_MS = 1000; +const DEFAULT_READ_TIMEOUT_MS = 3000; +const DEFAULT_POWERSHELL_TIMEOUT_MS = 5000; +const DEFAULT_MAX_BUFFER_BYTES = 50 * 1024 * 1024; + +const MACOS_FILE_PATH_SCRIPT = String.raw` +ObjC.import('AppKit'); +ObjC.import('Foundation'); + +const out = []; +const pb = $.NSPasteboard.generalPasteboard; +if (String(pb) !== '[id nil]') { + try { + const options = $.NSMutableDictionary.dictionary; + options.setObjectForKey($.NSNumber.numberWithBool(true), $.NSPasteboardURLReadingFileURLsOnlyKey); + const urls = pb.readObjectsForClassesOptions([$.NSURL], options); + const count = urls ? urls.count : 0; + for (let i = 0; i < count; i++) { + const value = urls.objectAtIndex(i).path; + const path = value ? ObjC.unwrap(value) : ''; + if (path) out.push(path); + } + } catch (error) {} + + if (out.length === 0) { + try { + const files = ObjC.deepUnwrap(pb.propertyListForType('NSFilenamesPboardType')); + if (Array.isArray(files)) { + for (const path of files) { + if (path) out.push(String(path)); + } + } else if (files) { + out.push(String(files)); + } + } catch (error) {} + } +} +out.join('\n'); +`.trim(); + +function isWaylandSession(env: NodeJS.ProcessEnv): boolean { + return Boolean(env['WAYLAND_DISPLAY']) || env['XDG_SESSION_TYPE'] === 'wayland'; +} + +function isWSL(env: NodeJS.ProcessEnv): boolean { + if (env['WSL_DISTRO_NAME'] !== undefined || env['WSLENV'] !== undefined) return true; + try { + return /microsoft|wsl/i.test(readFileSync('/proc/version', 'utf-8')); + } catch { + return false; + } +} + +function baseMimeType(raw: string): string { + return raw.split(';')[0]?.trim().toLowerCase() ?? raw.toLowerCase(); +} + +function isSupportedImageMimeType(mime: string): boolean { + const base = baseMimeType(mime); + return (SUPPORTED_IMAGE_MIME_TYPES as readonly string[]).includes(base); +} + +function selectPreferredImageMimeType(candidates: string[]): string | null { + const normalized = candidates + .map((t) => t.trim()) + .filter((t) => t.length > 0) + .map((raw) => ({ raw, base: baseMimeType(raw) })); + + for (const preferred of SUPPORTED_IMAGE_MIME_TYPES) { + const match = normalized.find((t) => t.base === preferred); + if (match !== undefined) return match.raw; + } + const anyImage = normalized.find((t) => t.base.startsWith('image/')); + return anyImage?.raw ?? null; +} + +function videoMimeFromPath(path: string): string | null { + const dot = path.lastIndexOf('.'); + if (dot < 0) return null; + const suffix = path.slice(dot).toLowerCase(); + return VIDEO_MIME_BY_SUFFIX[suffix] ?? null; +} + +function parseClipboardPaths(text: string): string[] { + return splitClipboardPathLines(text) + .map((line) => line.trim()) + .filter((line) => line.length > 0 && !line.startsWith('#')) + .map((line) => { + if (line.startsWith('file://')) { + try { + return fileURLToPath(line); + } catch { + return ''; + } + } + return line; + }) + .filter((line) => line.length > 0 && isAbsolute(line)); +} + +function splitClipboardPathLines(text: string): string[] { + const lines: string[] = []; + let start = 0; + for (let i = 0; i < text.length; i += 1) { + const char = text[i]; + if (char === '\r' || char === '\n' || text.codePointAt(i) === 0) { + lines.push(text.slice(start, i)); + start = i + 1; + } + } + lines.push(text.slice(start)); + return lines; +} + +function readImagePath(path: string): ClipboardImage | null { + let stat: ReturnType<typeof statSync>; + try { + stat = statSync(path); + } catch { + return null; + } + if (!stat.isFile()) return null; + + let bytes: Buffer; + try { + bytes = readFileSync(path); + } catch { + return null; + } + if (bytes.length === 0) return null; + + const meta = parseImageMeta(bytes); + if (meta === null) return null; + return { kind: 'image', bytes: new Uint8Array(bytes), mimeType: meta.mime }; +} + +function readVideoPath(path: string): ClipboardVideo | null { + const mimeType = videoMimeFromPath(path); + if (mimeType === null) return null; + let stat: ReturnType<typeof statSync>; + try { + stat = statSync(path); + } catch { + return null; + } + if (!stat.isFile()) return null; + if (stat.size > MAX_VIDEO_BYTES) { + throw new ClipboardMediaError( + `Video is ${(stat.size / 1024 / 1024).toFixed(1)} MB; maximum supported size is 100 MB.`, + ); + } + return { + kind: 'video', + mimeType, + filename: basename(path), + sourcePath: path, + }; +} + +function readMediaPath(path: string): ClipboardMedia | null { + // Video files are never opened as images. + const video = readVideoPath(path); + if (video !== null) return video; + return readImagePath(path); +} + +function readMediaFromPaths(paths: readonly string[]): ClipboardMedia | null { + for (const path of paths) { + const media = readMediaPath(path); + if (media !== null) return media; + } + return null; +} + +function readMediaFromText(text: string): ClipboardMedia | null { + return readMediaFromPaths(parseClipboardPaths(text)); +} + +function runCommand( + command: string, + args: string[], + options?: RunCommandOptions, +): { stdout: Buffer; ok: boolean } { + const result = spawnSync(command, args, { + timeout: options?.timeoutMs ?? DEFAULT_READ_TIMEOUT_MS, + maxBuffer: DEFAULT_MAX_BUFFER_BYTES, + env: options?.env, + }); + if (result.error !== undefined || result.status !== 0) { + return { ok: false, stdout: Buffer.alloc(0) }; + } + const stdout = Buffer.isBuffer(result.stdout) ? result.stdout : Buffer.from(result.stdout ?? ''); + return { ok: true, stdout }; +} + +function parseTargetList(output: Buffer): string[] { + return output + .toString('utf-8') + .split(/\r?\n/) + .map((t) => t.trim()) + .filter((t) => t.length > 0); +} + +function readClipboardFileMediaViaWlPaste(): ClipboardMedia | null { + const list = runCommand('wl-paste', ['--list-types'], { + timeoutMs: DEFAULT_LIST_TIMEOUT_MS, + }); + if (!list.ok) return null; + + const types = parseTargetList(list.stdout); + const uriType = types.find((t) => baseMimeType(t) === 'text/uri-list'); + if (uriType === undefined) return null; + + const uris = runCommand('wl-paste', ['--type', uriType, '--no-newline']); + return uris.ok ? readMediaFromText(uris.stdout.toString('utf-8')) : null; +} + +function readClipboardImageViaWlPaste(): ClipboardImage | null { + const list = runCommand('wl-paste', ['--list-types'], { + timeoutMs: DEFAULT_LIST_TIMEOUT_MS, + }); + if (!list.ok) return null; + + const selected = selectPreferredImageMimeType(parseTargetList(list.stdout)); + if (selected === null) return null; + + const data = runCommand('wl-paste', ['--type', selected, '--no-newline']); + if (!data.ok || data.stdout.length === 0) return null; + return { kind: 'image', bytes: data.stdout, mimeType: baseMimeType(selected) }; +} + +function readClipboardFileMediaViaXclip(): ClipboardMedia | null { + const targets = runCommand('xclip', ['-selection', 'clipboard', '-t', 'TARGETS', '-o'], { + timeoutMs: DEFAULT_LIST_TIMEOUT_MS, + }); + if (!targets.ok) return null; + + const candidates = parseTargetList(targets.stdout); + const uriType = candidates.find((t) => baseMimeType(t) === 'text/uri-list'); + if (uriType === undefined) return null; + + const uris = runCommand('xclip', ['-selection', 'clipboard', '-t', uriType, '-o']); + return uris.ok ? readMediaFromText(uris.stdout.toString('utf-8')) : null; +} + +function readClipboardImageViaXclip(): ClipboardImage | null { + const targets = runCommand('xclip', ['-selection', 'clipboard', '-t', 'TARGETS', '-o'], { + timeoutMs: DEFAULT_LIST_TIMEOUT_MS, + }); + + const candidates = targets.ok ? parseTargetList(targets.stdout) : []; + const preferred = candidates.length > 0 ? selectPreferredImageMimeType(candidates) : null; + const tryTypes = + preferred !== null + ? [preferred, ...SUPPORTED_IMAGE_MIME_TYPES] + : [...SUPPORTED_IMAGE_MIME_TYPES]; + + for (const mime of tryTypes) { + const data = runCommand('xclip', ['-selection', 'clipboard', '-t', mime, '-o']); + if (data.ok && data.stdout.length > 0) { + return { kind: 'image', bytes: data.stdout, mimeType: baseMimeType(mime) }; + } + } + return null; +} + +/** + * Windows clipboard images (Win+Shift+S) don't bridge into the WSL + * Linux clipboard. PowerShell reaches the Windows clipboard directly; + * we round-trip via a temp PNG because binary stdout is unreliable + * across the WSL interop boundary. + */ +function readClipboardImageViaPowerShell(): ClipboardImage | null { + const tmpFile = join(tmpdir(), `kimi-wsl-clip-${randomUUID()}.png`); + try { + const winPathResult = runCommand('wslpath', ['-w', tmpFile], { + timeoutMs: DEFAULT_LIST_TIMEOUT_MS, + }); + if (!winPathResult.ok) return null; + const winPath = winPathResult.stdout.toString('utf-8').trim(); + if (winPath.length === 0) return null; + + const psScript = [ + 'Add-Type -AssemblyName System.Windows.Forms', + 'Add-Type -AssemblyName System.Drawing', + '$path = $env:KIMI_WSL_CLIPBOARD_IMAGE_PATH', + '$img = [System.Windows.Forms.Clipboard]::GetImage()', + "if ($img) { $img.Save($path, [System.Drawing.Imaging.ImageFormat]::Png); Write-Output 'ok' } else { Write-Output 'empty' }", + ].join('; '); + + const result = runCommand('powershell.exe', ['-NoProfile', '-Command', psScript], { + timeoutMs: DEFAULT_POWERSHELL_TIMEOUT_MS, + env: { ...process.env, KIMI_WSL_CLIPBOARD_IMAGE_PATH: winPath }, + }); + if (!result.ok) return null; + if (result.stdout.toString('utf-8').trim() !== 'ok') return null; + + const bytes = readFileSync(tmpFile); + if (bytes.length === 0) return null; + return { kind: 'image', bytes: new Uint8Array(bytes), mimeType: 'image/png' }; + } catch { + return null; + } finally { + try { + unlinkSync(tmpFile); + } catch { + // ignore cleanup errors + } + } +} + +function readClipboardFilePathsViaMacOs(run: RunCommand): string[] { + const result = run('osascript', ['-l', 'JavaScript', '-e', MACOS_FILE_PATH_SCRIPT], { + timeoutMs: DEFAULT_LIST_TIMEOUT_MS, + }); + if (!result.ok || result.stdout.length === 0) return []; + return parseClipboardPaths(result.stdout.toString('utf-8')); +} + +function isFileLikeNativeFormat(format: string): boolean { + const f = format.toLowerCase(); + const base = baseMimeType(format); + return ( + f.includes('file-url') || + f.includes('file url') || + f.includes('nsfilenames') || + f.includes('com.apple.finder') || + base === 'text/uri-list' || + base === 'public.url' + ); +} + +function safeAvailableFormats(clip: ClipboardModule | null): string[] { + if (clip?.availableFormats === undefined) return []; + try { + return clip.availableFormats(); + } catch { + return []; + } +} + +async function readClipboardFileMediaViaNativeText( + clip: ClipboardModule | null, +): Promise<{ media: ClipboardMedia | null; lookedFileLike: boolean }> { + if (clip === null) return { media: null, lookedFileLike: false }; + + const formats = safeAvailableFormats(clip); + const lookedFileLike = formats.some(isFileLikeNativeFormat); + if (!lookedFileLike || clip.getText === undefined) { + return { media: null, lookedFileLike }; + } + + try { + return { media: readMediaFromText(await clip.getText()), lookedFileLike }; + } catch (error) { + if (error instanceof ClipboardMediaError) throw error; + return { media: null, lookedFileLike }; + } +} + +async function readClipboardImageViaNative( + clip: ClipboardModule | null = clipboard, +): Promise<ClipboardImage | null> { + if (clip === null) return null; + + let hasImage = false; + try { + hasImage = clip.hasImage(); + } catch { + return null; + } + if (!hasImage) return null; + + try { + const data = await clip.getImageBinary(); + if (data.length === 0) return null; + return { kind: 'image', bytes: Uint8Array.from(data), mimeType: 'image/png' }; + } catch { + return null; + } +} + +export async function readClipboardMedia(options?: { + env?: NodeJS.ProcessEnv; + platform?: NodeJS.Platform; + clipboard?: ClipboardModule | null; + runCommand?: RunCommand; +}): Promise<ClipboardMedia | null> { + const env = options?.env ?? process.env; + const platform = options?.platform ?? process.platform; + const clip = options?.clipboard ?? clipboard; + const run = options?.runCommand ?? runCommand; + + // Termux on Android has no desktop clipboard; skip early rather than + // churn through every fallback. + if (env['TERMUX_VERSION'] !== undefined) return null; + + let image: ClipboardImage | null = null; + if (platform === 'linux') { + const wayland = isWaylandSession(env); + const wsl = isWSL(env); + + if (wayland || wsl) { + const fileMedia = readClipboardFileMediaViaWlPaste() ?? readClipboardFileMediaViaXclip(); + if (fileMedia !== null) return fileMedia; + image = readClipboardImageViaWlPaste() ?? readClipboardImageViaXclip(); + } + if (image === null && wsl) { + image = readClipboardImageViaPowerShell(); + } + if (image === null && !wayland) { + const nativeFileMedia = await readClipboardFileMediaViaNativeText(clip); + if (nativeFileMedia.media !== null) return nativeFileMedia.media; + if (nativeFileMedia.lookedFileLike) return null; + image = await readClipboardImageViaNative(clip); + } + } else { + if (platform === 'darwin') { + const fileMedia = readMediaFromPaths(readClipboardFilePathsViaMacOs(run)); + if (fileMedia !== null) return fileMedia; + } + + const nativeFileMedia = await readClipboardFileMediaViaNativeText(clip); + if (nativeFileMedia.media !== null) return nativeFileMedia.media; + + // Finder exposes file icons/thumbnails as image data. If the clipboard + // looks file-like but we could not read a real file path, do not consume + // that icon as an image attachment. + if (platform === 'darwin' && nativeFileMedia.lookedFileLike) { + return null; + } + + image = await readClipboardImageViaNative(clip); + } + + if (image === null) return null; + if (!isSupportedImageMimeType(image.mimeType)) return null; + return image; +} diff --git a/apps/kimi-code/src/utils/clipboard/clipboard-native.ts b/apps/kimi-code/src/utils/clipboard/clipboard-native.ts new file mode 100644 index 000000000..051e9af46 --- /dev/null +++ b/apps/kimi-code/src/utils/clipboard/clipboard-native.ts @@ -0,0 +1,51 @@ +/** + * Optional native clipboard binding. + * + * `@mariozechner/clipboard` is a native Node binding that can read image + * binaries from the system clipboard on macOS and Windows. It's an + * optional dependency — if the native module fails to load (e.g. on a + * platform where no prebuilt is available) we degrade to shell-based + * fallbacks (wl-paste / xclip / PowerShell) in `clipboard-image.ts`. + */ + +import { createRequire } from 'node:module'; + +import { loadNativePackage } from '#/native/native-require'; + +declare const __KIMI_CODE_NATIVE_BUNDLE__: boolean | undefined; + +export interface ClipboardModule { + availableFormats?(): string[]; + hasText?(): boolean; + getText?(): Promise<string>; + hasImage(): boolean; + getImageBinary(): Promise<Array<number>>; +} + +const nodeRequire = createRequire(import.meta.url); +const isNativeBundle = + typeof __KIMI_CODE_NATIVE_BUNDLE__ === 'boolean' && __KIMI_CODE_NATIVE_BUNDLE__; + +// The native module uses X11/Wayland on Linux; if no display is +// available, skip the load attempt so headless environments don't pay +// the binding cost just to fail later. +const hasDisplay = + process.platform !== 'linux' || Boolean(process.env['DISPLAY'] ?? process.env['WAYLAND_DISPLAY']); + +const clipboard: ClipboardModule | null = (() => { + if (process.env['TERMUX_VERSION'] !== undefined || !hasDisplay) return null; + try { + const bundledClipboard = loadNativePackage<ClipboardModule>('@mariozechner/clipboard'); + if (bundledClipboard !== null) return bundledClipboard; + } catch { + return null; + } + if (isNativeBundle) return null; + try { + return nodeRequire('@mariozechner/clipboard') as ClipboardModule; + } catch { + return null; + } +})(); + +export { clipboard }; diff --git a/apps/kimi-code/src/utils/git/git-ls-files.ts b/apps/kimi-code/src/utils/git/git-ls-files.ts new file mode 100644 index 000000000..a68613284 --- /dev/null +++ b/apps/kimi-code/src/utils/git/git-ls-files.ts @@ -0,0 +1,189 @@ +/** + * Git-aware file listing + relevance signals with a short-TTL cache. + * Used as the cross-directory `@file` completion source when `fd` is + * not installed. + * + * Tracks three things per snapshot, all refreshed atomically: + * - `files` deduped (tracked + untracked-not-ignored), capped at 1000 + * - `mtimeByPath` absolute-path → fs mtime (ms), for recency ranking + * - `recencyOrder` file path → position in recent git history (0-indexed; smaller = more recent) + * + * Rebuild strategy: 2s TTL plus `.git/index` mtime invalidation so + * rapid edits surface without paying the full spawn+stat cost on every + * keystroke. When outside a git worktree, + * `getSnapshot()` returns `null` and callers fall back further. + */ + +import { spawnSync } from 'node:child_process'; +import { existsSync, statSync } from 'node:fs'; +import { join } from 'node:path'; + +const TTL_MS = 2000; +const MAX_ENTRIES = 1000; +// Number of most-recent commits to scan for "recently edited" hotness. +// 200 is enough to cover a week of active work in a typical repo while +// staying fast (<100ms even on large repos). +const RECENT_COMMIT_DEPTH = 200; + +export interface GitSnapshot { + readonly files: readonly string[]; + /** Absolute path → mtime (ms). Missing entries = stat failed. */ + readonly mtimeByPath: ReadonlyMap<string, number>; + /** Path → 0-indexed recency rank (earlier = more recent). */ + readonly recencyOrder: ReadonlyMap<string, number>; +} + +export interface GitLsFilesCache { + /** Full snapshot, or `null` when the work dir is not a git repo. */ + getSnapshot(): GitSnapshot | null; + /** Convenience shortcut; identical to `getSnapshot()?.files ?? null`. */ + list(): string[] | null; + isGitRepo(): boolean; +} + +interface SnapshotState { + snapshot: GitSnapshot; + fetchedAt: number; + indexMtime: number; +} + +export function createGitLsFilesCache(workDir: string): GitLsFilesCache { + const gitRoot = resolveGitRoot(workDir); + const indexPath = gitRoot === null ? null : join(gitRoot, '.git', 'index'); + let state: SnapshotState | undefined; + + return { + isGitRepo: () => gitRoot !== null, + getSnapshot: () => { + if (gitRoot === null) return null; + + const now = Date.now(); + const currentIndexMtime = indexMtime(indexPath); + const fresh = + state !== undefined && + now - state.fetchedAt < TTL_MS && + state.indexMtime === currentIndexMtime; + if (fresh) return state!.snapshot; + + const snapshot = fetchSnapshot(gitRoot); + if (snapshot === null) return null; // transient git failure — retry next call + + state = { snapshot, fetchedAt: now, indexMtime: currentIndexMtime }; + return snapshot; + }, + list: function listCompat() { + return this.getSnapshot()?.files.slice() ?? null; + }, + }; +} + +function resolveGitRoot(workDir: string): string | null { + try { + const result = spawnSync('git', ['-C', workDir, 'rev-parse', '--show-toplevel'], { + encoding: 'utf8', + }); + if (result.status !== 0) return null; + const stdout = result.stdout.trim(); + return stdout.length > 0 ? stdout : null; + } catch { + return null; + } +} + +function indexMtime(indexPath: string | null): number { + if (indexPath === null || !existsSync(indexPath)) return 0; + try { + return statSync(indexPath).mtimeMs; + } catch { + return 0; + } +} + +function fetchSnapshot(gitRoot: string): GitSnapshot | null { + const tracked = runLsFiles(gitRoot, ['-z']); + if (tracked === null) return null; + const untracked = runLsFiles(gitRoot, ['-z', '--others', '--exclude-standard']); + if (untracked === null) return null; + + const seen = new Set<string>(); + for (const path of tracked) seen.add(path); + for (const path of untracked) seen.add(path); + const merged = [...seen].toSorted(); + const files = merged.length > MAX_ENTRIES ? merged.slice(0, MAX_ENTRIES) : merged; + + const mtimeByPath = collectMtimes(gitRoot, files); + const recencyOrder = collectRecencyOrder(gitRoot, new Set(files)); + + return { files, mtimeByPath, recencyOrder }; +} + +function runLsFiles(gitRoot: string, args: readonly string[]): string[] | null { + try { + const result = spawnSync('git', ['-C', gitRoot, 'ls-files', ...args], { + encoding: 'utf8', + maxBuffer: 16 * 1024 * 1024, + }); + if (result.status !== 0) return null; + return result.stdout.split('\0').filter((entry) => entry.length > 0); + } catch { + return null; + } +} + +function collectMtimes(gitRoot: string, files: readonly string[]): Map<string, number> { + const result = new Map<string, number>(); + for (const path of files) { + try { + const stat = statSync(join(gitRoot, path)); + result.set(path, stat.mtimeMs); + } catch { + // File was deleted between ls-files and stat, or permission error. + // Missing entry → ranker treats it as "no mtime signal". + } + } + return result; +} + +/** + * Walk the last RECENT_COMMIT_DEPTH commits and record the first time + * each path is seen (a file touched in HEAD wins over a file touched + * 50 commits ago). Runs on the whole repo, not the work dir, so rename + * tracking stays consistent even when the user cd's into a subdir. + * + * `trackedSet` filters out paths that were renamed away / deleted — we + * only care about files that still appear in `ls-files`, since those + * are the ones we could actually complete. + */ +function collectRecencyOrder(gitRoot: string, trackedSet: Set<string>): Map<string, number> { + const result = new Map<string, number>(); + try { + const proc = spawnSync( + 'git', + [ + '-C', + gitRoot, + 'log', + `-n`, + String(RECENT_COMMIT_DEPTH), + '--name-only', + '--pretty=format:', + '--no-renames', + ], + { encoding: 'utf8', maxBuffer: 16 * 1024 * 1024 }, + ); + if (proc.status !== 0) return result; + let rank = 0; + for (const raw of proc.stdout.split('\n')) { + const line = raw.trim(); + if (line.length === 0) continue; + if (result.has(line)) continue; // keep the earliest (most recent) occurrence + if (!trackedSet.has(line)) continue; // drop deleted / renamed-away paths + result.set(line, rank); + rank += 1; + } + } catch { + // Fall through with whatever we've collected — an incomplete + // recency map just means fewer entries get a hotness boost. + } + return result; +} diff --git a/apps/kimi-code/src/utils/git/git-status.ts b/apps/kimi-code/src/utils/git/git-status.ts new file mode 100644 index 000000000..d78358b11 --- /dev/null +++ b/apps/kimi-code/src/utils/git/git-status.ts @@ -0,0 +1,351 @@ +/** + * Cached git branch + working-tree status for the footer/statusline. + * + * Branch name refreshes every 5s, porcelain status every 15s. Branch + * and status reads stay synchronous with short timeouts. Pull request + * lookup uses an async cache so a slow `gh pr view` never blocks + * footer rendering. + */ + +import { execFile, spawnSync } from 'node:child_process'; + +const BRANCH_TTL_MS = 5_000; +const STATUS_TTL_MS = 15_000; +const PULL_REQUEST_TTL_MS = 60_000; +const SPAWN_TIMEOUT_MS = 500; +const PR_SPAWN_TIMEOUT_MS = 5_000; + +export interface GitStatus { + readonly branch: string; + readonly dirty: boolean; + readonly ahead: number; + readonly behind: number; + readonly diffAdded: number; + readonly diffDeleted: number; + readonly pullRequest: PullRequestInfo | null; +} + +export interface PullRequestInfo { + readonly number: number; + readonly url: string; +} + +export interface GitStatusCache { + /** Returns current status, or `null` when workDir is not a git repo. */ + getStatus(): GitStatus | null; +} + +export interface GitStatusCacheOptions { + readonly onChange?: () => void; +} + +interface BranchState { + value: string | null; + fetchedAt: number; +} + +interface StatusState { + dirty: boolean; + ahead: number; + behind: number; + diffAdded: number; + diffDeleted: number; + fetchedAt: number; +} + +interface PullRequestState { + value: PullRequestInfo | null; + branch: string | null; + fetchedAt: number; + pendingBranch: string | null; + requestId: number; +} + +const AHEAD_BEHIND_RE = /\[(?:ahead (\d+))?(?:, )?(?:behind (\d+))?\]/; + +export function createGitStatusCache( + workDir: string, + options: GitStatusCacheOptions = {}, +): GitStatusCache { + const isRepo = detectGitRepo(workDir); + let branch: BranchState = { value: null, fetchedAt: 0 }; + let status: StatusState = { + dirty: false, + ahead: 0, + behind: 0, + diffAdded: 0, + diffDeleted: 0, + fetchedAt: 0, + }; + let pullRequest: PullRequestState = { + value: null, + branch: null, + fetchedAt: 0, + pendingBranch: null, + requestId: 0, + }; + + return { + getStatus: () => { + if (!isRepo) return null; + + const now = Date.now(); + if (now - branch.fetchedAt >= BRANCH_TTL_MS) { + branch = { value: readBranch(workDir), fetchedAt: now }; + } + if (branch.value === null) return null; + + if (now - status.fetchedAt >= STATUS_TTL_MS) { + status = { ...readStatus(workDir), fetchedAt: now }; + } + refreshPullRequestIfNeeded(branch.value, now); + + return { + branch: branch.value, + dirty: status.dirty, + ahead: status.ahead, + behind: status.behind, + diffAdded: status.diffAdded, + diffDeleted: status.diffDeleted, + pullRequest: pullRequest.branch === branch.value ? pullRequest.value : null, + }; + }, + }; + + function refreshPullRequestIfNeeded(branchName: string, now: number): void { + if (pullRequest.pendingBranch === branchName) return; + const fetchedAt = pullRequest.branch === branchName ? pullRequest.fetchedAt : 0; + if (now - fetchedAt < PULL_REQUEST_TTL_MS) return; + + const requestId = pullRequest.requestId + 1; + pullRequest = { + value: pullRequest.branch === branchName ? pullRequest.value : null, + branch: branchName, + fetchedAt, + pendingBranch: branchName, + requestId, + }; + + void readPullRequest(workDir).then((value) => { + if (pullRequest.requestId !== requestId) return; + + const previous = pullRequest.branch === branchName ? pullRequest.value : null; + const changed = !samePullRequest(previous, value); + pullRequest = { + value, + branch: branchName, + fetchedAt: Date.now(), + pendingBranch: null, + requestId, + }; + if (changed) options.onChange?.(); + }); + } +} + +function detectGitRepo(workDir: string): boolean { + try { + const result = spawnSync('git', ['-C', workDir, 'rev-parse', '--is-inside-work-tree'], { + encoding: 'utf8', + timeout: SPAWN_TIMEOUT_MS, + }); + return result.status === 0 && result.stdout.trim() === 'true'; + } catch { + return false; + } +} + +function readBranch(workDir: string): string | null { + try { + const result = spawnSync('git', ['-C', workDir, 'branch', '--show-current'], { + encoding: 'utf8', + timeout: SPAWN_TIMEOUT_MS, + }); + if (result.status !== 0) return null; + const name = result.stdout.trim(); + return name.length > 0 ? name : null; + } catch { + return null; + } +} + +function readStatus(workDir: string): { + dirty: boolean; + ahead: number; + behind: number; + diffAdded: number; + diffDeleted: number; +} { + try { + const result = spawnSync('git', ['-C', workDir, 'status', '--porcelain', '-b'], { + encoding: 'utf8', + timeout: SPAWN_TIMEOUT_MS, + maxBuffer: 4 * 1024 * 1024, + }); + if (result.status !== 0) { + return { dirty: false, ahead: 0, behind: 0, diffAdded: 0, diffDeleted: 0 }; + } + + let dirty = false; + let ahead = 0; + let behind = 0; + for (const line of result.stdout.split('\n')) { + if (line.startsWith('## ')) { + const m = AHEAD_BEHIND_RE.exec(line); + if (m) { + ahead = Number.parseInt(m[1] ?? '0', 10) || 0; + behind = Number.parseInt(m[2] ?? '0', 10) || 0; + } + } else if (line.trim().length > 0) { + dirty = true; + } + } + const diff = dirty ? readDiffStats(workDir) : { added: 0, deleted: 0 }; + return { + dirty, + ahead, + behind, + diffAdded: diff.added, + diffDeleted: diff.deleted, + }; + } catch { + return { dirty: false, ahead: 0, behind: 0, diffAdded: 0, diffDeleted: 0 }; + } +} + +function readDiffStats(workDir: string): { added: number; deleted: number } { + try { + const result = spawnSync('git', ['-C', workDir, 'diff', '--numstat', 'HEAD', '--'], { + encoding: 'utf8', + timeout: SPAWN_TIMEOUT_MS, + maxBuffer: 4 * 1024 * 1024, + }); + if (result.status !== 0) return { added: 0, deleted: 0 }; + + let added = 0; + let deleted = 0; + for (const line of result.stdout.split('\n')) { + if (!line) continue; + const [addedText, deletedText] = line.split('\t'); + added += parseDiffNumstatCount(addedText); + deleted += parseDiffNumstatCount(deletedText); + } + return { added, deleted }; + } catch { + return { added: 0, deleted: 0 }; + } +} + +function parseDiffNumstatCount(value: string | undefined): number { + if (value === undefined || value === '-') return 0; + const n = Number.parseInt(value, 10); + return Number.isFinite(n) && n > 0 ? n : 0; +} + +function readPullRequest(workDir: string): Promise<PullRequestInfo | null> { + return new Promise((resolve) => { + execFile( + 'gh', + ['pr', 'view', '--json', 'number,url'], + { + cwd: workDir, + encoding: 'utf8', + env: { + ...process.env, + GH_NO_UPDATE_NOTIFIER: '1', + GH_PROMPT_DISABLED: '1', + }, + timeout: PR_SPAWN_TIMEOUT_MS, + maxBuffer: 256 * 1024, + }, + (error, stdout) => { + if (error !== null) { + resolve(null); + return; + } + resolve(parsePullRequest(stdout)); + }, + ); + }); +} + +function samePullRequest(a: PullRequestInfo | null, b: PullRequestInfo | null): boolean { + if (a === null || b === null) return a === b; + return a.number === b.number && a.url === b.url; +} + +function parsePullRequest(stdout: string): PullRequestInfo | null { + try { + const raw = JSON.parse(stdout) as unknown; + if (typeof raw !== 'object' || raw === null) return null; + const record = raw as Record<string, unknown>; + const number = record['number']; + const url = record['url']; + if (typeof number !== 'number' || !Number.isInteger(number) || number <= 0) return null; + if (typeof url !== 'string' || !isSafeHttpUrl(url)) return null; + return { number, url }; + } catch { + return null; + } +} + +function isSafeHttpUrl(value: string): boolean { + if (hasControlChars(value)) return false; + try { + const url = new URL(value); + return url.protocol === 'https:' || url.protocol === 'http:'; + } catch { + return false; + } +} + +function hasControlChars(value: string): boolean { + for (const char of value) { + const code = char.codePointAt(0) ?? 0; + if (code <= 0x1f || code === 0x7f) return true; + } + return false; +} + +export interface FormatGitBadgeOptions { + readonly linkPullRequest?: boolean; +} + +export function formatGitBadgeBase(status: GitStatus): string { + const parts: string[] = []; + const diff = formatDiffStats(status); + if (diff) parts.push(diff); + let sync = ''; + if (status.ahead > 0) sync += `↑${status.ahead}`; + if (status.behind > 0) sync += `↓${status.behind}`; + if (sync) parts.push(sync); + return parts.length === 0 ? status.branch : `${status.branch} [${parts.join(' ')}]`; +} + +export function formatPullRequestBadge( + pullRequest: PullRequestInfo, + options: FormatGitBadgeOptions = {}, +): string { + const prText = `[PR#${String(pullRequest.number)}]`; + return options.linkPullRequest ? toTerminalHyperlink(prText, pullRequest.url) : prText; +} + +export function formatGitBadge(status: GitStatus, options: FormatGitBadgeOptions = {}): string { + const base = formatGitBadgeBase(status); + if (status.pullRequest === null) return base; + + return `${base} ${formatPullRequestBadge(status.pullRequest, options)}`; +} + +function formatDiffStats(status: GitStatus): string | null { + const parts: string[] = []; + if (status.diffAdded > 0) parts.push(`+${String(status.diffAdded)}`); + if (status.diffDeleted > 0) parts.push(`-${String(status.diffDeleted)}`); + if (parts.length > 0) return parts.join(' '); + return status.dirty ? '±' : null; +} + +function toTerminalHyperlink(text: string, url: string): string { + if (!isSafeHttpUrl(url)) return text; + return `\u001B]8;;${url}\u0007${text}\u001B]8;;\u0007`; +} diff --git a/apps/kimi-code/src/utils/history/input-history.ts b/apps/kimi-code/src/utils/history/input-history.ts new file mode 100644 index 000000000..950542563 --- /dev/null +++ b/apps/kimi-code/src/utils/history/input-history.ts @@ -0,0 +1,42 @@ +/** + * User input history persistence — JSONL file with `{"content": "..."}` per line. + * + * Semantics: + * - One JSON object per line (`InputHistoryEntry { content }`) + * - Append-only writes + * - Skip empty entries + * - Skip when same as last entry (consecutive deduplication) + * - Tolerate corrupt lines: log + skip, do not abort load + */ + +import { z } from 'zod'; + +import { appendJsonlLine, readJsonlFile } from '#/utils/persistence'; + +export interface InputHistoryEntry { + content: string; +} + +const InputHistoryEntrySchema: z.ZodType<InputHistoryEntry> = z.object({ + content: z.string(), +}); + +export async function loadInputHistory(file: string): Promise<InputHistoryEntry[]> { + return readJsonlFile(file, InputHistoryEntrySchema); +} + +/** + * Append an entry to the history file. Returns true if written, false if + * skipped (empty or equal to `lastContent`). + */ +export async function appendInputHistory( + file: string, + text: string, + lastContent?: string, +): Promise<boolean> { + const content = text.trim(); + if (content.length === 0) return false; + if (content === lastContent) return false; + await appendJsonlLine(file, InputHistoryEntrySchema, { content }); + return true; +} diff --git a/apps/kimi-code/src/utils/image/image-mime.ts b/apps/kimi-code/src/utils/image/image-mime.ts new file mode 100644 index 000000000..e7ceb97de --- /dev/null +++ b/apps/kimi-code/src/utils/image/image-mime.ts @@ -0,0 +1,189 @@ +/** + * Detect image MIME type + dimensions from raw bytes. + * + * Uses magic-byte sniffing for MIME and minimal format-specific parsing + * for dimensions. Only formats that the kimi-core multimodal pipeline + * accepts are supported: PNG / JPEG / GIF / WebP. + * + * Unsupported or truncated inputs return `null` so the caller can + * decline the paste cleanly. + */ + +export type SupportedImageMime = 'image/png' | 'image/jpeg' | 'image/gif' | 'image/webp'; + +export interface ImageMeta { + mime: SupportedImageMime; + width: number; + height: number; +} + +export function parseImageMeta(bytes: Uint8Array): ImageMeta | null { + if (isPng(bytes)) return parsePng(bytes); + if (isJpeg(bytes)) return parseJpeg(bytes); + if (isGif(bytes)) return parseGif(bytes); + if (isWebp(bytes)) return parseWebp(bytes); + return null; +} + +// ── PNG ───────────────────────────────────────────────────────────── + +function isPng(b: Uint8Array): boolean { + return ( + b.length >= 8 && + b[0] === 0x89 && + b[1] === 0x50 && + b[2] === 0x4e && + b[3] === 0x47 && + b[4] === 0x0d && + b[5] === 0x0a && + b[6] === 0x1a && + b[7] === 0x0a + ); +} + +function parsePng(b: Uint8Array): ImageMeta | null { + // IHDR chunk immediately follows the 8-byte signature: length (4) + + // "IHDR" (4) + width (4 BE) + height (4 BE) + ... + if (b.length < 24) return null; + const width = readUInt32BE(b, 16); + const height = readUInt32BE(b, 20); + if (width <= 0 || height <= 0) return null; + return { mime: 'image/png', width, height }; +} + +// ── JPEG ──────────────────────────────────────────────────────────── + +function isJpeg(b: Uint8Array): boolean { + return b.length >= 3 && b[0] === 0xff && b[1] === 0xd8 && b[2] === 0xff; +} + +function parseJpeg(b: Uint8Array): ImageMeta | null { + // Scan for a Start-Of-Frame marker (SOF0..SOF3, SOF5..SOF7, SOF9..SOF11, + // SOF13..SOF15). After the marker + 2-byte segment length come: + // precision (1), height (2 BE), width (2 BE), components (1). + let i = 2; + while (i < b.length) { + if (b[i] !== 0xff) { + i += 1; + continue; + } + // Skip fill bytes (0xFF padding before a marker). + while (i < b.length && b[i] === 0xff) i += 1; + if (i >= b.length) return null; + const marker = b[i]!; + i += 1; + if (marker === 0xd8 || marker === 0xd9) continue; // SOI / EOI — no length + if (i + 1 >= b.length) return null; + const segLen = readUInt16BE(b, i); + if (isSofMarker(marker)) { + if (i + 7 >= b.length) return null; + const height = readUInt16BE(b, i + 3); + const width = readUInt16BE(b, i + 5); + if (width <= 0 || height <= 0) return null; + return { mime: 'image/jpeg', width, height }; + } + i += segLen; + } + return null; +} + +function isSofMarker(marker: number): boolean { + if (marker < 0xc0 || marker > 0xcf) return false; + // Exclude DHT (0xC4), JPG (0xC8), DAC (0xCC) — these reuse the SOF + // number range but are not frame headers. + return marker !== 0xc4 && marker !== 0xc8 && marker !== 0xcc; +} + +// ── GIF ───────────────────────────────────────────────────────────── + +function isGif(b: Uint8Array): boolean { + return ( + b.length >= 6 && + b[0] === 0x47 && // G + b[1] === 0x49 && // I + b[2] === 0x46 && // F + b[3] === 0x38 && // 8 + (b[4] === 0x37 || b[4] === 0x39) && // 7 or 9 + b[5] === 0x61 // a + ); +} + +function parseGif(b: Uint8Array): ImageMeta | null { + // Logical screen width/height are at offsets 6-9, little-endian. + if (b.length < 10) return null; + const width = readUInt16LE(b, 6); + const height = readUInt16LE(b, 8); + if (width <= 0 || height <= 0) return null; + return { mime: 'image/gif', width, height }; +} + +// ── WebP ──────────────────────────────────────────────────────────── + +function isWebp(b: Uint8Array): boolean { + return ( + b.length >= 12 && + b[0] === 0x52 && // R + b[1] === 0x49 && // I + b[2] === 0x46 && // F + b[3] === 0x46 && // F + b[8] === 0x57 && // W + b[9] === 0x45 && // E + b[10] === 0x42 && // B + b[11] === 0x50 // P + ); +} + +function parseWebp(b: Uint8Array): ImageMeta | null { + // WebP has three sub-formats: VP8 (simple), VP8L (lossless), VP8X + // (extended). Offset 12 is the 4-byte chunk identifier. + if (b.length < 30) return null; + const chunk = String.fromCodePoint(b[12]!, b[13]!, b[14]!, b[15]!); + if (chunk === 'VP8 ') { + // Frame data starts at offset 20 + 3-byte start code; dimensions at + // offsets 26-29 (little-endian, 14-bit values with 2 top bits masked). + const widthRaw = readUInt16LE(b, 26); + const heightRaw = readUInt16LE(b, 28); + const width = widthRaw & 0x3fff; + const height = heightRaw & 0x3fff; + if (width <= 0 || height <= 0) return null; + return { mime: 'image/webp', width, height }; + } + if (chunk === 'VP8L') { + // VP8L packs width-1 (14 bits) and height-1 (14 bits) across bytes 21-24 + // starting from the signature byte at offset 20 (0x2F). + if (b[20] !== 0x2f) return null; + const b1 = b[21]!; + const b2 = b[22]!; + const b3 = b[23]!; + const b4 = b[24]!; + const width = 1 + (((b2 & 0x3f) << 8) | b1); + const height = 1 + (((b4 & 0x0f) << 10) | (b3 << 2) | ((b2 & 0xc0) >> 6)); + if (width <= 0 || height <= 0) return null; + return { mime: 'image/webp', width, height }; + } + if (chunk === 'VP8X') { + // Canvas width-1 at offsets 24-26, height-1 at 27-29 (24-bit LE). + const width = 1 + readUInt24LE(b, 24); + const height = 1 + readUInt24LE(b, 27); + if (width <= 0 || height <= 0) return null; + return { mime: 'image/webp', width, height }; + } + return null; +} + +// ── byte helpers ──────────────────────────────────────────────────── + +function readUInt16BE(b: Uint8Array, off: number): number { + return (b[off]! << 8) | b[off + 1]!; +} +function readUInt16LE(b: Uint8Array, off: number): number { + return b[off]! | (b[off + 1]! << 8); +} +function readUInt24LE(b: Uint8Array, off: number): number { + return b[off]! | (b[off + 1]! << 8) | (b[off + 2]! << 16); +} +function readUInt32BE(b: Uint8Array, off: number): number { + return Math.trunc( + b[off]! * 0x100_0000 + (b[off + 1]! << 16) + (b[off + 2]! << 8) + b[off + 3]!, + ); +} diff --git a/apps/kimi-code/src/utils/paths.ts b/apps/kimi-code/src/utils/paths.ts new file mode 100644 index 000000000..b0375d376 --- /dev/null +++ b/apps/kimi-code/src/utils/paths.ts @@ -0,0 +1,55 @@ +/** + * CLI-owned data path helpers. + * + * These paths are for local app data such as logs and input history. Config + * files are owned by Core/SDK and intentionally do not live behind this module. + */ + +import { createHash } from 'node:crypto'; +import { homedir } from 'node:os'; +import { join } from 'node:path'; + +import { + KIMI_CODE_DATA_DIR_NAME, + KIMI_CODE_HOME_ENV, + KIMI_CODE_INPUT_HISTORY_DIR_NAME, + KIMI_CODE_LOG_DIR_NAME, + KIMI_CODE_UPDATE_DIR_NAME, + KIMI_CODE_UPDATE_STATE_FILE_NAME, +} from '#/constant/app'; + +/** + * Return the root data directory for Kimi Code. + * + * Priority: `KIMI_CODE_HOME` env var > `~/.kimi-code`. + */ +export function getDataDir(): string { + const envDir = process.env[KIMI_CODE_HOME_ENV]; + if (envDir) { + return envDir; + } + return join(homedir(), KIMI_CODE_DATA_DIR_NAME); +} + +/** + * Return the diagnostic log directory: `<dataDir>/logs/`. + */ +export function getLogDir(): string { + return join(getDataDir(), KIMI_CODE_LOG_DIR_NAME); +} + +/** + * Return the update cache file: `<dataDir>/updates/latest.json`. + */ +export function getUpdateStateFile(): string { + return join(getDataDir(), KIMI_CODE_UPDATE_DIR_NAME, KIMI_CODE_UPDATE_STATE_FILE_NAME); +} + +/** + * Return the user input history file for a given working directory. + * Layout: `<share_dir>/user-history/<md5(cwd)>.jsonl`. + */ +export function getInputHistoryFile(workDir: string): string { + const hash = createHash('md5').update(workDir, 'utf-8').digest('hex'); + return join(getDataDir(), KIMI_CODE_INPUT_HISTORY_DIR_NAME, `${hash}.jsonl`); +} diff --git a/apps/kimi-code/src/utils/persistence.ts b/apps/kimi-code/src/utils/persistence.ts new file mode 100644 index 000000000..a458ae02a --- /dev/null +++ b/apps/kimi-code/src/utils/persistence.ts @@ -0,0 +1,105 @@ +/** + * Small persistence helpers for CLI-owned data files. + * + * This module is intentionally for non-config files only. User-facing + * configuration is owned by core/SDK; do not route `config.toml` through + * these helpers. + */ + +import { appendFile, mkdir, readFile, rename, unlink, writeFile } from 'node:fs/promises'; +import { basename, dirname, join } from 'node:path'; + +import type { z } from 'zod'; + +function isNotFound(error: unknown): boolean { + return ( + typeof error === 'object' && error !== null && (error as { code?: string }).code === 'ENOENT' + ); +} + +function assertNonConfigWrite(filePath: string): void { + if (basename(filePath) === 'config.toml') { + throw new Error( + 'CLI persistence helpers must not write config.toml; use core/SDK config APIs.', + ); + } +} + +function tempPathFor(filePath: string): string { + const dir = dirname(filePath); + const base = basename(filePath); + const nonce = `${process.pid}.${Date.now()}.${Math.random().toString(16).slice(2)}`; + return join(dir, `.${base}.${nonce}.tmp`); +} + +export async function readJsonFile<T>( + filePath: string, + schema: z.ZodType<T>, + fallback: T, +): Promise<T> { + let raw: string; + try { + raw = await readFile(filePath, 'utf-8'); + } catch (error) { + if (isNotFound(error)) return fallback; + throw error; + } + const parsed = JSON.parse(raw) as unknown; + return schema.parse(parsed); +} + +export async function writeJsonFile<T>( + filePath: string, + schema: z.ZodType<T>, + value: T, +): Promise<void> { + assertNonConfigWrite(filePath); + const parsed = schema.parse(value); + await mkdir(dirname(filePath), { recursive: true }); + const tmpPath = tempPathFor(filePath); + try { + await writeFile(tmpPath, `${JSON.stringify(parsed, null, 2)}\n`, 'utf-8'); + await rename(tmpPath, filePath); + } catch (error) { + await unlink(tmpPath).catch(() => {}); + throw error; + } +} + +export async function readJsonlFile<T>( + filePath: string, + lineSchema: z.ZodType<T>, +): Promise<T[]> { + let raw: string; + try { + raw = await readFile(filePath, 'utf-8'); + } catch (error) { + if (isNotFound(error)) return []; + throw error; + } + + const entries: T[] = []; + for (const line of raw.split('\n')) { + const trimmed = line.trim(); + if (trimmed.length === 0) continue; + try { + const parsed = JSON.parse(trimmed) as unknown; + const result = lineSchema.safeParse(parsed); + if (result.success) entries.push(result.data); + } catch { + // JSONL is append-only user data; tolerate bad rows and keep the rest. + } + } + return entries; +} + +export async function appendJsonlLine<T>( + filePath: string, + lineSchema: z.ZodType<T>, + value: T, +): Promise<void> { + assertNonConfigWrite(filePath); + const parsed = lineSchema.parse(value); + await mkdir(dirname(filePath), { recursive: true }); + await appendFile(filePath, `${JSON.stringify(parsed)}\n`, 'utf-8'); +} diff --git a/apps/kimi-code/src/utils/process/external-editor.ts b/apps/kimi-code/src/utils/process/external-editor.ts new file mode 100644 index 000000000..7aa895d3c --- /dev/null +++ b/apps/kimi-code/src/utils/process/external-editor.ts @@ -0,0 +1,60 @@ +/** + * External-editor helper — spawn $VISUAL / $EDITOR (or a configured + * command) on a temp file seeded with the current editor buffer, then + * read the edited contents back. + * + * Resolution priority: + * configured (from Core/SDK defaults or `/editor`) > + * $VISUAL > $EDITOR > undefined (caller handles "no editor" toast). + */ + +import { spawn } from 'node:child_process'; +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +export function resolveEditorCommand(configured?: string | null): string | undefined { + const candidates = [configured, process.env['VISUAL'], process.env['EDITOR']]; + for (const c of candidates) { + if (typeof c === 'string' && c.trim().length > 0) { + return c.trim(); + } + } + return undefined; +} + +/** + * Launch `command` (tokenised via a shell) against a temp file seeded + * with `initialText`. Returns the edited contents on success, or + * `undefined` if the editor exited non-zero / the file disappeared. + * + * The command is passed to `/bin/sh -c "<cmd> <tmpfile>"` so users can + * supply argv-style strings like `"code --wait"` or `"nvim +set ft=markdown"`. + */ +export async function editInExternalEditor( + initialText: string, + command: string, +): Promise<string | undefined> { + const dir = await mkdtemp(join(tmpdir(), 'kimi-edit-')); + const file = join(dir, 'prompt.md'); + await writeFile(file, initialText, 'utf-8'); + try { + const code = await new Promise<number>((resolve, reject) => { + const shellCmd = `${command} ${shellQuote(file)}`; + const child = spawn('/bin/sh', ['-c', shellCmd], { stdio: 'inherit' }); + child.on('exit', (c) =>{ resolve(c ?? 0); }); + child.on('error', reject); + }); + if (code !== 0) return undefined; + return await readFile(file, 'utf-8'); + } finally { + await rm(dir, { recursive: true, force: true }).catch(() => { + // best-effort cleanup + }); + } +} + +function shellQuote(path: string): string { + // Single-quote and escape any embedded single quotes. + return `'${path.replaceAll('\'', "'\\''")}'`; +} diff --git a/apps/kimi-code/src/utils/process/fd-detect.ts b/apps/kimi-code/src/utils/process/fd-detect.ts new file mode 100644 index 000000000..d8fc662b5 --- /dev/null +++ b/apps/kimi-code/src/utils/process/fd-detect.ts @@ -0,0 +1,28 @@ +/** + * Probe for the `fd` binary so the pi-tui `CombinedAutocompleteProvider` + * can enable its cross-directory fuzzy file search. + * + * Naming differs across distros: + * - Homebrew / Arch / most Linuxes: `fd` + * - Debian / Ubuntu: `fdfind` + * + * We use `spawnSync(..., { stdio: 'ignore' })` rather than shelling out + * to `which` so the check doesn't depend on the parent shell's PATH + * resolution semantics and stays cheap (~ms) on startup. + */ + +import { spawnSync } from 'node:child_process'; + +const CANDIDATES = ['fd', 'fdfind']; + +export function detectFdPath(): string | null { + for (const name of CANDIDATES) { + try { + const result = spawnSync(name, ['--version'], { stdio: 'ignore' }); + if (result.status === 0) return name; + } catch { + // ENOENT, EACCES, etc. — try next candidate + } + } + return null; +} diff --git a/apps/kimi-code/src/utils/process/proctitle.ts b/apps/kimi-code/src/utils/process/proctitle.ts new file mode 100644 index 000000000..b731233af --- /dev/null +++ b/apps/kimi-code/src/utils/process/proctitle.ts @@ -0,0 +1,31 @@ +/** + * Early-startup process name initialization. + * + * Sets the process title so `ps`/`top` and the terminal tab show + * `Kimi Code` from the moment the binary launches — before Commander + * parses argv, before any preflight, even on `--help`/`--version`. + * + * OSC is written to stderr (not stdout) so it still reaches the terminal + * when stdout is piped, e.g. `kimi --print | grep ...`. + */ +import { PRODUCT_NAME } from '#/constant/app'; +import { BEL, ESC } from '#/constant/terminal'; + +export function setProcessTitle(label: string): void { + try { + process.title = label; + } catch { + /* noop */ + } + try { + if (process.stderr.isTTY) { + process.stderr.write(`${ESC}]0;${label}${BEL}`); + } + } catch { + /* noop */ + } +} + +export function initProcessName(name: string = PRODUCT_NAME): void { + setProcessTitle(name); +} diff --git a/apps/kimi-code/src/utils/process/stdin.ts b/apps/kimi-code/src/utils/process/stdin.ts new file mode 100644 index 000000000..c59792f2a --- /dev/null +++ b/apps/kimi-code/src/utils/process/stdin.ts @@ -0,0 +1,18 @@ +import { createInterface } from 'node:readline'; + +export function readStdinText(): Promise<string> { + return new Promise((resolve, reject) => { + const chunks: Buffer[] = []; + process.stdin.on('data', (chunk: Buffer) => chunks.push(chunk)); + process.stdin.on('end', () =>{ resolve(Buffer.concat(chunks).toString('utf-8').trim()); }); + process.stdin.on('error', reject); + process.stdin.resume(); + }); +} + +export async function* createStdinLineReader(): AsyncIterable<string> { + const rl = createInterface({ input: process.stdin, crlfDelay: Infinity }); + for await (const line of rl) { + yield line; + } +} diff --git a/apps/kimi-code/src/utils/usage/usage-format.ts b/apps/kimi-code/src/utils/usage/usage-format.ts new file mode 100644 index 000000000..34db897a2 --- /dev/null +++ b/apps/kimi-code/src/utils/usage/usage-format.ts @@ -0,0 +1,37 @@ +/** + * Formatting helpers for the `/usage` slash command. + * + * Kept pure + ANSI-free so they're trivial to unit-test; the slash + * command itself chalks the colour afterwards. + */ + +export function formatTokenCount(n: number): string { + if (!Number.isFinite(n) || n < 0) return '0'; + if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`; + if (n >= 1_000) return `${(n / 1_000).toFixed(1)}k`; + return String(Math.round(n)); +} + +/** + * Build a `[███░░░░░░░]` style bar. Returns a plain-ASCII string with + * `filled`/`empty` glyphs — colouring is the caller's responsibility. + */ +export function renderProgressBar(ratio: number, width = 20, filled = '█', empty = '░'): string { + const clamped = safeUsageRatio(ratio); + const filledCount = Math.round(clamped * width); + return filled.repeat(filledCount) + empty.repeat(Math.max(0, width - filledCount)); +} + +export function safeUsageRatio(ratio: number): number { + return Number.isFinite(ratio) ? Math.max(0, Math.min(ratio, 1)) : 0; +} + +/** + * Map a usage ratio to a semantic colour token — the `/usage` renderer + * translates these into palette hex values. + */ +export function ratioSeverity(ratio: number): 'ok' | 'warn' | 'danger' { + if (ratio >= 0.85) return 'danger'; + if (ratio >= 0.5) return 'warn'; + return 'ok'; +} diff --git a/apps/kimi-code/test/cli/export.test.ts b/apps/kimi-code/test/cli/export.test.ts new file mode 100644 index 000000000..4a124cc80 --- /dev/null +++ b/apps/kimi-code/test/cli/export.test.ts @@ -0,0 +1,446 @@ +/** + * `kimi export` + * + * Verifies the CLI layer: argument handling, previous-session confirmation, + * error reporting, and delegation to the session export implementation. + */ + +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import type { createKimiDeviceId as createKimiDeviceIdFn } from '@moonshot-ai/kimi-code-oauth'; +import { Command } from 'commander'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { handleExport, registerExportCommand } from '#/cli/sub/export'; +import type { ExportDeps } from '#/cli/sub/export'; +import type { + ExportSessionInput, + ExportSessionManifest, + ExportSessionResult, + SessionSummary, +} from '@moonshot-ai/kimi-code-sdk'; + +let tmp: string; + +type CreateKimiDeviceId = typeof createKimiDeviceIdFn; + +const mocks = vi.hoisted(() => ({ + kimiHarnessConstructor: vi.fn(), + harnessEnsureConfigFile: vi.fn(), + harnessGetConfig: vi.fn(async () => ({ + providers: {}, + defaultModel: 'k2', + telemetry: true, + })), + harnessGetCachedAccessToken: vi.fn(), + harnessExportSession: vi.fn(), + createKimiDeviceId: vi.fn<CreateKimiDeviceId>(() => 'device-1'), + initializeTelemetry: vi.fn(), + shutdownTelemetry: vi.fn(), + telemetryTrack: vi.fn(), + setTelemetryContext: vi.fn(), + withTelemetryContext: vi.fn(), +})); + +vi.mock('@moonshot-ai/kimi-code-sdk', async (importOriginal) => { + const actual = await importOriginal<typeof import('@moonshot-ai/kimi-code-sdk')>(); + return { + ...actual, + KimiHarness: class { + homeDir = '/tmp/kimi-export-home'; + auth = { + getCachedAccessToken: mocks.harnessGetCachedAccessToken, + }; + ensureConfigFile = mocks.harnessEnsureConfigFile; + getConfig = mocks.harnessGetConfig; + constructor(...args: unknown[]) { + mocks.kimiHarnessConstructor(...args); + } + + exportSession = mocks.harnessExportSession; + }, + }; +}); + +vi.mock('@moonshot-ai/kimi-code-oauth', async () => { + const actual = await vi.importActual<typeof import('@moonshot-ai/kimi-code-oauth')>( + '@moonshot-ai/kimi-code-oauth', + ); + return { + ...actual, + createKimiDeviceId: mocks.createKimiDeviceId, + KIMI_CODE_PROVIDER_NAME: 'kimi-code', + }; +}); + +vi.mock('@moonshot-ai/kimi-telemetry', () => ({ + initializeTelemetry: mocks.initializeTelemetry, + shutdownTelemetry: mocks.shutdownTelemetry, + track: mocks.telemetryTrack, + setTelemetryContext: mocks.setTelemetryContext, + withTelemetryContext: mocks.withTelemetryContext, +})); + +beforeEach(() => { + tmp = mkdtempSync(join(tmpdir(), 'kimi-export-')); +}); + +afterEach(() => { + rmSync(tmp, { recursive: true, force: true }); + vi.clearAllMocks(); + mocks.harnessGetConfig.mockResolvedValue({ + providers: {}, + defaultModel: 'k2', + telemetry: true, + }); +}); + +function makeSummary(id: string, overrides: Partial<SessionSummary> = {}): SessionSummary { + return { + id, + workDir: tmp, + sessionDir: join(tmp, 'sessions', id), + createdAt: 1, + updatedAt: 2, + ...overrides, + }; +} + +function makeResult(id: string, zipPath: string): ExportSessionResult { + const manifest: ExportSessionManifest = { + sessionId: id, + exportedAt: '2026-04-18T12:00:00.000Z', + kimiCodeVersion: '1.27.0', + wireProtocolVersion: '1.0', + os: 'test', + nodejsVersion: '22.0.0', + workspaceDir: tmp, + }; + return { + zipPath, + entries: ['manifest.json', 'wire.jsonl'], + sessionDir: join(tmp, 'sessions', id), + manifest, + }; +} + +function makeDeps(overrides: Partial<ExportDeps> = {}): { + deps: ExportDeps; + stdout: string[]; + stderr: string[]; + exitCodes: number[]; + exportInputs: ExportSessionInput[]; + listedWorkDirs: string[]; +} { + const stdout: string[] = []; + const stderr: string[] = []; + const exitCodes: number[] = []; + const exportInputs: ExportSessionInput[] = []; + const listedWorkDirs: string[] = []; + const deps: ExportDeps = { + listSessions: async (workDir) => { + listedWorkDirs.push(workDir); + return []; + }, + exportSession: async (input) => { + exportInputs.push(input); + return makeResult(input.id, input.outputPath ?? join(tmp, `${input.id}.zip`)); + }, + confirmPreviousSession: async () => true, + version: '1.0.0-test', + cwd: () => tmp, + stdout: { + write: (chunk: string) => { + stdout.push(chunk); + return true; + }, + }, + stderr: { + write: (chunk: string) => { + stderr.push(chunk); + return true; + }, + }, + exit: ((code: number) => { + exitCodes.push(code); + throw new ExitCalled(code); + }) as ExportDeps['exit'], + ...overrides, + }; + return { deps, stdout, stderr, exitCodes, exportInputs, listedWorkDirs }; +} + +class ExitCalled extends Error { + constructor(public readonly code: number) { + super(`exit(${code})`); + } +} + +async function runExport( + deps: ExportDeps, + args: { + sessionId?: string; + output?: string; + yes?: boolean; + includeGlobalLog?: boolean; + } = {}, +): Promise<void> { + try { + await handleExport(deps, args.sessionId, args.output, { + yes: args.yes ?? false, + includeGlobalLog: args.includeGlobalLog ?? true, + }); + } catch (error) { + if (error instanceof ExitCalled) return; + throw error; + } +} + +describe('kimi export', () => { + it('delegates a named session export and prints the resulting zip path', async () => { + const output = join(tmp, 'out.zip'); + const { deps, stdout, stderr, exitCodes, exportInputs, listedWorkDirs } = makeDeps(); + + await runExport(deps, { sessionId: 'ses_test123456', output }); + + expect(exitCodes).toEqual([]); + expect(stderr).toEqual([]); + expect(listedWorkDirs).toEqual([]); + expect(exportInputs).toEqual([{ id: 'ses_test123456', outputPath: output, includeGlobalLog: true, version: '1.0.0-test' }]); + expect(stdout.join('').trim()).toBe(output); + }); + + it('omits outputPath when the caller does not provide --output', async () => { + const { deps, stdout, exportInputs } = makeDeps(); + + await runExport(deps, { sessionId: 'session_default_output' }); + + expect(exportInputs).toEqual([{ id: 'session_default_output', includeGlobalLog: true, version: '1.0.0-test' }]); + expect(stdout.join('').trim()).toBe(join(tmp, 'session_default_output.zip')); + }); + + it('exits 1 when no session-id is provided and no previous session exists', async () => { + const { deps, stderr, exitCodes, exportInputs, listedWorkDirs } = makeDeps(); + + await runExport(deps); + + expect(listedWorkDirs).toEqual([tmp]); + expect(exportInputs).toEqual([]); + expect(exitCodes).toContain(1); + expect(stderr.join('').toLowerCase()).toContain('no previous session'); + }); + + it('surfaces export errors for a named session', async () => { + const { deps, stderr, exitCodes } = makeDeps({ + exportSession: async () => { + throw new Error('Session "ses_does_not_exist" was not found'); + }, + }); + + await runExport(deps, { sessionId: 'ses_does_not_exist' }); + + expect(exitCodes).toContain(1); + expect(stderr.join('').toLowerCase()).toContain('not found'); + }); + + it('falls back to the most-recent session when no id is supplied', async () => { + const previous = makeSummary('ses_fallback'); + const output = join(tmp, 'fallback.zip'); + const { deps, stdout, exitCodes, exportInputs } = makeDeps({ + listSessions: async () => [previous], + }); + + await runExport(deps, { output }); + + expect(exitCodes).toEqual([]); + expect(exportInputs).toEqual([{ id: 'ses_fallback', outputPath: output, includeGlobalLog: true, version: '1.0.0-test' }]); + expect(stdout.join('').trim()).toBe(output); + }); + + it('confirms before exporting the previous session when no id is supplied', async () => { + const previous = makeSummary('ses_confirm', { title: 'Prod debug' }); + const summaries: unknown[] = []; + const { deps, stdout, exitCodes, exportInputs } = makeDeps({ + listSessions: async () => [previous], + confirmPreviousSession: async (summary) => { + summaries.push(summary); + return false; + }, + }); + + await runExport(deps, { output: join(tmp, 'cancelled.zip') }); + + expect(exitCodes).toEqual([]); + expect(exportInputs).toEqual([]); + expect(stdout.join('')).toContain('Export cancelled.'); + expect(summaries).toEqual([ + { + workDir: tmp, + sessionId: 'ses_confirm', + sessionDir: join(tmp, 'sessions', 'ses_confirm'), + title: 'Prod debug', + }, + ]); + }); + + it('skips previous-session confirmation with --yes', async () => { + const previous = makeSummary('ses_yes'); + const { deps, exitCodes, exportInputs } = makeDeps({ + listSessions: async () => [previous], + confirmPreviousSession: async () => { + throw new Error('confirm should not be called'); + }, + }); + + await runExport(deps, { output: join(tmp, 'yes.zip'), yes: true }); + + expect(exitCodes).toEqual([]); + expect(exportInputs).toEqual([{ id: 'ses_yes', outputPath: join(tmp, 'yes.zip'), includeGlobalLog: true, version: '1.0.0-test' }]); + }); + + it('describes the user-facing command without implementation details', () => { + const program = new Command('kimi'); + const { deps } = makeDeps(); + + registerExportCommand(program, deps); + + const command = program.commands.find((item) => item.name() === 'export'); + expect(command?.description()).toBe('Export a session as a ZIP archive.'); + expect(command?.description()).not.toMatch(/sdk/i); + }); + + it('parses --no-include-global-log as an option when no session id is given', async () => { + const previous = makeSummary('ses_global_log'); + const { deps, stdout, exitCodes, exportInputs } = makeDeps({ + listSessions: async () => [previous], + confirmPreviousSession: async () => true, + }); + const program = new Command('kimi'); + registerExportCommand(program, deps); + + await program.parseAsync(['node', 'kimi', 'export', '--no-include-global-log', '-y']); + + expect(exitCodes).toEqual([]); + expect(exportInputs).toEqual([{ id: 'ses_global_log', version: '1.0.0-test' }]); + expect(stdout.join('').trim()).toBe(join(tmp, 'ses_global_log.zip')); + }); + + it('parses options after an explicit session id', async () => { + const output = join(tmp, 'after-id.zip'); + const { deps, exitCodes, exportInputs } = makeDeps(); + const program = new Command('kimi'); + registerExportCommand(program, deps); + + await program.parseAsync([ + 'node', + 'kimi', + 'export', + 'ses_after_id', + '-o', + output, + '-y', + '--no-include-global-log', + ]); + + expect(exitCodes).toEqual([]); + expect(exportInputs).toEqual([ + { id: 'ses_after_id', outputPath: output, version: '1.0.0-test' }, + ]); + }); + + it('initializes and flushes telemetry around default export tracking', async () => { + const program = new Command('kimi'); + const output = join(tmp, 'telemetry.zip'); + mocks.harnessExportSession.mockResolvedValue(makeResult('ses_telemetry', output)); + + registerExportCommand(program, { + cwd: () => tmp, + stdout: { + write: () => true, + }, + stderr: { + write: () => true, + }, + exit: ((code: number) => { + throw new ExitCalled(code); + }) as ExportDeps['exit'], + }); + + await program.parseAsync(['node', 'kimi', 'export', 'ses_telemetry', '--output', output], { + from: 'node', + }); + + expect(mocks.kimiHarnessConstructor).toHaveBeenCalledWith( + expect.objectContaining({ + telemetry: { + track: mocks.telemetryTrack, + setContext: mocks.setTelemetryContext, + withContext: mocks.withTelemetryContext, + }, + }), + ); + expect(mocks.harnessEnsureConfigFile).toHaveBeenCalledOnce(); + expect(mocks.harnessGetConfig).toHaveBeenCalledOnce(); + expect(mocks.createKimiDeviceId).toHaveBeenCalledWith('/tmp/kimi-export-home'); + expect(mocks.initializeTelemetry).toHaveBeenCalledWith({ + homeDir: '/tmp/kimi-export-home', + deviceId: 'device-1', + enabled: true, + appName: 'kimi-code-cli', + version: expect.any(String), + uiMode: 'shell', + model: 'k2', + getAccessToken: expect.any(Function), + }); + expect(mocks.initializeTelemetry.mock.invocationCallOrder[0]).toBeLessThan( + mocks.harnessExportSession.mock.invocationCallOrder[0]!, + ); + expect(mocks.harnessExportSession).toHaveBeenCalledWith({ + id: 'ses_telemetry', + outputPath: output, + version: expect.any(String), + includeGlobalLog: true, + }); + expect(mocks.shutdownTelemetry).toHaveBeenCalledWith({ timeoutMs: 3000 }); + expect(mocks.harnessExportSession.mock.invocationCallOrder[0]).toBeLessThan( + mocks.shutdownTelemetry.mock.invocationCallOrder[0]!, + ); + }); + + it('passes enabled false when default export config disables telemetry', async () => { + const program = new Command('kimi'); + const output = join(tmp, 'telemetry-disabled.zip'); + mocks.harnessGetConfig.mockResolvedValue({ + providers: {}, + defaultModel: 'k2', + telemetry: false, + }); + mocks.harnessExportSession.mockResolvedValue(makeResult('ses_disabled', output)); + + registerExportCommand(program, { + cwd: () => tmp, + stdout: { + write: () => true, + }, + stderr: { + write: () => true, + }, + exit: ((code: number) => { + throw new ExitCalled(code); + }) as ExportDeps['exit'], + }); + + await program.parseAsync(['node', 'kimi', 'export', 'ses_disabled', '--output', output], { + from: 'node', + }); + + expect(mocks.initializeTelemetry).toHaveBeenCalledWith( + expect.objectContaining({ + enabled: false, + }), + ); + expect(mocks.shutdownTelemetry).toHaveBeenCalledWith({ timeoutMs: 3000 }); + }); +}); diff --git a/apps/kimi-code/test/cli/main.test.ts b/apps/kimi-code/test/cli/main.test.ts new file mode 100644 index 000000000..8ab1ea976 --- /dev/null +++ b/apps/kimi-code/test/cli/main.test.ts @@ -0,0 +1,211 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { ErrorCodes, KimiError } from '@moonshot-ai/kimi-code-sdk'; + +import { validateOptions } from '#/cli/options'; +import type { CLIOptions } from '#/cli/options'; +import type * as OptionsModule from '#/cli/options'; +import { runPrompt } from '#/cli/run-prompt'; +import { runShell } from '#/cli/run-shell'; +import { formatStartupError } from '#/cli/startup-error'; +import { runUpdatePreflight } from '#/cli/update/preflight'; +import { handleMainCommand, main } from '#/main'; + +const mocks = vi.hoisted(() => { + const parse = vi.fn(); + return { + parse, + createProgram: vi.fn(() => ({ parse })), + getVersion: vi.fn(() => '0.0.1-alpha.2'), + validateOptions: vi.fn(), + runUpdatePreflight: vi.fn(), + runShell: vi.fn(), + runPrompt: vi.fn(), + installCrashHandlers: vi.fn(), + }; +}); + +vi.mock('@moonshot-ai/kimi-telemetry', () => ({ + installCrashHandlers: mocks.installCrashHandlers, + track: vi.fn(), +})); + +vi.mock('../../src/cli/commands', () => ({ + createProgram: mocks.createProgram, +})); + +vi.mock('../../src/cli/version', () => ({ + getVersion: mocks.getVersion, +})); + +vi.mock('../../src/cli/options', async () => { + const actual = await vi.importActual<typeof OptionsModule>('../../src/cli/options.js'); + return { + ...actual, + validateOptions: mocks.validateOptions, + }; +}); + +vi.mock('../../src/cli/update/preflight', () => ({ + runUpdatePreflight: mocks.runUpdatePreflight, +})); + +vi.mock('../../src/cli/run-shell', () => ({ + runShell: mocks.runShell, +})); + +vi.mock('../../src/cli/run-prompt', () => ({ + runPrompt: mocks.runPrompt, +})); + +class ExitCalled extends Error { + constructor(readonly code: number) { + super(`exit(${code})`); + } +} + +function defaultOpts(): CLIOptions { + return { + session: undefined, + continue: false, + yolo: false, + plan: false, + model: undefined, + outputFormat: undefined, + prompt: undefined, + skillsDirs: [], + }; +} + +async function runHandleMainCommand(opts: CLIOptions): Promise<number | null> { + const exitSpy = vi.spyOn(process, 'exit').mockImplementation((code?: string | number | null) => { + throw new ExitCalled(Number(code ?? 0)); + }); + try { + await handleMainCommand(opts, '0.0.1-alpha.2'); + return null; + } catch (error) { + if (error instanceof ExitCalled) { + return error.code; + } + throw error; + } finally { + exitSpy.mockRestore(); + } +} + +describe('main entry command handling', () => { + afterEach(() => { + vi.clearAllMocks(); + }); + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('runs update preflight before starting the shell', async () => { + const opts = defaultOpts(); + mocks.validateOptions.mockReturnValue({ options: opts, uiMode: 'shell' }); + mocks.runUpdatePreflight.mockResolvedValue('continue'); + mocks.runShell.mockResolvedValue(void 0); + + const exitCode = await runHandleMainCommand(opts); + + expect(exitCode).toBeNull(); + expect(validateOptions).toHaveBeenCalledWith(opts); + expect(runUpdatePreflight).toHaveBeenCalledWith('0.0.1-alpha.2', { track: expect.any(Function) }); + expect(mocks.runUpdatePreflight.mock.invocationCallOrder[0]).toBeLessThan( + mocks.runShell.mock.invocationCallOrder[0]!, + ); + expect(runShell).toHaveBeenCalledWith(opts, '0.0.1-alpha.2'); + }); + + it('runs prompt mode without interactive update preflight', async () => { + const opts: CLIOptions = { + ...defaultOpts(), + prompt: 'explain the repo', + }; + mocks.validateOptions.mockReturnValue({ options: opts, uiMode: 'print' }); + mocks.runUpdatePreflight.mockResolvedValue('continue'); + mocks.runPrompt.mockResolvedValue(void 0); + + const exitCode = await runHandleMainCommand(opts); + + expect(exitCode).toBeNull(); + expect(runUpdatePreflight).toHaveBeenCalledWith('0.0.1-alpha.2', { + track: expect.any(Function), + isTTY: false, + }); + expect(runPrompt).toHaveBeenCalledWith(opts, '0.0.1-alpha.2'); + expect(runShell).not.toHaveBeenCalled(); + }); + + it('keeps shell mode update preflight interactive by default', async () => { + const opts = defaultOpts(); + mocks.validateOptions.mockReturnValue({ options: opts, uiMode: 'shell' }); + mocks.runUpdatePreflight.mockResolvedValue('continue'); + mocks.runShell.mockResolvedValue(void 0); + + const exitCode = await runHandleMainCommand(opts); + + expect(exitCode).toBeNull(); + expect(runUpdatePreflight).toHaveBeenCalledWith('0.0.1-alpha.2', { + track: expect.any(Function), + }); + expect(runShell).toHaveBeenCalledWith(opts, '0.0.1-alpha.2'); + }); + + it('installs crash handlers before parsing CLI arguments', () => { + main(); + + expect(mocks.installCrashHandlers).toHaveBeenCalledTimes(1); + expect(mocks.installCrashHandlers.mock.invocationCallOrder[0]).toBeLessThan( + mocks.createProgram.mock.invocationCallOrder[0]!, + ); + expect(mocks.parse).toHaveBeenCalledWith(process.argv); + }); + + it('exits early when update preflight requests process exit', async () => { + const opts = defaultOpts(); + mocks.validateOptions.mockReturnValue({ options: opts, uiMode: 'shell' }); + mocks.runUpdatePreflight.mockResolvedValue('exit'); + mocks.runShell.mockResolvedValue(void 0); + + const exitCode = await runHandleMainCommand(opts); + + expect(exitCode).toBe(0); + expect(runShell).not.toHaveBeenCalled(); + }); + + it('formats Kimi startup errors with structured fields', () => { + const error = new KimiError( + ErrorCodes.SHELL_GIT_BASH_NOT_FOUND, + 'Git Bash was not found on this Windows host. Checked: C:\\Program Files\\Git\\bin\\bash.exe.', + ); + const red = (text: string): string => `\u001B[31m${text}\u001B[39m`; + + expect(formatStartupError(error, { errorStyle: red })).toBe( + [ + '\u001B[31merror: Git Bash not found\u001B[39m', + '', + '\u001B[31mmessage:\u001B[39m', + '\u001B[31mGit Bash was not found on this Windows host. Checked: C:\\Program Files\\Git\\bin\\bash.exe.\u001B[39m', + '', + ].join('\n'), + ); + }); + + it('keeps generic startup errors on the legacy fallback path', () => { + expect(formatStartupError(new Error('Provider not set'), { errorStyle: (text) => text })).toBe( + 'error: failed to start shell: Provider not set\n', + ); + }); + + it('formats generic prompt mode errors without saying shell', () => { + expect( + formatStartupError(new Error('Provider not set'), { + errorStyle: (text) => text, + operation: 'run prompt', + }), + ).toBe('error: failed to run prompt: Provider not set\n'); + }); +}); diff --git a/apps/kimi-code/test/cli/options.test.ts b/apps/kimi-code/test/cli/options.test.ts new file mode 100644 index 000000000..a98268ced --- /dev/null +++ b/apps/kimi-code/test/cli/options.test.ts @@ -0,0 +1,256 @@ +import { describe, expect, it } from 'vitest'; + +import { createProgram } from '#/cli/commands'; +import type { CLIOptions } from '#/cli/options'; +import { OptionConflictError, validateOptions } from '#/cli/options'; + +function parse(argv: string[]): CLIOptions { + let captured: CLIOptions | undefined; + + const program = createProgram( + '0.1.0-test', + (opts) => { + captured = opts; + }, + () => {}, + ); + + program.exitOverride(); + program.configureOutput({ + writeOut: () => {}, + writeErr: () => {}, + }); + + program.parse(['node', 'kimi', ...argv]); + + if (captured === undefined) { + throw new Error('Main action handler was not called'); + } + return captured; +} + +describe('CLI options parsing', () => { + describe('defaults', () => { + it('returns defaults when no arguments are given', () => { + const opts = parse([]); + expect(opts.yolo).toBe(false); + expect(opts.plan).toBe(false); + expect(opts.continue).toBe(false); + expect(opts.session).toBeUndefined(); + expect(opts.model).toBeUndefined(); + expect(opts.outputFormat).toBeUndefined(); + expect(opts.prompt).toBeUndefined(); + expect(opts.skillsDirs).toEqual([]); + }); + }); + + describe('--version', () => { + it('prints the version string and exits', () => { + let output = ''; + const program = createProgram('1.2.3', () => {}, () => {}); + program.exitOverride(); + program.configureOutput({ + writeOut: (s) => { + output += s; + }, + }); + + expect(() => program.parse(['node', 'kimi', '--version'])).toThrow(); + expect(output).toContain('1.2.3'); + }); + + it('supports -V as a short alias', () => { + let output = ''; + const program = createProgram('4.5.6', () => {}, () => {}); + program.exitOverride(); + program.configureOutput({ + writeOut: (s) => { + output += s; + }, + }); + + expect(() => program.parse(['node', 'kimi', '-V'])).toThrow(); + expect(output).toContain('4.5.6'); + }); + }); + + describe('--yolo family', () => { + it('--yolo sets yolo to true', () => { + expect(parse(['--yolo']).yolo).toBe(true); + }); + + it('-y sets yolo to true', () => { + expect(parse(['-y']).yolo).toBe(true); + }); + + it('--yes sets yolo to true (hidden alias)', () => { + expect(parse(['--yes']).yolo).toBe(true); + }); + + it('--auto-approve sets yolo to true (hidden alias)', () => { + expect(parse(['--auto-approve']).yolo).toBe(true); + }); + }); + + describe('--session / --resume / --continue', () => { + it('-S sets session', () => { + expect(parse(['-S', 'sess-123']).session).toBe('sess-123'); + }); + + it('-r is an alias for --session', () => { + expect(parse(['-r', 'sess-456']).session).toBe('sess-456'); + }); + + it('--resume is an alias for --session', () => { + expect(parse(['--resume', 'sess-789']).session).toBe('sess-789'); + }); + + it('bare -S (no id) yields empty string — triggers the picker', () => { + expect(parse(['-S']).session).toBe(''); + }); + + it('-C sets continue', () => { + expect(parse(['-C']).continue).toBe(true); + }); + + it('--continue and --session combined raises a conflict', () => { + const opts = parse(['--continue', '--session', 'abc123']); + expect(() => validateOptions(opts)).toThrow(OptionConflictError); + expect(() => validateOptions(opts)).toThrow('Cannot combine --continue, --session.'); + }); + }); + + describe('--plan', () => { + it('sets plan mode flag', () => { + expect(parse(['--plan']).plan).toBe(true); + }); + }); + + describe('--model / -m', () => { + it('parses -m as a model override', () => { + expect(parse(['-m', 'kimi-code/k2']).model).toBe('kimi-code/k2'); + }); + + it('parses --model=value as a model override', () => { + expect(parse(['--model=kimi-code/k2.5']).model).toBe('kimi-code/k2.5'); + }); + + it('rejects empty model values', () => { + const opts = parse(['--model', ' ']); + expect(() => validateOptions(opts)).toThrow(OptionConflictError); + expect(() => validateOptions(opts)).toThrow('Model cannot be empty.'); + }); + }); + + describe('--prompt / -p', () => { + it('parses -p as prompt mode', () => { + const opts = parse(['-p', 'explain this repo']); + expect(opts.prompt).toBe('explain this repo'); + expect(validateOptions(opts).uiMode).toBe('print'); + }); + + it('parses --prompt=value as prompt mode', () => { + const opts = parse(['--prompt=explain this repo']); + expect(opts.prompt).toBe('explain this repo'); + expect(validateOptions(opts).uiMode).toBe('print'); + }); + + it('rejects empty prompt values before reaching the SDK', () => { + const opts = parse(['-p', ' ']); + expect(() => validateOptions(opts)).toThrow(OptionConflictError); + expect(() => validateOptions(opts)).toThrow('Prompt cannot be empty.'); + }); + + it('allows prompt mode with --continue', () => { + const opts = parse(['-p', 'continue here', '--continue']); + expect(opts.continue).toBe(true); + expect(validateOptions(opts).uiMode).toBe('print'); + }); + + it('allows prompt mode with a concrete session id', () => { + const opts = parse(['-p', 'resume here', '--session', 'ses_123']); + expect(opts.session).toBe('ses_123'); + expect(validateOptions(opts).uiMode).toBe('print'); + }); + + it('rejects prompt mode with bare --session picker', () => { + const opts = parse(['-p', 'resume here', '--session']); + expect(() => validateOptions(opts)).toThrow(OptionConflictError); + expect(() => validateOptions(opts)).toThrow('Cannot use --session without an id in prompt mode.'); + }); + + it('rejects prompt mode with --yolo because prompt mode always uses auto permission', () => { + const opts = parse(['-p', 'run this', '--yolo']); + expect(() => validateOptions(opts)).toThrow(OptionConflictError); + expect(() => validateOptions(opts)).toThrow('Cannot combine --prompt with --yolo.'); + }); + + it('rejects prompt mode with --plan', () => { + const opts = parse(['-p', 'run this', '--plan']); + expect(() => validateOptions(opts)).toThrow(OptionConflictError); + expect(() => validateOptions(opts)).toThrow('Cannot combine --prompt with --plan.'); + }); + + it('parses --output-format=stream-json in prompt mode', () => { + const opts = parse(['-p', 'run this', '--output-format=stream-json']); + expect(opts.outputFormat).toBe('stream-json'); + expect(validateOptions(opts).uiMode).toBe('print'); + }); + + it('parses --output-format text in prompt mode', () => { + const opts = parse(['-p', 'run this', '--output-format', 'text']); + expect(opts.outputFormat).toBe('text'); + }); + + it('rejects --output-format outside prompt mode', () => { + const opts = parse(['--output-format=stream-json']); + expect(() => validateOptions(opts)).toThrow(OptionConflictError); + expect(() => validateOptions(opts)).toThrow( + 'Output format is only supported in prompt mode.', + ); + }); + }); + + describe('--skills-dir', () => { + it('collects repeated skill directories', () => { + expect(parse(['--skills-dir', '/one', '--skills-dir=/two']).skillsDirs).toEqual([ + '/one', + '/two', + ]); + }); + }); + + describe('sub-commands', () => { + it('registers the diagnostic sub-commands during alpha', () => { + const program = createProgram('0.0.0', () => {}, () => {}); + const commandNames: string[] = program.commands.map((command) => command.name()); + expect(commandNames).toEqual(['export', 'migrate']); + }); + }); + + describe('rejected flags', () => { + it('any removed flag is unknown to Commander', () => { + for (const arg of [ + '--verbose', + '--debug', + '--work-dir=/', + '--config=x', + '--thinking', + '--print', + '--wire', + '--agent=default', + '--add-dir=/', + '--raw-model', + '--config-file=x', + '--quiet', + '--final-message-only', + '--input-format=text', + '--agent-file=x', + '--mcp-config={}', + '--mcp-config-file=/', + ]) { + expect(() => parse([arg])).toThrow(); + } + }); + }); +}); diff --git a/apps/kimi-code/test/cli/run-prompt.test.ts b/apps/kimi-code/test/cli/run-prompt.test.ts new file mode 100644 index 000000000..0169acba3 --- /dev/null +++ b/apps/kimi-code/test/cli/run-prompt.test.ts @@ -0,0 +1,689 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { runPrompt } from '#/cli/run-prompt'; + +const mocks = vi.hoisted(() => { + const eventHandlers = new Set<(event: any) => void>(); + const agentEvent = (agentId: string, event: Record<string, unknown>) => ({ + sessionId: 'ses_prompt', + agentId, + ...event, + }); + const mainEvent = (event: Record<string, unknown>) => agentEvent('main', event); + const session = { + id: 'ses_prompt', + setModel: vi.fn(), + setPermission: vi.fn(), + setApprovalHandler: vi.fn(), + setQuestionHandler: vi.fn(), + getStatus: vi.fn( + async (): Promise<{ readonly permission: string; readonly model?: string }> => ({ + permission: 'manual', + }), + ), + onEvent: vi.fn((handler: (event: any) => void) => { + eventHandlers.add(handler); + return () => eventHandlers.delete(handler); + }), + prompt: vi.fn(async () => { + for (const handler of eventHandlers) { + handler( + mainEvent({ type: 'turn.started', turnId: 1, origin: { kind: 'user' } }), + ); + handler(mainEvent({ type: 'assistant.delta', turnId: 1, delta: 'hello' })); + handler(mainEvent({ type: 'assistant.delta', turnId: 1, delta: ' world' })); + handler(mainEvent({ type: 'turn.ended', turnId: 1, reason: 'completed' })); + } + }), + }; + + return { + session, + eventHandlers, + agentEvent, + mainEvent, + kimiHarnessConstructor: vi.fn(), + harnessEnsureConfigFile: vi.fn(), + harnessGetConfig: vi.fn( + async (): Promise<{ providers: {}; defaultModel?: string; telemetry: boolean }> => ({ + providers: {}, + defaultModel: 'k2', + telemetry: true, + }), + ), + harnessCreateSession: vi.fn(async () => session), + harnessResumeSession: vi.fn(async () => session), + harnessListSessions: vi.fn(async () => [{ id: 'ses_previous' }]), + harnessClose: vi.fn(), + harnessTrack: vi.fn(), + harnessGetCachedAccessToken: vi.fn(), + initializeTelemetry: vi.fn(), + setCrashPhase: vi.fn(), + shutdownTelemetry: vi.fn(), + telemetryTrack: vi.fn(), + setTelemetryContext: vi.fn(), + lifecycleTrack: vi.fn(), + withTelemetryContext: vi.fn(() => ({ track: vi.fn() })), + }; +}); + +vi.mock('@moonshot-ai/kimi-code-sdk', async (importOriginal) => { + const actual = await importOriginal<typeof import('@moonshot-ai/kimi-code-sdk')>(); + return { + ...actual, + KimiHarness: class { + homeDir = '/tmp/kimi-code-test-home'; + auth = { getCachedAccessToken: mocks.harnessGetCachedAccessToken }; + ensureConfigFile = mocks.harnessEnsureConfigFile; + getConfig = mocks.harnessGetConfig; + createSession = mocks.harnessCreateSession; + resumeSession = mocks.harnessResumeSession; + listSessions = mocks.harnessListSessions; + close = mocks.harnessClose; + track = mocks.harnessTrack; + + constructor(...args: unknown[]) { + mocks.kimiHarnessConstructor(...args); + } + }, + }; +}); + +vi.mock('@moonshot-ai/kimi-code-oauth', async () => { + const actual = await vi.importActual<typeof import('@moonshot-ai/kimi-code-oauth')>( + '@moonshot-ai/kimi-code-oauth', + ); + return { + ...actual, + createKimiDeviceId: vi.fn(() => 'device-1'), + KIMI_CODE_PROVIDER_NAME: 'kimi-code', + }; +}); + +vi.mock('@moonshot-ai/kimi-telemetry', () => ({ + initializeTelemetry: mocks.initializeTelemetry, + setCrashPhase: mocks.setCrashPhase, + shutdownTelemetry: mocks.shutdownTelemetry, + track: mocks.telemetryTrack, + setTelemetryContext: mocks.setTelemetryContext, + withTelemetryContext: mocks.withTelemetryContext, +})); + +function opts(overrides: Partial<Parameters<typeof runPrompt>[0]> = {}) { + return { + session: undefined, + continue: false, + yolo: false, + plan: false, + model: undefined, + outputFormat: undefined, + prompt: 'say hello', + skillsDirs: [], + ...overrides, + }; +} + +function writer(columns?: number) { + let text = ''; + return { + columns, + write: vi.fn((chunk: string) => { + text += chunk; + return true; + }), + text: () => text, + }; +} + +function fakeProcess() { + const listeners = new Map<NodeJS.Signals, () => Promise<void> | void>(); + return { + once: vi.fn((signal: NodeJS.Signals, listener: () => Promise<void> | void) => { + listeners.set(signal, listener); + }), + off: vi.fn((signal: NodeJS.Signals, listener: () => Promise<void> | void) => { + if (listeners.get(signal) === listener) { + listeners.delete(signal); + } + }), + exit: vi.fn(), + listener: (signal: NodeJS.Signals) => listeners.get(signal), + }; +} + +async function waitForAssertion(assertion: () => void): Promise<void> { + let lastError: unknown; + for (let attempt = 0; attempt < 20; attempt += 1) { + try { + assertion(); + return; + } catch (error) { + lastError = error; + await new Promise((resolve) => setTimeout(resolve, 0)); + } + } + throw lastError; +} + +describe('runPrompt', () => { + afterEach(() => { + vi.clearAllMocks(); + mocks.eventHandlers.clear(); + }); + + it('creates a fresh auto-permission session and streams assistant output to stdout', async () => { + const stdout = writer(); + const stderr = writer(); + + await runPrompt(opts({ skillsDirs: ['/skills'] }), '1.2.3-test', { stdout, stderr }); + + expect(mocks.kimiHarnessConstructor).toHaveBeenCalledWith( + expect.objectContaining({ skillDirs: ['/skills'], uiMode: 'print' }), + ); + expect(mocks.harnessCreateSession).toHaveBeenCalledWith({ + workDir: process.cwd(), + model: 'k2', + permission: 'auto', + }); + expect(mocks.session.setPermission).not.toHaveBeenCalled(); + expect(mocks.session.setApprovalHandler).toHaveBeenCalledWith(expect.any(Function)); + expect(mocks.session.setQuestionHandler).toHaveBeenCalledWith(expect.any(Function)); + expect(mocks.session.prompt).toHaveBeenCalledWith('say hello'); + expect(stdout.text()).toBe('• hello world\n\n'); + expect(stderr.text()).toBe('To resume this session: kimi -r ses_prompt\n'); + expect(mocks.shutdownTelemetry).toHaveBeenCalled(); + expect(mocks.harnessClose).toHaveBeenCalled(); + }); + + it('uses the CLI model override when creating a fresh prompt session', async () => { + await runPrompt(opts({ model: 'kimi-code/k2.5' }), '1.2.3-test', { + stdout: { write: vi.fn(() => true) }, + stderr: { write: vi.fn(() => true) }, + }); + + expect(mocks.harnessCreateSession).toHaveBeenCalledWith({ + workDir: process.cwd(), + model: 'kimi-code/k2.5', + permission: 'auto', + }); + expect(mocks.initializeTelemetry).toHaveBeenCalledWith( + expect.objectContaining({ model: 'kimi-code/k2.5' }), + ); + }); + + it('formats thinking and assistant output as transcript blocks', async () => { + mocks.session.prompt.mockImplementationOnce(async () => { + for (const handler of mocks.eventHandlers) { + handler( + mocks.mainEvent({ type: 'turn.started', turnId: 3, origin: { kind: 'user' } }), + ); + handler( + mocks.mainEvent({ + type: 'thinking.delta', + turnId: 3, + delta: 'The user wants an exact reply.', + }), + ); + handler( + mocks.mainEvent({ + type: 'thinking.delta', + turnId: 3, + delta: '\nNo tools are needed.', + }), + ); + handler(mocks.mainEvent({ type: 'assistant.delta', turnId: 3, delta: 'prompt-mode-ok' })); + handler(mocks.mainEvent({ type: 'turn.ended', turnId: 3, reason: 'completed' })); + } + }); + const stdout = writer(); + const stderr = writer(); + + await runPrompt(opts(), '1.2.3-test', { stdout, stderr }); + + expect(stderr.text()).toBe( + '• The user wants an exact reply.\n No tools are needed.\n\nTo resume this session: kimi -r ses_prompt\n', + ); + expect(stdout.text()).toBe('• prompt-mode-ok\n\n'); + expect(stderr.write).toHaveBeenNthCalledWith(1, '• The user wants an exact reply.'); + expect(stderr.write).toHaveBeenNthCalledWith(2, '\n No tools are needed.'); + expect(stdout.write).toHaveBeenNthCalledWith(1, '• prompt-mode-ok'); + }); + + it('formats hook results as their own transcript block', async () => { + mocks.session.prompt.mockImplementationOnce(async () => { + for (const handler of mocks.eventHandlers) { + handler( + mocks.mainEvent({ type: 'turn.started', turnId: 3, origin: { kind: 'user' } }), + ); + handler( + mocks.mainEvent({ + type: 'hook.result', + turnId: 3, + hookEvent: 'UserPromptSubmit', + content: '{}', + }), + ); + handler(mocks.mainEvent({ type: 'assistant.delta', turnId: 3, delta: 'answer' })); + handler(mocks.mainEvent({ type: 'turn.ended', turnId: 3, reason: 'completed' })); + } + }); + const stdout = writer(); + const stderr = writer(); + + await runPrompt(opts(), '1.2.3-test', { stdout, stderr }); + + expect(stdout.text()).toBe('• UserPromptSubmit hook\n\n {}\n\n• answer\n\n'); + expect(stderr.text()).toBe('To resume this session: kimi -r ses_prompt\n'); + }); + + it('wraps transcript blocks with hanging indentation when terminal width is known', async () => { + mocks.session.prompt.mockImplementationOnce(async () => { + for (const handler of mocks.eventHandlers) { + handler( + mocks.mainEvent({ type: 'turn.started', turnId: 4, origin: { kind: 'user' } }), + ); + handler(mocks.mainEvent({ type: 'thinking.delta', turnId: 4, delta: 'thinking-wrap' })); + handler(mocks.mainEvent({ type: 'assistant.delta', turnId: 4, delta: 'answer-wrap' })); + handler(mocks.mainEvent({ type: 'turn.ended', turnId: 4, reason: 'completed' })); + } + }); + const stdout = writer(10); + const stderr = writer(10); + + await runPrompt(opts(), '1.2.3-test', { stdout, stderr }); + + expect(stderr.text()).toBe('• thinking\n -wrap\n\nTo resume this session: kimi -r ses_prompt\n'); + expect(stdout.text()).toBe('• answer-w\n rap\n\n'); + }); + + it('filters prompt output and completion to the main agent turn', async () => { + mocks.session.prompt.mockImplementationOnce(async () => { + const emit = (event: Record<string, unknown>) => { + for (const handler of Array.from(mocks.eventHandlers)) { + handler(event); + } + }; + + emit(mocks.mainEvent({ type: 'turn.started', turnId: 1, origin: { kind: 'user' } })); + emit( + mocks.agentEvent('child-agent', { + type: 'turn.started', + turnId: 1, + origin: { kind: 'user' }, + }), + ); + emit( + mocks.agentEvent('child-agent', { + type: 'assistant.delta', + turnId: 1, + delta: 'sub answer', + }), + ); + emit(mocks.agentEvent('child-agent', { type: 'turn.ended', turnId: 1, reason: 'completed' })); + await Promise.resolve(); + emit(mocks.mainEvent({ type: 'assistant.delta', turnId: 1, delta: 'main answer' })); + emit(mocks.mainEvent({ type: 'turn.ended', turnId: 1, reason: 'completed' })); + }); + const stdout = writer(); + const stderr = writer(); + + await runPrompt(opts(), '1.2.3-test', { stdout, stderr }); + + expect(stdout.text()).toBe('• main answer\n\n'); + expect(stderr.text()).toBe('To resume this session: kimi -r ses_prompt\n'); + }); + + it('ignores child-agent error events while the main turn continues', async () => { + mocks.session.prompt.mockImplementationOnce(async () => { + const emit = (event: Record<string, unknown>) => { + for (const handler of Array.from(mocks.eventHandlers)) { + handler(event); + } + }; + + emit(mocks.mainEvent({ type: 'turn.started', turnId: 1, origin: { kind: 'user' } })); + emit( + mocks.agentEvent('child-agent', { + type: 'error', + code: 'subagent.failed', + message: 'child failed', + }), + ); + await Promise.resolve(); + emit(mocks.mainEvent({ type: 'assistant.delta', turnId: 1, delta: 'main recovered' })); + emit(mocks.mainEvent({ type: 'turn.ended', turnId: 1, reason: 'completed' })); + }); + const stdout = writer(); + const stderr = writer(); + + await runPrompt(opts(), '1.2.3-test', { stdout, stderr }); + + expect(stdout.text()).toBe('• main recovered\n\n'); + expect(stderr.text()).toBe('To resume this session: kimi -r ses_prompt\n'); + }); + + it('resumes a concrete session and forces auto permission before prompting', async () => { + await runPrompt(opts({ session: 'ses_existing' }), '1.2.3-test', { + stdout: { write: vi.fn(() => true) }, + stderr: { write: vi.fn(() => true) }, + }); + + expect(mocks.harnessResumeSession).toHaveBeenCalledWith({ id: 'ses_existing' }); + expect(mocks.session.getStatus).toHaveBeenCalled(); + expect(mocks.session.setPermission).toHaveBeenNthCalledWith(1, 'auto'); + expect(mocks.session.setPermission).toHaveBeenNthCalledWith(2, 'manual'); + }); + + it('applies the CLI model override to resumed prompt sessions', async () => { + await runPrompt(opts({ session: 'ses_existing', model: 'kimi-code/k2.5' }), '1.2.3-test', { + stdout: { write: vi.fn(() => true) }, + stderr: { write: vi.fn(() => true) }, + }); + + expect(mocks.harnessResumeSession).toHaveBeenCalledWith({ id: 'ses_existing' }); + expect(mocks.session.setModel).toHaveBeenCalledWith('kimi-code/k2.5'); + expect(mocks.initializeTelemetry).toHaveBeenCalledWith( + expect.objectContaining({ model: 'kimi-code/k2.5' }), + ); + }); + + it('writes stream-json output as assistant JSONL without transcript bullets', async () => { + const stdout = writer(); + const stderr = writer(); + + await runPrompt(opts({ outputFormat: 'stream-json' }), '1.2.3-test', { stdout, stderr }); + + expect(stdout.text()).toBe('{"role":"assistant","content":"hello world"}\n'); + expect(stderr.text()).toBe('To resume this session: kimi -r ses_prompt\n'); + }); + + it('writes stream-json tool calls and tool results as JSONL messages', async () => { + mocks.session.prompt.mockImplementationOnce(async () => { + for (const handler of mocks.eventHandlers) { + handler( + mocks.mainEvent({ type: 'turn.started', turnId: 8, origin: { kind: 'user' } }), + ); + handler(mocks.mainEvent({ type: 'assistant.delta', turnId: 8, delta: 'checking' })); + handler( + mocks.mainEvent({ + type: 'tool.call.started', + turnId: 8, + toolCallId: 'tc_1', + name: 'Shell', + args: { command: 'ls' }, + }), + ); + handler( + mocks.mainEvent({ + type: 'tool.result', + turnId: 8, + toolCallId: 'tc_1', + output: 'file1.py\nfile2.py', + }), + ); + handler(mocks.mainEvent({ type: 'assistant.delta', turnId: 8, delta: 'done' })); + handler(mocks.mainEvent({ type: 'turn.ended', turnId: 8, reason: 'completed' })); + } + }); + const stdout = writer(); + const stderr = writer(); + + await runPrompt(opts({ outputFormat: 'stream-json' }), '1.2.3-test', { stdout, stderr }); + + expect(stdout.text()).toBe( + [ + '{"role":"assistant","content":"checking","tool_calls":[{"type":"function","id":"tc_1","function":{"name":"Shell","arguments":"{\\"command\\":\\"ls\\"}"}}]}', + '{"role":"tool","tool_call_id":"tc_1","content":"file1.py\\nfile2.py"}', + '{"role":"assistant","content":"done"}', + '', + ].join('\n'), + ); + }); + + it('resumes a concrete session without a configured default model', async () => { + mocks.harnessGetConfig.mockResolvedValueOnce({ providers: {}, telemetry: true }); + mocks.session.getStatus.mockResolvedValueOnce({ permission: 'manual', model: 'saved-model' }); + + await runPrompt(opts({ session: 'ses_existing' }), '1.2.3-test', { + stdout: { write: vi.fn(() => true) }, + stderr: { write: vi.fn(() => true) }, + }); + + expect(mocks.harnessResumeSession).toHaveBeenCalledWith({ id: 'ses_existing' }); + expect(mocks.harnessCreateSession).not.toHaveBeenCalled(); + expect(mocks.initializeTelemetry).toHaveBeenCalledWith( + expect.objectContaining({ model: 'saved-model' }), + ); + expect(mocks.session.setPermission).toHaveBeenNthCalledWith(1, 'auto'); + expect(mocks.session.setPermission).toHaveBeenNthCalledWith(2, 'manual'); + }); + + it('continues the previous workdir session when --continue is used', async () => { + await runPrompt(opts({ continue: true }), '1.2.3-test', { + stdout: { write: vi.fn(() => true) }, + stderr: { write: vi.fn(() => true) }, + }); + + expect(mocks.harnessListSessions).toHaveBeenCalledWith({ workDir: process.cwd() }); + expect(mocks.harnessResumeSession).toHaveBeenCalledWith({ id: 'ses_previous' }); + expect(mocks.session.setPermission).toHaveBeenNthCalledWith(1, 'auto'); + expect(mocks.session.setPermission).toHaveBeenNthCalledWith(2, 'manual'); + }); + + it('continues a previous session without a configured default model', async () => { + mocks.harnessGetConfig.mockResolvedValueOnce({ providers: {}, telemetry: true }); + mocks.session.getStatus.mockResolvedValueOnce({ permission: 'manual', model: 'saved-model' }); + + await runPrompt(opts({ continue: true }), '1.2.3-test', { + stdout: { write: vi.fn(() => true) }, + stderr: { write: vi.fn(() => true) }, + }); + + expect(mocks.harnessListSessions).toHaveBeenCalledWith({ workDir: process.cwd() }); + expect(mocks.harnessResumeSession).toHaveBeenCalledWith({ id: 'ses_previous' }); + expect(mocks.harnessCreateSession).not.toHaveBeenCalled(); + expect(mocks.initializeTelemetry).toHaveBeenCalledWith( + expect.objectContaining({ model: 'saved-model' }), + ); + }); + + it('restores resumed session permission even when the turn fails', async () => { + mocks.session.prompt.mockImplementationOnce(async () => { + for (const handler of mocks.eventHandlers) { + handler( + mocks.mainEvent({ type: 'turn.started', turnId: 5, origin: { kind: 'user' } }), + ); + handler( + mocks.mainEvent({ + type: 'turn.ended', + turnId: 5, + reason: 'failed', + error: { code: 'provider.error', message: 'model failed' }, + }), + ); + } + }); + + await expect( + runPrompt(opts({ session: 'ses_existing' }), '1.2.3-test', { + stdout: { write: vi.fn(() => true) }, + stderr: { write: vi.fn(() => true) }, + }), + ).rejects.toThrow('provider.error: model failed'); + + expect(mocks.session.setPermission).toHaveBeenNthCalledWith(1, 'auto'); + expect(mocks.session.setPermission).toHaveBeenNthCalledWith(2, 'manual'); + expect(mocks.session.setPermission.mock.invocationCallOrder[1]).toBeLessThan( + mocks.harnessClose.mock.invocationCallOrder[0]!, + ); + }); + + it('restores resumed session permission before exiting on SIGINT', async () => { + let releasePrompt!: () => void; + mocks.session.prompt.mockImplementationOnce(async () => { + for (const handler of mocks.eventHandlers) { + handler( + mocks.mainEvent({ type: 'turn.started', turnId: 6, origin: { kind: 'user' } }), + ); + } + await new Promise<void>((resolve) => { + releasePrompt = resolve; + }); + }); + const processMock = fakeProcess(); + const run = runPrompt(opts({ session: 'ses_existing' }), '1.2.3-test', { + stdout: { write: vi.fn(() => true) }, + stderr: { write: vi.fn(() => true) }, + process: processMock, + } as Parameters<typeof runPrompt>[2] & { process: ReturnType<typeof fakeProcess> }); + + await waitForAssertion(() => { + expect(mocks.session.setPermission).toHaveBeenCalledWith('auto'); + expect(processMock.listener('SIGINT')).toBeDefined(); + }); + + await processMock.listener('SIGINT')?.(); + + expect(mocks.session.setPermission).toHaveBeenNthCalledWith(2, 'manual'); + expect(mocks.session.setPermission.mock.invocationCallOrder[1]).toBeLessThan( + processMock.exit.mock.invocationCallOrder[0]!, + ); + expect(mocks.shutdownTelemetry).toHaveBeenCalled(); + expect(mocks.harnessClose).toHaveBeenCalled(); + expect(processMock.exit).toHaveBeenCalledWith(130); + + for (const handler of mocks.eventHandlers) { + handler(mocks.mainEvent({ type: 'turn.ended', turnId: 6, reason: 'completed' })); + } + releasePrompt(); + await run; + + expect(mocks.harnessClose).toHaveBeenCalledTimes(1); + }); + + it('waits for the pending auto permission write before signal restore', async () => { + let releaseAutoPermission!: () => void; + let releasePrompt!: () => void; + mocks.session.setPermission.mockImplementationOnce(async () => { + await new Promise<void>((resolve) => { + releaseAutoPermission = resolve; + }); + }); + mocks.session.prompt.mockImplementationOnce(async () => { + for (const handler of mocks.eventHandlers) { + handler( + mocks.mainEvent({ type: 'turn.started', turnId: 7, origin: { kind: 'user' } }), + ); + } + await new Promise<void>((resolve) => { + releasePrompt = resolve; + }); + }); + const processMock = fakeProcess(); + const run = runPrompt(opts({ session: 'ses_existing' }), '1.2.3-test', { + stdout: { write: vi.fn(() => true) }, + stderr: { write: vi.fn(() => true) }, + process: processMock, + } as Parameters<typeof runPrompt>[2] & { process: ReturnType<typeof fakeProcess> }); + + await waitForAssertion(() => { + expect(processMock.listener('SIGINT')).toBeDefined(); + expect(mocks.session.setPermission).toHaveBeenCalledWith('auto'); + }); + expect(processMock.once.mock.invocationCallOrder[0]).toBeLessThan( + mocks.session.setPermission.mock.invocationCallOrder[0]!, + ); + + const signalCleanup = processMock.listener('SIGINT')?.(); + await Promise.resolve(); + + expect(mocks.session.setPermission).toHaveBeenCalledTimes(1); + + releaseAutoPermission(); + await signalCleanup; + + expect(mocks.session.setPermission).toHaveBeenNthCalledWith(2, 'manual'); + expect(processMock.exit).toHaveBeenCalledWith(130); + + await waitForAssertion(() => { + expect(mocks.session.prompt).toHaveBeenCalledWith('say hello'); + }); + for (const handler of mocks.eventHandlers) { + handler(mocks.mainEvent({ type: 'turn.ended', turnId: 7, reason: 'completed' })); + } + releasePrompt(); + await run; + }); + + it('uses auto permission so headless mode can bypass plan approval and questions', async () => { + await runPrompt(opts(), '1.2.3-test', { + stdout: { write: vi.fn(() => true) }, + stderr: { write: vi.fn(() => true) }, + }); + + expect(mocks.harnessCreateSession).toHaveBeenCalledWith( + expect.objectContaining({ permission: 'auto' }), + ); + }); + + it('throws when no default model is configured', async () => { + mocks.harnessGetConfig.mockResolvedValueOnce({ providers: {}, telemetry: true }); + + await expect( + runPrompt(opts(), '1.2.3-test', { + stdout: { write: vi.fn(() => true) }, + stderr: { write: vi.fn(() => true) }, + }), + ).rejects.toThrow('No model configured. Set default_model in config.toml.'); + + expect(mocks.harnessClose).toHaveBeenCalled(); + }); + + it('rejects when the turn fails and still closes resources', async () => { + mocks.session.prompt.mockImplementationOnce(async () => { + for (const handler of mocks.eventHandlers) { + handler( + mocks.mainEvent({ type: 'turn.started', turnId: 2, origin: { kind: 'user' } }), + ); + handler( + mocks.mainEvent({ + type: 'turn.ended', + turnId: 2, + reason: 'failed', + error: { code: 'provider.error', message: 'model failed' }, + }), + ); + } + }); + + await expect( + runPrompt(opts(), '1.2.3-test', { + stdout: { write: vi.fn(() => true) }, + stderr: { write: vi.fn(() => true) }, + }), + ).rejects.toThrow('provider.error: model failed'); + + expect(mocks.shutdownTelemetry).toHaveBeenCalled(); + expect(mocks.harnessClose).toHaveBeenCalled(); + }); + + it('approval fallback approves if an unexpected approval request reaches SDK', async () => { + await runPrompt(opts(), '1.2.3-test', { + stdout: { write: vi.fn(() => true) }, + stderr: { write: vi.fn(() => true) }, + }); + + const handler = mocks.session.setApprovalHandler.mock.calls[0]![0] as () => unknown; + expect(handler()).toEqual({ decision: 'approved' }); + }); + + it('question fallback returns null so prompt mode never opens a question UI', async () => { + await runPrompt(opts(), '1.2.3-test', { + stdout: { write: vi.fn(() => true) }, + stderr: { write: vi.fn(() => true) }, + }); + + const handler = mocks.session.setQuestionHandler.mock.calls[0]![0] as () => unknown; + expect(handler()).toBeNull(); + }); +}); diff --git a/apps/kimi-code/test/cli/run-shell.test.ts b/apps/kimi-code/test/cli/run-shell.test.ts new file mode 100644 index 000000000..64f9e02aa --- /dev/null +++ b/apps/kimi-code/test/cli/run-shell.test.ts @@ -0,0 +1,535 @@ +import { execSync } from 'node:child_process'; + +import type { createKimiDeviceId as createKimiDeviceIdFn } from '@moonshot-ai/kimi-code-oauth'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { runShell } from '#/cli/run-shell'; + +import { captureProcessWrite, ExitCalled, mockProcessExit } from '../helpers/process'; + +type CreateKimiDeviceId = typeof createKimiDeviceIdFn; + +const mocks = vi.hoisted(() => { + type TuiConfigFallback = { + theme: 'dark' | 'light' | 'auto'; + editorCommand: string | null; + notifications: { enabled: boolean; condition: 'unfocused' | 'always' }; + }; + + class TuiConfigParseError extends Error { + readonly fallback: TuiConfigFallback; + + constructor(fallback: TuiConfigFallback) { + super('Invalid TUI config in ~/.kimi-code/tui.toml; using defaults.'); + this.fallback = fallback; + } + } + + const lifecycleTrack = vi.fn(); + + return { + loadTuiConfig: vi.fn(), + detectTerminalTheme: vi.fn(), + kimiHarnessConstructor: vi.fn(), + harnessEnsureConfigFile: vi.fn(), + harnessGetConfig: vi.fn(async () => ({ + providers: {}, + defaultModel: 'k2', + telemetry: true, + })), + harnessGetCachedAccessToken: vi.fn(), + harnessClose: vi.fn(), + detectPendingMigration: vi.fn<() => Promise<unknown>>(async () => null), + harnessTrack: vi.fn(), + kimiTuiConstructor: vi.fn(), + tuiStart: vi.fn(), + tuiGetStartupMcpMs: vi.fn(async () => 0), + tuiGetCurrentSessionId: vi.fn(() => ''), + tuiHasSessionContent: vi.fn(() => false), + createKimiDeviceId: vi.fn<CreateKimiDeviceId>(() => 'device-1'), + initializeTelemetry: vi.fn(), + setCrashPhase: vi.fn(), + shutdownTelemetry: vi.fn(), + telemetryTrack: vi.fn(), + setTelemetryContext: vi.fn(), + lifecycleTrack, + withTelemetryContext: vi.fn(() => ({ + track: lifecycleTrack, + })), + execSync: vi.fn(), + TuiConfigParseError, + }; +}); + +vi.mock('@moonshot-ai/kimi-code-sdk', async (importOriginal) => { + const actual = await importOriginal<typeof import('@moonshot-ai/kimi-code-sdk')>(); + return { + ...actual, + KimiHarness: class { + homeDir = '/tmp/kimi-code-test-home'; + auth = { + getCachedAccessToken: mocks.harnessGetCachedAccessToken, + }; + ensureConfigFile = mocks.harnessEnsureConfigFile; + getConfig = mocks.harnessGetConfig; + close = mocks.harnessClose; + track = mocks.harnessTrack; + + constructor(...args: unknown[]) { + mocks.kimiHarnessConstructor(...args); + } + }, + }; +}); + +vi.mock('@moonshot-ai/kimi-code-oauth', async () => { + const actual = await vi.importActual<typeof import('@moonshot-ai/kimi-code-oauth')>( + '@moonshot-ai/kimi-code-oauth', + ); + return { + ...actual, + createKimiDeviceId: mocks.createKimiDeviceId, + KIMI_CODE_PROVIDER_NAME: 'kimi-code', + }; +}); + +vi.mock('@moonshot-ai/kimi-telemetry', () => ({ + initializeTelemetry: mocks.initializeTelemetry, + setCrashPhase: mocks.setCrashPhase, + shutdownTelemetry: mocks.shutdownTelemetry, + track: mocks.telemetryTrack, + setTelemetryContext: mocks.setTelemetryContext, + withTelemetryContext: mocks.withTelemetryContext, +})); + +vi.mock('../../src/tui/config', () => ({ + loadTuiConfig: mocks.loadTuiConfig, + TuiConfigParseError: mocks.TuiConfigParseError, +})); + +vi.mock('../../src/tui/index', () => ({ + KimiTUI: class { + onExit?: () => Promise<void>; + + constructor(...args: unknown[]) { + mocks.kimiTuiConstructor(this, ...args); + } + + start = mocks.tuiStart; + getStartupMcpMs = mocks.tuiGetStartupMcpMs; + getCurrentSessionId = mocks.tuiGetCurrentSessionId; + hasSessionContent = mocks.tuiHasSessionContent; + }, +})); + +vi.mock('../../src/tui/theme/detect', () => ({ + detectTerminalTheme: mocks.detectTerminalTheme, +})); + +vi.mock('../../src/migration/index', () => ({ + detectPendingMigration: mocks.detectPendingMigration, +})); + +vi.mock('node:child_process', () => ({ + execSync: mocks.execSync, +})); + +describe('runShell', () => { + afterEach(() => { + vi.clearAllMocks(); + mocks.harnessGetConfig.mockResolvedValue({ + providers: {}, + defaultModel: 'k2', + telemetry: true, + }); + mocks.tuiGetStartupMcpMs.mockResolvedValue(0); + mocks.tuiGetCurrentSessionId.mockReturnValue(''); + mocks.tuiHasSessionContent.mockReturnValue(false); + }); + + it('constructs KimiHarness and KimiTUI with startup input', async () => { + mocks.loadTuiConfig.mockResolvedValue({ + theme: 'dark', + editorCommand: null, + notifications: { enabled: true, condition: 'unfocused' }, + }); + mocks.tuiStart.mockResolvedValue(undefined); + mocks.tuiGetStartupMcpMs.mockResolvedValue(47); + mocks.tuiGetCurrentSessionId.mockReturnValue('ses-startup'); + + const cliOptions = { + session: undefined, + continue: false, + yolo: true, + plan: true, + model: undefined, + outputFormat: undefined, + prompt: undefined, + skillsDirs: [], + }; + + await runShell(cliOptions, '1.2.3-test'); + + expect(mocks.kimiHarnessConstructor).toHaveBeenCalledWith( + expect.objectContaining({ + identity: expect.objectContaining({ + userAgentProduct: 'kimi-code-cli', + version: '1.2.3-test', + }), + }), + ); + expect(mocks.harnessEnsureConfigFile).toHaveBeenCalledOnce(); + expect(mocks.harnessEnsureConfigFile.mock.invocationCallOrder[0]).toBeLessThan( + mocks.harnessGetConfig.mock.invocationCallOrder[0]!, + ); + expect(execSync).toHaveBeenCalledWith('stty -ixon', { stdio: 'ignore' }); + expect(mocks.kimiTuiConstructor).toHaveBeenCalledTimes(1); + expect(mocks.createKimiDeviceId).toHaveBeenCalledWith( + '/tmp/kimi-code-test-home', + expect.any(Object), + ); + expect(mocks.initializeTelemetry).toHaveBeenCalledWith({ + homeDir: '/tmp/kimi-code-test-home', + deviceId: 'device-1', + enabled: true, + appName: 'kimi-code-cli', + version: '1.2.3-test', + uiMode: 'shell', + model: 'k2', + getAccessToken: expect.any(Function), + }); + expect(mocks.setCrashPhase).toHaveBeenCalledWith('runtime'); + + const [, harness, startupInput] = mocks.kimiTuiConstructor.mock.calls[0]!; + expect(harness).toBeTypeOf('object'); + expect(startupInput).toMatchObject({ + cliOptions, + tuiConfig: { + theme: 'dark', + editorCommand: null, + notifications: { enabled: true, condition: 'unfocused' }, + }, + version: '1.2.3-test', + workDir: process.cwd(), + resolvedTheme: 'dark', + }); + expect(mocks.tuiStart).toHaveBeenCalledOnce(); + expect(mocks.harnessTrack).not.toHaveBeenCalledWith('started', expect.anything()); + expect(mocks.withTelemetryContext).toHaveBeenCalledWith({ sessionId: 'ses-startup' }); + expect(mocks.lifecycleTrack).toHaveBeenCalledWith('started', { + resumed: false, + yolo: true, + plan: true, + afk: false, + }); + expect(mocks.lifecycleTrack).toHaveBeenCalledWith('startup_perf', { + duration_ms: expect.any(Number), + config_ms: expect.any(Number), + init_ms: expect.any(Number), + mcp_ms: 47, + }); + }); + + it('tracks first launch when device id creation reports first launch', async () => { + mocks.loadTuiConfig.mockResolvedValue({ + theme: 'dark', + editorCommand: null, + notifications: { enabled: true, condition: 'unfocused' }, + }); + mocks.tuiStart.mockResolvedValue(undefined); + mocks.createKimiDeviceId.mockImplementationOnce((homeDir, options) => { + const deviceId = `device-for-${homeDir}`; + options?.onFirstLaunch?.(deviceId); + return deviceId; + }); + + await runShell( + { + session: undefined, + continue: false, + yolo: false, + plan: false, + model: undefined, + outputFormat: undefined, + prompt: undefined, + skillsDirs: [], + }, + '1.2.3-test', + ); + + expect(mocks.createKimiDeviceId).toHaveBeenCalledWith( + '/tmp/kimi-code-test-home', + expect.objectContaining({ onFirstLaunch: expect.any(Function) }), + ); + expect(mocks.harnessTrack).toHaveBeenCalledWith('first_launch'); + }); + + it('marks resumed lifecycle starts from session flags', async () => { + mocks.loadTuiConfig.mockResolvedValue({ + theme: 'dark', + editorCommand: null, + notifications: { enabled: true, condition: 'unfocused' }, + }); + mocks.tuiStart.mockResolvedValue(undefined); + mocks.tuiGetCurrentSessionId.mockReturnValue('ses-1'); + + await runShell( + { + session: 'ses-1', + continue: false, + yolo: false, + plan: false, + model: undefined, + outputFormat: undefined, + prompt: undefined, + skillsDirs: [], + }, + '1.2.3-test', + ); + + expect(mocks.lifecycleTrack).toHaveBeenCalledWith('started', { + resumed: true, + yolo: false, + plan: false, + afk: false, + }); + }); + + it('binds startup_perf to the session captured before MCP metrics resolve', async () => { + mocks.loadTuiConfig.mockResolvedValue({ + theme: 'dark', + editorCommand: null, + notifications: { enabled: true, condition: 'unfocused' }, + }); + mocks.tuiStart.mockResolvedValue(undefined); + let currentSessionId = 'ses-startup'; + mocks.tuiGetCurrentSessionId.mockImplementation(() => currentSessionId); + mocks.tuiGetStartupMcpMs.mockImplementation(async () => { + currentSessionId = 'ses-later'; + return 47; + }); + + await runShell( + { + session: undefined, + continue: false, + yolo: false, + plan: false, + model: undefined, + outputFormat: undefined, + prompt: undefined, + skillsDirs: [], + }, + '1.2.3-test', + ); + + expect(mocks.withTelemetryContext).toHaveBeenNthCalledWith(1, { sessionId: 'ses-startup' }); + expect(mocks.withTelemetryContext).toHaveBeenNthCalledWith(2, { sessionId: 'ses-startup' }); + expect(mocks.lifecycleTrack).toHaveBeenNthCalledWith(2, 'startup_perf', { + duration_ms: expect.any(Number), + config_ms: expect.any(Number), + init_ms: expect.any(Number), + mcp_ms: 47, + }); + }); + + it('bridges OAuth refresh outcomes to telemetry', async () => { + mocks.loadTuiConfig.mockResolvedValue({ + theme: 'dark', + editorCommand: null, + notifications: { enabled: true, condition: 'unfocused' }, + }); + mocks.tuiStart.mockResolvedValue(undefined); + + await runShell( + { + session: undefined, + continue: false, + yolo: false, + plan: false, + model: undefined, + outputFormat: undefined, + prompt: undefined, + skillsDirs: [], + }, + '1.2.3-test', + ); + + const [harnessOptions] = mocks.kimiHarnessConstructor.mock.calls[0] as [ + { + readonly onOAuthRefresh: ( + outcome: + | { readonly success: true } + | { readonly success: false; readonly reason: 'unauthorized' | 'network_or_other' }, + ) => void; + }, + ]; + + harnessOptions.onOAuthRefresh({ success: true }); + harnessOptions.onOAuthRefresh({ success: false, reason: 'unauthorized' }); + harnessOptions.onOAuthRefresh({ success: false, reason: 'network_or_other' }); + + expect(mocks.telemetryTrack).toHaveBeenCalledWith('oauth_refresh', { success: true }); + expect(mocks.telemetryTrack).toHaveBeenCalledWith('oauth_refresh', { + success: false, + reason: 'unauthorized', + }); + expect(mocks.telemetryTrack).toHaveBeenCalledWith('oauth_refresh', { + success: false, + reason: 'network_or_other', + }); + }); + + it('detects auto theme and forwards config parse warnings as startup notice', async () => { + mocks.loadTuiConfig.mockRejectedValue( + new mocks.TuiConfigParseError({ + theme: 'auto', + editorCommand: 'vim', + notifications: { enabled: true, condition: 'always' }, + }), + ); + mocks.detectTerminalTheme.mockResolvedValue('light'); + mocks.tuiStart.mockResolvedValue(undefined); + + await runShell( + { + session: '', + continue: false, + yolo: false, + plan: false, + model: undefined, + outputFormat: undefined, + prompt: undefined, + skillsDirs: [], + }, + '1.2.3-test', + ); + + expect(mocks.detectTerminalTheme).toHaveBeenCalledOnce(); + const [, , startupInput] = mocks.kimiTuiConstructor.mock.calls[0]!; + expect(startupInput).toMatchObject({ + startupNotice: 'Invalid TUI config in ~/.kimi-code/tui.toml; using defaults.', + resolvedTheme: 'light', + tuiConfig: { + theme: 'auto', + editorCommand: 'vim', + notifications: { enabled: true, condition: 'always' }, + }, + }); + }); + + it('closes the harness when TUI startup fails', async () => { + mocks.loadTuiConfig.mockResolvedValue({ + theme: 'dark', + editorCommand: null, + notifications: { enabled: true, condition: 'unfocused' }, + }); + mocks.tuiStart.mockRejectedValue(new Error('boom')); + + await expect( + runShell( + { + session: undefined, + continue: false, + yolo: false, + plan: false, + model: undefined, + outputFormat: undefined, + prompt: undefined, + skillsDirs: [], + }, + '1.2.3-test', + ), + ).rejects.toThrow('boom'); + + expect(mocks.setCrashPhase).toHaveBeenCalledWith('shutdown'); + expect(mocks.harnessTrack).toHaveBeenCalledWith('exit', { duration_s: expect.any(Number) }); + expect(mocks.shutdownTelemetry).toHaveBeenCalledOnce(); + expect(mocks.harnessClose).toHaveBeenCalledOnce(); + }); + + it('tracks exit and prints resume instructions from the TUI exit handler', async () => { + mocks.loadTuiConfig.mockResolvedValue({ + theme: 'dark', + editorCommand: null, + notifications: { enabled: true, condition: 'unfocused' }, + }); + mocks.tuiStart.mockResolvedValue(undefined); + mocks.tuiGetCurrentSessionId.mockReturnValue('ses-1'); + mocks.tuiHasSessionContent.mockReturnValue(true); + + const stdout = captureProcessWrite('stdout'); + const stderr = captureProcessWrite('stderr'); + const exitSpy = mockProcessExit(); + + try { + await runShell( + { + session: undefined, + continue: false, + yolo: false, + plan: false, + model: undefined, + outputFormat: undefined, + prompt: undefined, + skillsDirs: [], + }, + '1.2.3-test', + ); + const [tui] = mocks.kimiTuiConstructor.mock.calls[0]!; + mocks.harnessTrack.mockClear(); + mocks.lifecycleTrack.mockClear(); + mocks.withTelemetryContext.mockClear(); + + await expect((tui as { onExit: () => Promise<void> }).onExit()).rejects.toBeInstanceOf( + ExitCalled, + ); + + expect(mocks.setCrashPhase).toHaveBeenCalledWith('shutdown'); + expect(mocks.withTelemetryContext).toHaveBeenCalledWith({ sessionId: 'ses-1' }); + expect(mocks.lifecycleTrack).toHaveBeenCalledWith('exit', { + duration_s: expect.any(Number), + }); + expect(mocks.harnessTrack).not.toHaveBeenCalledWith('exit', expect.anything()); + expect(mocks.shutdownTelemetry).toHaveBeenCalledOnce(); + expect(stdout.text()).toBe(' Bye!\n'); + expect(stderr.text()).toContain(' To resume this session: kimi -r ses-1'); + } finally { + exitSpy.mockRestore(); + stdout.restore(); + stderr.restore(); + } + }); + + it('surfaces an invalid target config as an error for kimi migrate, not silently', async () => { + mocks.loadTuiConfig.mockResolvedValue({ + theme: 'dark', + editorCommand: null, + notifications: { enabled: true, condition: 'unfocused' }, + }); + mocks.detectPendingMigration.mockResolvedValue({ totalSessions: 1 }); + mocks.harnessGetConfig.mockRejectedValue( + new Error('Invalid configuration in ~/.kimi-code/config.toml'), + ); + + // A broken config.toml must fail loudly — `kimi migrate` must not swallow + // it and proceed, or the user never learns their config is broken. + await expect( + runShell( + { + session: undefined, + continue: false, + yolo: false, + plan: false, + model: undefined, + outputFormat: undefined, + prompt: undefined, + skillsDirs: [], + }, + '1.2.3-test', + { migrateOnly: true }, + ), + ).rejects.toThrow('Invalid configuration'); + expect(mocks.tuiStart).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/kimi-code/test/cli/session-flag-picker.test.ts b/apps/kimi-code/test/cli/session-flag-picker.test.ts new file mode 100644 index 000000000..1f24560fa --- /dev/null +++ b/apps/kimi-code/test/cli/session-flag-picker.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, it } from 'vitest'; + +import { createProgram } from '#/cli/commands'; +import type { CLIOptions } from '#/cli/options'; +import { OptionConflictError, validateOptions } from '#/cli/options'; + +function parse(argv: string[]): CLIOptions { + let captured: CLIOptions | undefined; + const program = createProgram( + '0.0.0-test', + (opts) => { + captured = opts; + }, + () => {}, + ); + program.exitOverride(); + program.configureOutput({ + writeOut: () => {}, + writeErr: () => {}, + }); + program.parse(['node', 'kimi', ...argv]); + if (captured === undefined) { + throw new Error('Main action handler was not called'); + } + return captured; +} + +describe('--session / -r / -S picker routing', () => { + describe('argParser: no-arg forms coerce to empty string', () => { + it('--session with no id → session === ""', () => { + const opts = parse(['--session']); + expect(opts.session).toBe(''); + }); + + it('-S with no id → session === ""', () => { + const opts = parse(['-S']); + expect(opts.session).toBe(''); + }); + + it('-r with no id → session === ""', () => { + const opts = parse(['-r']); + expect(opts.session).toBe(''); + }); + }); + + describe('argParser: id forms keep the id verbatim', () => { + it('--session foo → session === "foo"', () => { + const opts = parse(['--session', 'foo']); + expect(opts.session).toBe('foo'); + }); + + it('-S foo → session === "foo"', () => { + const opts = parse(['-S', 'foo']); + expect(opts.session).toBe('foo'); + }); + + it('-r foo → session === "foo" (hidden alias)', () => { + const opts = parse(['-r', 'foo']); + expect(opts.session).toBe('foo'); + }); + }); + + describe('validateOptions: empty session vs ui mode', () => { + it('empty session + shell mode → OK, session stays ""', () => { + const { options, uiMode } = validateOptions(parse(['--session'])); + expect(uiMode).toBe('shell'); + expect(options.session).toBe(''); + }); + + // Note: --print / --wire are held back from the first release, so + // the "picker + print/wire" combinations can't be constructed via + // Commander anymore. The validateOptions guard still lives in + // source for when those flags return. + }); + + // Silence unused-import warning kept for the preserved guard tests. + void OptionConflictError; +}); diff --git a/apps/kimi-code/test/cli/update/cache.test.ts b/apps/kimi-code/test/cli/update/cache.test.ts new file mode 100644 index 000000000..a1ba436bc --- /dev/null +++ b/apps/kimi-code/test/cli/update/cache.test.ts @@ -0,0 +1,62 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { readUpdateCache, writeUpdateCache } from '#/cli/update/cache'; +import { emptyUpdateCache } from '#/cli/update/types'; +import { getUpdateStateFile } from '#/utils/paths'; + +const originalEnv = { ...process.env }; + +let dir: string; + +beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'kimi-update-cache-')); + process.env['KIMI_CODE_HOME'] = dir; +}); + +afterEach(() => { + rmSync(dir, { recursive: true, force: true }); + process.env = { ...originalEnv }; +}); + +describe('update cache', () => { + it('returns an empty cache when the file is missing', async () => { + await expect(readUpdateCache()).resolves.toEqual(emptyUpdateCache()); + }); + + it('falls back to an empty cache when the file is corrupt', async () => { + mkdirSync(join(dir, 'updates'), { recursive: true }); + writeFileSync(getUpdateStateFile(), '{"broken"', 'utf-8'); + await expect(readUpdateCache()).resolves.toEqual(emptyUpdateCache()); + }); + + it('falls back to an empty cache when the file has the old npm.json shape', async () => { + mkdirSync(join(dir, 'updates'), { recursive: true }); + writeFileSync( + getUpdateStateFile(), + JSON.stringify({ + packageName: '@moonshot-ai/kimi-code', + checkedAt: '2026-04-23T08:00:00.000Z', + distTags: { beta: '0.0.1-beta.1' }, + }), + 'utf-8', + ); + await expect(readUpdateCache()).resolves.toEqual(emptyUpdateCache()); + }); + + it('writes and reads back the cache from updates/latest.json', async () => { + const cache = { + source: 'cdn', + checkedAt: '2026-04-23T08:00:00.000Z', + latest: '0.5.0', + } as const; + + await writeUpdateCache(cache); + + expect(getUpdateStateFile()).toBe(join(dir, 'updates', 'latest.json')); + await expect(readUpdateCache()).resolves.toEqual(cache); + }); +}); diff --git a/apps/kimi-code/test/cli/update/cdn.test.ts b/apps/kimi-code/test/cli/update/cdn.test.ts new file mode 100644 index 000000000..3127bd71d --- /dev/null +++ b/apps/kimi-code/test/cli/update/cdn.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { fetchLatestVersionFromCdn } from '#/cli/update/cdn'; +import { KIMI_CODE_CDN_LATEST_URL } from '#/constant/app'; + +function mockFetchOk(body: string): typeof fetch { + return vi.fn(async () => ({ + ok: true, + status: 200, + text: async () => body, + })) as unknown as typeof fetch; +} + +function mockFetchStatus(status: number): typeof fetch { + return vi.fn(async () => ({ + ok: status >= 200 && status < 300, + status, + text: async () => '', + })) as unknown as typeof fetch; +} + +describe('fetchLatestVersionFromCdn', () => { + it('returns the trimmed semver returned by CDN /latest', async () => { + const f = mockFetchOk(' 0.5.0\n'); + await expect(fetchLatestVersionFromCdn(f)).resolves.toBe('0.5.0'); + expect(f).toHaveBeenCalledWith(KIMI_CODE_CDN_LATEST_URL); + }); + + it('throws when response is non-2xx', async () => { + await expect(fetchLatestVersionFromCdn(mockFetchStatus(404))).rejects.toThrow(/HTTP 404/); + }); + + it('throws when body is not valid semver', async () => { + await expect(fetchLatestVersionFromCdn(mockFetchOk('not-a-version'))).rejects.toThrow( + /invalid semver/, + ); + }); + + it('throws when body is empty', async () => { + await expect(fetchLatestVersionFromCdn(mockFetchOk(' '))).rejects.toThrow(/invalid semver/); + }); + + it('propagates the underlying fetch error', async () => { + const f = vi.fn(async () => { + throw new Error('network down'); + }) as unknown as typeof fetch; + await expect(fetchLatestVersionFromCdn(f)).rejects.toThrow(/network down/); + }); +}); diff --git a/apps/kimi-code/test/cli/update/preflight.test.ts b/apps/kimi-code/test/cli/update/preflight.test.ts new file mode 100644 index 000000000..dc115c9e4 --- /dev/null +++ b/apps/kimi-code/test/cli/update/preflight.test.ts @@ -0,0 +1,269 @@ +import type * as ChildProcess from 'node:child_process'; +import { EventEmitter } from 'node:events'; + +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { readUpdateCache } from '#/cli/update/cache'; +import { runUpdatePreflight } from '#/cli/update/preflight'; +import { promptForInstallConfirmation } from '#/cli/update/prompt'; +import type * as PromptModule from '#/cli/update/prompt'; +import { refreshUpdateCache } from '#/cli/update/refresh'; +import type * as RefreshModule from '#/cli/update/refresh'; +import { detectInstallSource } from '#/cli/update/source'; +import { emptyUpdateCache, type UpdateCache } from '#/cli/update/types'; + +const mocks = vi.hoisted(() => ({ + readUpdateCache: vi.fn(), + detectInstallSource: vi.fn(), + promptForInstallConfirmation: vi.fn(), + refreshUpdateCache: vi.fn(), + spawn: vi.fn(), +})); + +vi.mock('../../../src/cli/update/cache', () => ({ + readUpdateCache: mocks.readUpdateCache, +})); + +vi.mock('../../../src/cli/update/source', () => ({ + detectInstallSource: mocks.detectInstallSource, +})); + +vi.mock('../../../src/cli/update/prompt', async () => { + const actual = await vi.importActual<typeof PromptModule>('../../../src/cli/update/prompt.js'); + return { + ...actual, + promptForInstallConfirmation: mocks.promptForInstallConfirmation, + }; +}); + +vi.mock('../../../src/cli/update/refresh', async () => { + const actual = await vi.importActual<typeof RefreshModule>('../../../src/cli/update/refresh.js'); + return { + ...actual, + refreshUpdateCache: mocks.refreshUpdateCache, + }; +}); + +vi.mock('node:child_process', async () => { + const actual = await vi.importActual<typeof ChildProcess>('node:child_process'); + return { + ...actual, + spawn: mocks.spawn, + }; +}); + +function cacheWith(version: string): UpdateCache { + return { + source: 'cdn', + checkedAt: '2026-04-23T08:00:00.000Z', + latest: version, + }; +} + +function captureOutput(): { + stdout: string[]; + stderr: string[]; + options: { + stdout: { write(chunk: string): boolean }; + stderr: { write(chunk: string): boolean }; + isTTY: boolean; + }; +} { + const stdout: string[] = []; + const stderr: string[] = []; + return { + stdout, + stderr, + options: { + stdout: { write: (chunk: string) => { stdout.push(chunk); return true; } }, + stderr: { write: (chunk: string) => { stderr.push(chunk); return true; } }, + isTTY: true, + }, + }; +} + +function mockSpawnExit(code: number, signal: NodeJS.Signals | null = null): void { + mocks.spawn.mockImplementation(() => { + const child = new EventEmitter(); + queueMicrotask(() => { child.emit('exit', code, signal); }); + return child; + }); +} + +describe('runUpdatePreflight', () => { + afterEach(() => { vi.clearAllMocks(); }); + + it('continues on first launch with empty cache, still refreshes in background', async () => { + mocks.readUpdateCache.mockResolvedValue(emptyUpdateCache()); + mocks.refreshUpdateCache.mockResolvedValue(emptyUpdateCache()); + const { options } = captureOutput(); + + await expect(runUpdatePreflight('0.4.0', options)).resolves.toBe('continue'); + expect(readUpdateCache).toHaveBeenCalledTimes(1); + expect(refreshUpdateCache).toHaveBeenCalledTimes(1); + expect(detectInstallSource).not.toHaveBeenCalled(); + }); + + it('skips when non-interactive', async () => { + mocks.readUpdateCache.mockResolvedValue(cacheWith('0.5.0')); + mocks.refreshUpdateCache.mockResolvedValue(cacheWith('0.5.0')); + const { options } = captureOutput(); + await expect( + runUpdatePreflight('0.4.0', { ...options, isTTY: false }), + ).resolves.toBe('continue'); + expect(detectInstallSource).not.toHaveBeenCalled(); + }); + + it('npm-global: prompts and spawns npm install -g', async () => { + mocks.readUpdateCache.mockResolvedValue(cacheWith('0.5.0')); + mocks.refreshUpdateCache.mockResolvedValue(cacheWith('0.5.0')); + mocks.detectInstallSource.mockResolvedValue('npm-global'); + mocks.promptForInstallConfirmation.mockResolvedValue(true); + mockSpawnExit(0); + const { stdout, options } = captureOutput(); + + await expect(runUpdatePreflight('0.4.0', options)).resolves.toBe('exit'); + expect(mocks.promptForInstallConfirmation).toHaveBeenCalledWith( + expect.objectContaining({ + installCommand: 'npm install -g @moonshot-ai/kimi-code@0.5.0', + installSource: 'npm-global', + }), + ); + expect(mocks.spawn).toHaveBeenCalledWith( + expect.stringMatching(/^npm(\.cmd)?$/), + ['install', '-g', '@moonshot-ai/kimi-code@0.5.0'], + { stdio: 'inherit' }, + ); + expect(stdout.join('')).toContain('Updated @moonshot-ai/kimi-code to 0.5.0'); + }); + + it('pnpm-global: spawns pnpm add -g', async () => { + mocks.readUpdateCache.mockResolvedValue(cacheWith('0.5.0')); + mocks.refreshUpdateCache.mockResolvedValue(cacheWith('0.5.0')); + mocks.detectInstallSource.mockResolvedValue('pnpm-global'); + mocks.promptForInstallConfirmation.mockResolvedValue(true); + mockSpawnExit(0); + const { options } = captureOutput(); + await runUpdatePreflight('0.4.0', options); + expect(mocks.spawn).toHaveBeenCalledWith( + expect.stringMatching(/^pnpm(\.cmd)?$/), + ['add', '-g', '@moonshot-ai/kimi-code@0.5.0'], + { stdio: 'inherit' }, + ); + }); + + it('yarn-global: spawns yarn global add', async () => { + mocks.readUpdateCache.mockResolvedValue(cacheWith('0.5.0')); + mocks.refreshUpdateCache.mockResolvedValue(cacheWith('0.5.0')); + mocks.detectInstallSource.mockResolvedValue('yarn-global'); + mocks.promptForInstallConfirmation.mockResolvedValue(true); + mockSpawnExit(0); + const { options } = captureOutput(); + await runUpdatePreflight('0.4.0', options); + expect(mocks.spawn).toHaveBeenCalledWith( + expect.stringMatching(/^yarn(\.cmd)?$/), + ['global', 'add', '@moonshot-ai/kimi-code@0.5.0'], + { stdio: 'inherit' }, + ); + }); + + it('bun-global: spawns bun add -g', async () => { + mocks.readUpdateCache.mockResolvedValue(cacheWith('0.5.0')); + mocks.refreshUpdateCache.mockResolvedValue(cacheWith('0.5.0')); + mocks.detectInstallSource.mockResolvedValue('bun-global'); + mocks.promptForInstallConfirmation.mockResolvedValue(true); + mockSpawnExit(0); + const { options } = captureOutput(); + await runUpdatePreflight('0.4.0', options); + expect(mocks.spawn).toHaveBeenCalledWith( + expect.stringMatching(/^bun(\.exe)?$/), + ['add', '-g', '@moonshot-ai/kimi-code@0.5.0'], + { stdio: 'inherit' }, + ); + }); + + it('native on darwin: spawns bash -c curl|bash', async () => { + mocks.readUpdateCache.mockResolvedValue(cacheWith('0.5.0')); + mocks.refreshUpdateCache.mockResolvedValue(cacheWith('0.5.0')); + mocks.detectInstallSource.mockResolvedValue('native'); + mocks.promptForInstallConfirmation.mockResolvedValue(true); + mockSpawnExit(0); + const originalPlatform = process.platform; + Object.defineProperty(process, 'platform', { value: 'darwin' }); + try { + const { options } = captureOutput(); + await runUpdatePreflight('0.4.0', options); + expect(mocks.spawn).toHaveBeenCalledWith( + 'bash', + ['-c', expect.stringContaining('curl -fsSL https://code.kimi.com/kimi-code/install.sh')], + { stdio: 'inherit' }, + ); + } finally { + Object.defineProperty(process, 'platform', { value: originalPlatform }); + } + }); + + it('native on win32: prints manual powershell command, does not spawn', async () => { + mocks.readUpdateCache.mockResolvedValue(cacheWith('0.5.0')); + mocks.refreshUpdateCache.mockResolvedValue(cacheWith('0.5.0')); + mocks.detectInstallSource.mockResolvedValue('native'); + const originalPlatform = process.platform; + Object.defineProperty(process, 'platform', { value: 'win32' }); + try { + const { stdout, options } = captureOutput(); + await expect(runUpdatePreflight('0.4.0', options)).resolves.toBe('continue'); + expect(stdout.join('')).toContain('irm https://code.kimi.com/kimi-code/install.ps1 | iex'); + expect(promptForInstallConfirmation).not.toHaveBeenCalled(); + expect(mocks.spawn).not.toHaveBeenCalled(); + } finally { + Object.defineProperty(process, 'platform', { value: originalPlatform }); + } + }); + + it('unsupported: prints fallback npm command', async () => { + mocks.readUpdateCache.mockResolvedValue(cacheWith('0.5.0')); + mocks.refreshUpdateCache.mockResolvedValue(cacheWith('0.5.0')); + mocks.detectInstallSource.mockResolvedValue('unsupported'); + const { stdout, options } = captureOutput(); + await expect(runUpdatePreflight('0.4.0', options)).resolves.toBe('continue'); + expect(stdout.join('')).toContain('npm install -g @moonshot-ai/kimi-code@0.5.0'); + expect(mocks.spawn).not.toHaveBeenCalled(); + }); + + it('declined install continues without spawn', async () => { + mocks.readUpdateCache.mockResolvedValue(cacheWith('0.5.0')); + mocks.refreshUpdateCache.mockResolvedValue(cacheWith('0.5.0')); + mocks.detectInstallSource.mockResolvedValue('npm-global'); + mocks.promptForInstallConfirmation.mockResolvedValue(false); + const { options } = captureOutput(); + await expect(runUpdatePreflight('0.4.0', options)).resolves.toBe('continue'); + expect(mocks.spawn).not.toHaveBeenCalled(); + }); + + it('warns and continues when spawn exits non-zero', async () => { + mocks.readUpdateCache.mockResolvedValue(cacheWith('0.5.0')); + mocks.refreshUpdateCache.mockResolvedValue(cacheWith('0.5.0')); + mocks.detectInstallSource.mockResolvedValue('npm-global'); + mocks.promptForInstallConfirmation.mockResolvedValue(true); + mockSpawnExit(1); + const { stderr, options } = captureOutput(); + await expect(runUpdatePreflight('0.4.0', options)).resolves.toBe('continue'); + expect(stderr.join('')).toContain('warning: failed to install'); + }); + + it('tracks update_prompted telemetry', async () => { + mocks.readUpdateCache.mockResolvedValue(cacheWith('0.5.0')); + mocks.refreshUpdateCache.mockResolvedValue(cacheWith('0.5.0')); + mocks.detectInstallSource.mockResolvedValue('npm-global'); + mocks.promptForInstallConfirmation.mockResolvedValue(false); + const { options } = captureOutput(); + const track = vi.fn(); + await runUpdatePreflight('0.4.0', { ...options, track }); + expect(track).toHaveBeenCalledWith('update_prompted', expect.objectContaining({ + current: '0.4.0', + latest: '0.5.0', + decision: 'prompt-install', + source: 'npm-global', + })); + }); +}); diff --git a/apps/kimi-code/test/cli/update/prompt.test.ts b/apps/kimi-code/test/cli/update/prompt.test.ts new file mode 100644 index 000000000..b898ab654 --- /dev/null +++ b/apps/kimi-code/test/cli/update/prompt.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from 'vitest'; + +import { + createInstallPromptChoices, + getDefaultInstallPromptSelection, + moveInstallPromptSelection, +} from '#/cli/update/prompt'; + +describe('install prompt helpers', () => { + it('defaults the selection to "Install update now"', () => { + const choices = createInstallPromptChoices({ version: '0.0.2-beta.1' }); + + expect(getDefaultInstallPromptSelection(choices)).toBe(0); + expect(choices[0]).toEqual({ + value: 'install', + label: 'Install update now (0.0.2-beta.1)', + }); + expect(choices[1]).toEqual({ + value: 'skip', + label: 'Continue with current version', + }); + }); + + it('moves selection with arrow directions and clamps at the edges', () => { + expect(moveInstallPromptSelection(1, 'up', 2)).toBe(0); + expect(moveInstallPromptSelection(0, 'up', 2)).toBe(0); + expect(moveInstallPromptSelection(0, 'down', 2)).toBe(1); + expect(moveInstallPromptSelection(1, 'down', 2)).toBe(1); + }); +}); diff --git a/apps/kimi-code/test/cli/update/refresh.test.ts b/apps/kimi-code/test/cli/update/refresh.test.ts new file mode 100644 index 000000000..16a18d2f3 --- /dev/null +++ b/apps/kimi-code/test/cli/update/refresh.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { refreshUpdateCache } from '#/cli/update/refresh'; + +describe('refreshUpdateCache', () => { + it('writes a fresh cache on successful fetch', async () => { + const writeCache = vi.fn(async () => {}); + const result = await refreshUpdateCache({ + fetchLatest: async () => '0.5.0', + writeCache, + now: () => new Date('2026-05-20T12:34:56.000Z'), + }); + + expect(result).toEqual({ + source: 'cdn', + checkedAt: '2026-05-20T12:34:56.000Z', + latest: '0.5.0', + }); + expect(writeCache).toHaveBeenCalledWith({ + source: 'cdn', + checkedAt: '2026-05-20T12:34:56.000Z', + latest: '0.5.0', + }); + }); + + it('propagates fetch errors and skips writeCache so the cache is preserved', async () => { + const writeCache = vi.fn(async () => {}); + await expect( + refreshUpdateCache({ + fetchLatest: async () => { + throw new Error('network down'); + }, + writeCache, + now: () => new Date(), + }), + ).rejects.toThrow(/network down/); + + expect(writeCache).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/kimi-code/test/cli/update/select.test.ts b/apps/kimi-code/test/cli/update/select.test.ts new file mode 100644 index 000000000..a616a4867 --- /dev/null +++ b/apps/kimi-code/test/cli/update/select.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from 'vitest'; + +import { selectUpdateTarget } from '#/cli/update/select'; + +describe('selectUpdateTarget', () => { + it('returns the latest version when it is newer than current', () => { + expect(selectUpdateTarget('0.4.0', '0.5.0')).toEqual({ version: '0.5.0' }); + }); + + it('returns null when latest equals current', () => { + expect(selectUpdateTarget('0.5.0', '0.5.0')).toBeNull(); + }); + + it('returns null when latest is older than current', () => { + expect(selectUpdateTarget('0.6.0', '0.5.0')).toBeNull(); + }); + + it('returns null when latest is null (cache empty)', () => { + expect(selectUpdateTarget('0.5.0', null)).toBeNull(); + }); + + it('returns null when current is not a valid semver', () => { + expect(selectUpdateTarget('not-a-version', '0.5.0')).toBeNull(); + }); + + it('returns null when latest is not a valid semver', () => { + expect(selectUpdateTarget('0.5.0', 'not-a-version')).toBeNull(); + }); + + it('handles prerelease semver comparisons correctly', () => { + expect(selectUpdateTarget('0.5.0-rc.1', '0.5.0')).toEqual({ version: '0.5.0' }); + expect(selectUpdateTarget('0.5.0', '0.5.0-rc.1')).toBeNull(); + }); +}); diff --git a/apps/kimi-code/test/cli/update/source.test.ts b/apps/kimi-code/test/cli/update/source.test.ts new file mode 100644 index 000000000..20bba5cd1 --- /dev/null +++ b/apps/kimi-code/test/cli/update/source.test.ts @@ -0,0 +1,149 @@ +import { describe, expect, it } from 'vitest'; + +import { + classifyByPathHeuristic, + classifyInstallSource, + detectInstallSource, +} from '#/cli/update/source'; + +describe('classifyByPathHeuristic', () => { + it('returns null for an npm-style global path (handled by classifyInstallSource)', () => { + expect(classifyByPathHeuristic('/usr/local/lib/node_modules/@moonshot-ai/kimi-code')).toBeNull(); + }); + + it('detects pnpm global on macOS', () => { + expect( + classifyByPathHeuristic('/Users/me/Library/pnpm/global/5/node_modules/@moonshot-ai/kimi-code'), + ).toBe('pnpm-global'); + }); + + it('detects pnpm global on Linux', () => { + expect( + classifyByPathHeuristic('/home/me/.local/share/pnpm/global/5/node_modules/@moonshot-ai/kimi-code'), + ).toBe('pnpm-global'); + }); + + it('detects pnpm global on Windows (normalized backslashes)', () => { + expect( + classifyByPathHeuristic('C:\\Users\\me\\AppData\\Local\\pnpm\\global\\5\\node_modules\\@moonshot-ai/kimi-code'), + ).toBe('pnpm-global'); + }); + + it('detects yarn classic global', () => { + expect( + classifyByPathHeuristic('/Users/me/.config/yarn/global/node_modules/@moonshot-ai/kimi-code'), + ).toBe('yarn-global'); + }); + + it('detects yarn berry global (~/.yarn/global)', () => { + expect( + classifyByPathHeuristic('/Users/me/.yarn/global/node_modules/@moonshot-ai/kimi-code'), + ).toBe('yarn-global'); + }); + + it('detects bun global', () => { + expect( + classifyByPathHeuristic('/Users/me/.bun/install/global/node_modules/@moonshot-ai/kimi-code'), + ).toBe('bun-global'); + }); + + it('returns null for an unknown layout', () => { + expect(classifyByPathHeuristic('/Users/me/dev/@moonshot-ai/kimi-code')).toBeNull(); + }); +}); + +describe('classifyInstallSource (npm prefix matching)', () => { + it('matches a macOS/Linux npm global package path', () => { + expect( + classifyInstallSource('/usr/local/lib/node_modules/@moonshot-ai/kimi-code', '/usr/local', 'darwin'), + ).toBe('npm-global'); + }); + + it('returns unsupported when the package path does not match the prefix', () => { + expect( + classifyInstallSource('/Users/me/dev/@moonshot-ai/kimi-code', '/usr/local', 'darwin'), + ).toBe('unsupported'); + }); +}); + +describe('detectInstallSource', () => { + it('returns pnpm-global when packageRoot matches pnpm heuristic', async () => { + await expect( + detectInstallSource({ + getPackageRoot: () => + '/Users/me/Library/pnpm/global/5/node_modules/@moonshot-ai/kimi-code', + getGlobalPrefix: async () => '/usr/local', + detectNative: () => false, + platform: 'darwin', + }), + ).resolves.toBe('pnpm-global'); + }); + + it('returns yarn-global when packageRoot matches yarn heuristic', async () => { + await expect( + detectInstallSource({ + getPackageRoot: () => '/Users/me/.config/yarn/global/node_modules/@moonshot-ai/kimi-code', + getGlobalPrefix: async () => '/usr/local', + detectNative: () => false, + platform: 'darwin', + }), + ).resolves.toBe('yarn-global'); + }); + + it('returns bun-global when packageRoot matches bun heuristic', async () => { + await expect( + detectInstallSource({ + getPackageRoot: () => '/Users/me/.bun/install/global/node_modules/@moonshot-ai/kimi-code', + getGlobalPrefix: async () => '/usr/local', + detectNative: () => false, + platform: 'darwin', + }), + ).resolves.toBe('bun-global'); + }); + + it('returns npm-global when packageRoot matches npm prefix', async () => { + await expect( + detectInstallSource({ + getPackageRoot: () => '/usr/local/lib/node_modules/@moonshot-ai/kimi-code', + getGlobalPrefix: async () => '/usr/local', + detectNative: () => false, + platform: 'darwin', + }), + ).resolves.toBe('npm-global'); + }); + + it('returns native when SEA isSea() is true (highest priority)', async () => { + await expect( + detectInstallSource({ + getPackageRoot: () => '/usr/local/lib/node_modules/@moonshot-ai/kimi-code', + getGlobalPrefix: async () => '/usr/local', + detectNative: () => true, + platform: 'darwin', + }), + ).resolves.toBe('native'); + }); + + it('returns unsupported when nothing matches', async () => { + await expect( + detectInstallSource({ + getPackageRoot: () => '/Users/me/dev/@moonshot-ai/kimi-code', + getGlobalPrefix: async () => '/usr/local', + detectNative: () => false, + platform: 'darwin', + }), + ).resolves.toBe('unsupported'); + }); + + it('returns unsupported when npm prefix lookup throws', async () => { + await expect( + detectInstallSource({ + getPackageRoot: () => '/Users/me/dev/@moonshot-ai/kimi-code', + getGlobalPrefix: async () => { + throw new Error('prefix failed'); + }, + detectNative: () => false, + platform: 'darwin', + }), + ).resolves.toBe('unsupported'); + }); +}); diff --git a/apps/kimi-code/test/cli/version.test.ts b/apps/kimi-code/test/cli/version.test.ts new file mode 100644 index 000000000..f17240eff --- /dev/null +++ b/apps/kimi-code/test/cli/version.test.ts @@ -0,0 +1,28 @@ +import { readFileSync } from 'node:fs'; +import { dirname } from 'node:path'; + +import { describe, expect, it } from 'vitest'; + +import { + buildKimiDefaultHeaders, + getHostPackageJsonPath, + getHostPackageRoot, + getVersion, +} from '#/cli/version'; + +describe('cli version helpers', () => { + it('resolves the host package manifest near apps/kimi-code and reads its version', () => { + const pkgPath = getHostPackageJsonPath(); + const pkg = JSON.parse(readFileSync(pkgPath, 'utf8')) as { version: string }; + + expect(pkgPath.endsWith('/apps/kimi-code/package.json')).toBe(true); + expect(getHostPackageRoot()).toBe(dirname(pkgPath)); + expect(getVersion()).toBe(pkg.version); + }); + + it('builds default headers with the kimi-code-cli user-agent', () => { + const headers = buildKimiDefaultHeaders('1.2.3'); + + expect(headers['User-Agent']).toBe('kimi-code-cli/1.2.3'); + }); +}); diff --git a/apps/kimi-code/test/e2e/local-logging-export.e2e.test.ts b/apps/kimi-code/test/e2e/local-logging-export.e2e.test.ts new file mode 100644 index 000000000..8421105f0 --- /dev/null +++ b/apps/kimi-code/test/e2e/local-logging-export.e2e.test.ts @@ -0,0 +1,143 @@ +import { readFile, mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import * as zlib from 'node:zlib'; + +import { Command } from 'commander'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { registerExportCommand } from '#/cli/sub/export'; +import { createKimiCodeHostIdentity } from '#/cli/version'; +import { KimiHarness, log } from '@moonshot-ai/kimi-code-sdk'; +import { __resetRootLoggerForTest } from '../../../../packages/agent-core/src/logging/logger'; + +const SESSION_LOG = 'logs/kimi-code.log'; +const GLOBAL_LOG = 'logs/global/kimi-code.log'; +const MAIN_WIRE = 'agents/main/wire.jsonl'; +const ENABLED = process.env['KIMI_E2E'] === '1'; + +let homeDir: string; +let workDir: string; +let oldHome: string | undefined; + +beforeEach(async () => { + await __resetRootLoggerForTest(); + homeDir = await mkdtemp(join(tmpdir(), 'kimi-cli-log-home-')); + workDir = await mkdtemp(join(tmpdir(), 'kimi-cli-log-work-')); + oldHome = process.env['KIMI_CODE_HOME']; + process.env['KIMI_CODE_HOME'] = homeDir; +}); + +afterEach(async () => { + await __resetRootLoggerForTest(); + if (oldHome === undefined) { + delete process.env['KIMI_CODE_HOME']; + } else { + process.env['KIMI_CODE_HOME'] = oldHome; + } + await rm(homeDir, { recursive: true, force: true }); + await rm(workDir, { recursive: true, force: true }); +}); + +describe.skipIf(!ENABLED)('local logging export e2e', () => { + it('exports session log and global log by default, and allows skipping global log', async () => { + const harness = new KimiHarness({ + homeDir, + identity: createKimiCodeHostIdentity('0.1.1'), + }); + try { + const session = await harness.createSession({ + id: 'ses_cli_logging_export', + workDir, + }); + log.warn('cli logging export marker', { sessionId: session.id }); + log.warn('cli global marker'); + + const defaultZip = join(workDir, 'default.zip'); + await runKimiExport([session.id, '-o', defaultZip]); + const defaultEntries = readZipEntries(await readFile(defaultZip)); + expect(defaultEntries.has(MAIN_WIRE)).toBe(true); + expect(defaultEntries.has(SESSION_LOG)).toBe(true); + expect(defaultEntries.has(GLOBAL_LOG)).toBe(true); + expect(defaultEntries.get(SESSION_LOG)!.toString('utf-8')).toContain( + 'cli logging export marker', + ); + expect(defaultEntries.get(GLOBAL_LOG)!.toString('utf-8')).toContain('cli global marker'); + const defaultManifest = JSON.parse( + defaultEntries.get('manifest.json')!.toString('utf-8'), + ) as Record<string, unknown>; + expect(defaultManifest['sessionLogPath']).toBe(SESSION_LOG); + expect(defaultManifest['globalLogPath']).toBe(GLOBAL_LOG); + + const noGlobalZip = join(workDir, 'no-global.zip'); + await runKimiExport([session.id, '-o', noGlobalZip, '--no-include-global-log']); + const noGlobalEntries = readZipEntries(await readFile(noGlobalZip)); + expect(noGlobalEntries.has(GLOBAL_LOG)).toBe(false); + const noGlobalManifest = JSON.parse( + noGlobalEntries.get('manifest.json')!.toString('utf-8'), + ) as Record<string, unknown>; + expect(noGlobalManifest['globalLogPath']).toBeUndefined(); + } finally { + await harness.close().catch(() => {}); + } + }, 15_000); +}); + +async function runKimiExport(args: string[]): Promise<void> { + const program = new Command('kimi'); + const stdout: string[] = []; + const stderr: string[] = []; + registerExportCommand(program, { + stdout: { + write: (chunk) => { + stdout.push(chunk); + return true; + }, + }, + stderr: { + write: (chunk) => { + stderr.push(chunk); + return true; + }, + }, + exit: (code: number): never => { + throw new Error(`kimi export exited ${code}: ${stderr.join('')}`); + }, + }); + await program.parseAsync(['node', 'kimi', 'export', ...args]); +} + +function readZipEntries(buf: Buffer): Map<string, Buffer> { + let eocd = -1; + for (let i = buf.length - 22; i >= Math.max(0, buf.length - 65_557); i--) { + if (buf.readUInt32LE(i) === 0x06054b50) { + eocd = i; + break; + } + } + if (eocd === -1) throw new Error('zip eocd not found'); + const entryCount = buf.readUInt16LE(eocd + 10); + const cdOffset = buf.readUInt32LE(eocd + 16); + const entries = new Map<string, Buffer>(); + let pos = cdOffset; + for (let i = 0; i < entryCount; i++) { + if (buf.readUInt32LE(pos) !== 0x02014b50) throw new Error('bad cd entry'); + const method = buf.readUInt16LE(pos + 10); + const compressedSize = buf.readUInt32LE(pos + 20); + const fnameLen = buf.readUInt16LE(pos + 28); + const extraLen = buf.readUInt16LE(pos + 30); + const commentLen = buf.readUInt16LE(pos + 32); + const lfhOffset = buf.readUInt32LE(pos + 42); + const filename = buf.toString('utf8', pos + 46, pos + 46 + fnameLen); + if (buf.readUInt32LE(lfhOffset) !== 0x04034b50) throw new Error('bad lfh'); + const lfhFnameLen = buf.readUInt16LE(lfhOffset + 26); + const lfhExtraLen = buf.readUInt16LE(lfhOffset + 28); + const dataStart = lfhOffset + 30 + lfhFnameLen + lfhExtraLen; + const compressed = buf.subarray(dataStart, dataStart + compressedSize); + const data = method === 0 ? compressed : method === 8 ? zlib.inflateRawSync(compressed) : null; + if (data === null) throw new Error('unsupported compression'); + entries.set(filename, data); + pos += 46 + fnameLen + extraLen + commentLen; + } + return entries; +} diff --git a/apps/kimi-code/test/e2e/real-llm-smoke.e2e.test.ts b/apps/kimi-code/test/e2e/real-llm-smoke.e2e.test.ts new file mode 100644 index 000000000..ffa93ba06 --- /dev/null +++ b/apps/kimi-code/test/e2e/real-llm-smoke.e2e.test.ts @@ -0,0 +1,81 @@ +/** + * Real-LLM smoke. Opt-in only — runs when `KIMI_E2E_REAL=1` is set, so + * `make test` / CI never touches the network. Goes through the SDK's + * `KimiHarness` entry and round-trips one real prompt. Requires provider + * credentials on disk. + * + * Env knobs: + * KIMI_E2E_REAL — set to "1" to enable this suite + * KIMI_E2E_MODEL — model alias override (default: config's default) + * KIMI_E2E_PROMPT — prompt text (default: "Reply with a single word: hi") + * KIMI_E2E_WORKDIR — workspace directory (default: /tmp/kimi-e2e) + */ + +import { mkdirSync } from 'node:fs'; +import process from 'node:process'; + +import { KimiHarness, type Event } from '@moonshot-ai/kimi-code-sdk'; +import { describe, expect, test } from 'vitest'; + +import { createKimiCodeHostIdentity, getVersion } from '#/cli/version'; + +const DEFAULT_PROMPT = 'Reply with a single word: hi'; +const DEFAULT_WORKDIR = '/tmp/kimi-e2e'; +const TURN_TIMEOUT_MS = 60_000; + +const ENABLED = process.env['KIMI_E2E_REAL'] === '1'; +type TurnEndedEvent = Extract<Event, { readonly type: 'turn.ended' }>; + +describe.skipIf(!ENABLED)('SDK e2e — real LLM smoke', () => { + test( + 'round-trips a single prompt through KimiHarness', + async () => { + const workDir = process.env['KIMI_E2E_WORKDIR'] ?? DEFAULT_WORKDIR; + const prompt = process.env['KIMI_E2E_PROMPT'] ?? DEFAULT_PROMPT; + const modelAlias = process.env['KIMI_E2E_MODEL']; + mkdirSync(workDir, { recursive: true }); + + const version = getVersion(); + process.stderr.write( + `[smoke] workDir=${workDir}${modelAlias !== undefined ? ` model=${modelAlias}` : ''}\n` + + `[smoke] prompt=${JSON.stringify(prompt)}\n`, + ); + + const harness = new KimiHarness({ + identity: createKimiCodeHostIdentity(version), + }); + + try { + const session = await harness.createSession({ + workDir, + model: modelAlias, + }); + process.stderr.write(`[smoke] session created: ${session.id}\n`); + + const turnEnded = new Promise<TurnEndedEvent>((resolve) => { + const off = session.onEvent((event) => { + const payload = JSON.stringify(event).slice(0, 500); + process.stdout.write(`[smoke][event:${event.type}] ${payload}\n`); + if (event.type === 'turn.ended') { + off(); + resolve(event); + } + }); + }); + + await session.prompt(prompt); + process.stderr.write('[smoke] prompt dispatched\n'); + + const event = await turnEnded; + process.stderr.write(`[smoke] turn.ended reason=${event.reason}\n`); + if (event.error !== undefined) { + process.stderr.write(`[smoke] error=${event.error.message}\n`); + } + expect(event.reason).toBe('completed'); + } finally { + await harness.close().catch(() => {}); + } + }, + TURN_TIMEOUT_MS + 10_000, + ); +}); diff --git a/apps/kimi-code/test/helpers/process.ts b/apps/kimi-code/test/helpers/process.ts new file mode 100644 index 000000000..5a861724b --- /dev/null +++ b/apps/kimi-code/test/helpers/process.ts @@ -0,0 +1,32 @@ +import { vi } from 'vitest'; + +export class ExitCalled extends Error { + constructor(public readonly code: number) { + super(`exit(${code})`); + } +} + +export function mockProcessExit(): { mockRestore(): void } { + return vi.spyOn(process, 'exit').mockImplementation(((code?: string | number | null) => { + throw new ExitCalled(Number(code ?? 0)); + }) as never); +} + +export function captureProcessWrite(stream: 'stdout' | 'stderr'): { + readonly chunks: string[]; + readonly text: () => string; + readonly restore: () => void; +} { + const chunks: string[] = []; + const target = process[stream]; + const spy = vi.spyOn(target, 'write').mockImplementation(((chunk: string | Uint8Array) => { + chunks.push(typeof chunk === 'string' ? chunk : Buffer.from(chunk).toString('utf8')); + return true; + }) as never); + + return { + chunks, + text: () => chunks.join(''), + restore: () =>{ spy.mockRestore(); }, + }; +} diff --git a/apps/kimi-code/test/migration/badge.test.ts b/apps/kimi-code/test/migration/badge.test.ts new file mode 100644 index 000000000..68fb72bf3 --- /dev/null +++ b/apps/kimi-code/test/migration/badge.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from 'vitest'; + +import { formatSessionLabel } from '#/migration/badge'; + +describe('formatSessionLabel', () => { + it('prepends [imported] when metadata.imported_from_kimi_cli === true', () => { + const label = formatSessionLabel({ + title: 'Refactor sessions list', + metadata: { imported_from_kimi_cli: true }, + }); + expect(label).toBe('[imported] Refactor sessions list'); + }); + + it('does not prepend [imported] when metadata is missing', () => { + const label = formatSessionLabel({ title: 'Plain session' }); + expect(label).toBe('Plain session'); + }); + + it('does not prepend [imported] when metadata is empty', () => { + const label = formatSessionLabel({ title: 'Plain session', metadata: {} }); + expect(label).toBe('Plain session'); + }); + + it('only triggers on the literal boolean true (not truthy values)', () => { + const label = formatSessionLabel({ + title: 'truthy but not true', + metadata: { imported_from_kimi_cli: 'yes' as unknown }, + }); + expect(label).toBe('truthy but not true'); + }); + + it('does not prepend [imported] when flag is false', () => { + const label = formatSessionLabel({ + title: 'native session', + metadata: { imported_from_kimi_cli: false }, + }); + expect(label).toBe('native session'); + }); + + it('preserves the title even when it is empty', () => { + const label = formatSessionLabel({ + title: '', + metadata: { imported_from_kimi_cli: true }, + }); + expect(label).toBe('[imported] '); + }); +}); diff --git a/apps/kimi-code/test/migration/command.test.ts b/apps/kimi-code/test/migration/command.test.ts new file mode 100644 index 000000000..2124dd70b --- /dev/null +++ b/apps/kimi-code/test/migration/command.test.ts @@ -0,0 +1,29 @@ +/** + * `kimi migrate` — a bare, flagless subcommand that delegates to a host + * handler. The migration UI is the native pi-tui screen, covered separately + * by `migration-screen.test.ts`. + */ + +import { Command } from 'commander'; +import { describe, expect, it, vi } from 'vitest'; + +import { registerMigrateCommand } from '#/migration/command'; + +describe('registerMigrateCommand', () => { + it('adds a flagless migrate subcommand to the program', () => { + const program = new Command('kimi'); + registerMigrateCommand(program, () => {}); + const sub = program.commands.find((c) => c.name() === 'migrate'); + expect(sub).toBeDefined(); + expect(sub!.description()).toContain('Migrate'); + expect(sub!.options).toHaveLength(0); + }); + + it('invokes the host handler when `migrate` runs', () => { + const program = new Command('kimi'); + const onMigrate = vi.fn(); + registerMigrateCommand(program, onMigrate); + program.parse(['migrate'], { from: 'user' }); + expect(onMigrate).toHaveBeenCalledTimes(1); + }); +}); diff --git a/apps/kimi-code/test/migration/detect-pending.test.ts b/apps/kimi-code/test/migration/detect-pending.test.ts new file mode 100644 index 000000000..c674d054d --- /dev/null +++ b/apps/kimi-code/test/migration/detect-pending.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, it, beforeEach, afterEach } from 'vitest'; +import { mkdtemp, mkdir, writeFile, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { detectPendingMigration } from '#/migration/detect-pending'; + +let src: string; +let tgt: string; +beforeEach(async () => { + src = await mkdtemp(join(tmpdir(), 'detect-pending-src-')); + tgt = await mkdtemp(join(tmpdir(), 'detect-pending-tgt-')); +}); +afterEach(async () => { + await rm(src, { recursive: true, force: true }); + await rm(tgt, { recursive: true, force: true }); +}); + +describe('detectPendingMigration', () => { + it('returns null when source dir does not exist', async () => { + const plan = await detectPendingMigration({ sourceHome: join(src, 'nope'), targetHome: tgt }); + expect(plan).toBeNull(); + }); + + it('returns null when the migrated marker exists', async () => { + await writeFile(join(src, '.migrated-to-kimi-code'), '{}', 'utf-8'); + const plan = await detectPendingMigration({ sourceHome: src, targetHome: tgt }); + expect(plan).toBeNull(); + }); + + it('returns null when the skip marker exists in target', async () => { + await writeFile(join(src, 'config.toml'), '', 'utf-8'); + await writeFile(join(tgt, '.skip-migration-from-kimi-cli'), '', 'utf-8'); + const plan = await detectPendingMigration({ sourceHome: src, targetHome: tgt }); + expect(plan).toBeNull(); + }); + + it('returns null when source has nothing worth migrating', async () => { + // empty source dir, no config/mcp/credentials/sessions + const plan = await detectPendingMigration({ sourceHome: src, targetHome: tgt }); + expect(plan).toBeNull(); + }); + + it('returns a MigrationPlan when source has migratable data', async () => { + await writeFile(join(src, 'config.toml'), 'default_thinking = true\n', 'utf-8'); + const plan = await detectPendingMigration({ sourceHome: src, targetHome: tgt }); + expect(plan).not.toBeNull(); + expect(plan?.hasConfig).toBe(true); + }); + + it('returns a MigrationPlan when source has only user-history', async () => { + await mkdir(join(src, 'user-history'), { recursive: true }); + await writeFile(join(src, 'user-history', 'shell.txt'), 'ls\n', 'utf-8'); + const plan = await detectPendingMigration({ sourceHome: src, targetHome: tgt }); + expect(plan).not.toBeNull(); + expect(plan?.hasUserHistory).toBe(true); + }); + + it('does not suppress when the marker targeted a different home', async () => { + await writeFile(join(src, 'config.toml'), 'default_thinking = true\n', 'utf-8'); + await writeFile( + join(src, '.migrated-to-kimi-code'), + JSON.stringify({ version: 1, target_path: '/some/other/home' }), + 'utf-8', + ); + const plan = await detectPendingMigration({ sourceHome: src, targetHome: tgt }); + expect(plan).not.toBeNull(); // this target was never migrated → still offer + }); + + it('suppresses when the marker targeted this home', async () => { + await writeFile(join(src, 'config.toml'), 'default_thinking = true\n', 'utf-8'); + await writeFile( + join(src, '.migrated-to-kimi-code'), + JSON.stringify({ version: 1, target_path: tgt }), + 'utf-8', + ); + const plan = await detectPendingMigration({ sourceHome: src, targetHome: tgt }); + expect(plan).toBeNull(); + }); +}); diff --git a/apps/kimi-code/test/migration/migration-screen.test.ts b/apps/kimi-code/test/migration/migration-screen.test.ts new file mode 100644 index 000000000..90b34dca3 --- /dev/null +++ b/apps/kimi-code/test/migration/migration-screen.test.ts @@ -0,0 +1,617 @@ +import { describe, expect, it, vi } from 'vitest'; +import { + MigrationScreenComponent, + type MigrationScreenResult, +} from '#/migration/migration-screen'; +import { darkColors } from '#/tui/theme/colors'; +import type { + MigrationPlan, + MigrationReport, + RunMigrationInput, +} from '@moonshot-ai/migration-legacy'; + +function makePlan(over: Partial<MigrationPlan> = {}): MigrationPlan { + return { + sourceHome: '/x/.kimi', + hasConfig: true, + hasMcp: true, + hasUserHistory: true, + oauthCredentials: ['kimi-code.json'], + workdirs: [], + detectedPlugins: [], + detectedMcpOauthServers: [], + totalSessions: 1365, + ...over, + }; +} + +function render(c: MigrationScreenComponent): string { + return c.render(80).join('\n'); +} + +describe('MigrationScreenComponent — ask phase', () => { + it('ask1 renders the intro block and three options', () => { + const c = new MigrationScreenComponent({ + plan: makePlan(), + sourceHome: '/x/.kimi', + targetHome: '/y/.kimi-code', + colors: darkColors, + onComplete: () => {}, + }); + const out = render(c); + expect(out).toContain('Migrate from kimi-cli'); + expect(out).toContain('1365 sessions'); + expect(out).toContain('Migrate now'); + expect(out).toContain('Ask me later'); + expect(out).toContain('Never ask again'); + }); + + it('ask1 summary shows the kimi-cli login hint when OAuth credentials are detected', async () => { + // OAuth credentials are detected but not migrated — surface that up front + // so the pre-migration summary is consistent with the result screen's + // "⚠ kimi-cli login not migrated — run /login" line. + const c = new MigrationScreenComponent({ + plan: makePlan(), + sourceHome: '/x/.kimi', + targetHome: '/y/.kimi-code', + colors: darkColors, + onComplete: () => {}, + }); + const out = render(c); + expect(out).toContain('kimi-cli login'); + }); + + it('ask1 renders a non-empty summary when the only detected data is an OAuth login', async () => { + // Legacy install with nothing but `credentials/*.json` would otherwise + // render the summary line as blank — `summarizePlan` no longer treats + // OAuth as a migrated kind. The OAuth hint must keep that line useful. + const c = new MigrationScreenComponent({ + plan: makePlan({ + hasConfig: false, + hasMcp: false, + hasUserHistory: false, + totalSessions: 0, + workdirs: [], + }), + sourceHome: '/x/.kimi', + targetHome: '/y/.kimi-code', + colors: darkColors, + onComplete: () => {}, + }); + const out = render(c); + expect(out).toContain('kimi-cli login'); + // ...and the summary line is not blank — the OAuth hint is its content. + expect(out).toMatch(/Found an existing kimi-cli installation:\n\s+\S/); + }); + + it('picking "Ask me later" at ask1 completes with decision=later', () => { + let result: { decision: string } | undefined; + const c = new MigrationScreenComponent({ + plan: makePlan(), + sourceHome: '/x/.kimi', + targetHome: '/y/.kimi-code', + colors: darkColors, + onComplete: (r) => { + result = r; + }, + }); + c.handleInput('\u001B[B'); // Down -> "Ask me later" + c.handleInput('\r'); // Enter + expect(result?.decision).toBe('later'); + }); + + it('"Migrate now" -> "Config only" advances ask1 -> ask2 and resolves scope.sessions=false', async () => { + let captured: RunMigrationInput | undefined; + const c = new MigrationScreenComponent({ + plan: makePlan(), + sourceHome: '/x/.kimi', + targetHome: '/y/.kimi-code', + colors: darkColors, + runMigration: async (input) => { + captured = input; + return makeReport(); + }, + onComplete: () => {}, + }); + c.handleInput('\r'); // ask1: "Migrate now" + c.handleInput('\r'); // ask2: "Config only" (first option) + await new Promise((r) => setTimeout(r, 0)); + expect(captured?.scope.sessions).toBe(false); + }); + + it('"Migrate now" -> "Config + sessions" begins migration immediately with sessions=true', async () => { + let captured: RunMigrationInput | undefined; + const c = new MigrationScreenComponent({ + plan: makePlan(), + sourceHome: '/x/.kimi', + targetHome: '/y/.kimi-code', + colors: darkColors, + runMigration: async (input) => { + captured = input; + return makeReport(); + }, + onComplete: () => {}, + }); + c.handleInput('\r'); // ask1: Migrate now + c.handleInput('\u001B[B'); // ask2: down -> Also migrate sessions + c.handleInput('\r'); // ask2 select -> "Config + N sessions" begins migration immediately + await new Promise((r) => setTimeout(r, 0)); + expect(captured?.scope.sessions).toBe(true); + }); + + it('ask2 shows the detected session count alongside the "config only" option', () => { + const c = new MigrationScreenComponent({ + plan: makePlan({ totalSessions: 1365 }), + sourceHome: '/x/.kimi', + targetHome: '/y/.kimi-code', + colors: darkColors, + onComplete: () => {}, + }); + c.handleInput('\r'); // ask1: Migrate now -> ask2 + const out = render(c); + expect(out).toContain('Config only'); + // Concrete count so the user sees the cost of "+ sessions" up front. + expect(out).toContain('Config + 1365 sessions'); + expect(out).not.toContain('Most recent'); + expect(out).not.toContain('Migrate now'); + }); + + it('ask2 falls back to "Config + all sessions" when no sessions were detected', () => { + const c = new MigrationScreenComponent({ + plan: makePlan({ totalSessions: 0 }), + sourceHome: '/x/.kimi', + targetHome: '/y/.kimi-code', + colors: darkColors, + onComplete: () => {}, + }); + c.handleInput('\r'); // ask1 -> ask2 + const out = render(c); + expect(out).toContain('Config + all sessions'); + // "Config + 0 sessions" would read as an obvious dead-end. + expect(out).not.toContain('Config + 0 sessions'); + }); + + it('skipDecisionStep starts at the scope question with the now/later/never gate hidden', () => { + const c = new MigrationScreenComponent({ + plan: makePlan(), + sourceHome: '/x/.kimi', + targetHome: '/y/.kimi-code', + colors: darkColors, + skipDecisionStep: true, + onComplete: () => {}, + }); + const out = render(c); + expect(out).toContain('Migrate chat sessions too?'); + expect(out).not.toContain('Migrate now'); + expect(out).not.toContain('Never ask again'); + }); + + it('skipDecisionStep -> "Config only" resolves scope without the decision step', async () => { + let captured: RunMigrationInput | undefined; + const c = new MigrationScreenComponent({ + plan: makePlan(), + sourceHome: '/x/.kimi', + targetHome: '/y/.kimi-code', + colors: darkColors, + skipDecisionStep: true, + runMigration: async (input) => { + captured = input; + return makeReport(); + }, + onComplete: () => {}, + }); + c.handleInput('\r'); // ask2: "Config only" (first option) — no ask1 gate + await new Promise((r) => setTimeout(r, 0)); + expect(captured?.scope.sessions).toBe(false); + }); +}); + +describe('MigrationScreenComponent — progress phase', () => { + it('renders a step checklist and the session counter when in progress', () => { + const c = new MigrationScreenComponent({ + plan: makePlan(), + sourceHome: '/x/.kimi', + targetHome: '/y/.kimi-code', + colors: darkColors, + onComplete: () => {}, + }); + // expose progress rendering via the test hook (see Step 5.2) + c._testEnterProgress(); + c._testUpdateStep('config done'); + c._testUpdateSessionProgress(32, 50); + const out = c.render(80).join('\n'); + expect(out).toContain('Migrating from kimi-cli'); + expect(out).toContain('32 / 50'); + expect(out).toContain('Config'); + }); + + it('animates the progress spinner while a migration step runs', async () => { + vi.useFakeTimers(); + try { + const c = new MigrationScreenComponent({ + plan: makePlan(), + sourceHome: '/x/.kimi', + targetHome: '/y/.kimi-code', + colors: darkColors, + skipDecisionStep: true, + // A migration that never settles keeps the screen in the progress + // phase so the spinner animation can be observed. + runMigration: () => new Promise<MigrationReport>(() => {}), + onComplete: () => {}, + }); + c.handleInput('\r'); // ask2: "Config only" -> migration begins + c._testUpdateSessionProgress(1, 3); // surface the spinner line + const before = c.render(80).join('\n'); + vi.advanceTimersByTime(400); // several spinner frames + const after = c.render(80).join('\n'); + // Before the fix nothing advanced the spinner — the frame, and the whole + // progress render, stayed frozen on the first braille glyph. + expect(after).not.toBe(before); + } finally { + vi.useRealTimers(); + } + }); + + it('tracks Config and MCP as independent steps', () => { + const c = new MigrationScreenComponent({ + plan: makePlan(), + sourceHome: '/x/.kimi', + targetHome: '/y/.kimi-code', + colors: darkColors, + onComplete: () => {}, + }); + c._testEnterProgress(); + c._testUpdateStep('config done'); // config finished; MCP has not started + const out = c.render(80).join('\n'); + // Four checklist rows (config, mcp, user-history, sessions). With only + // config done, exactly one shows ✓ and the other three show ◐ — MCP is + // its own step and stays pending. + expect((out.match(/✓/g) ?? []).length).toBe(1); + expect((out.match(/◐/g) ?? []).length).toBe(3); + }); +}); + +function makeReport( + over: Partial<MigrationReport['summary']['sessions']> = {}, + summaryOver: Partial<MigrationReport['summary']> = {}, + noticesOver: Partial<MigrationReport['notices']> = {}, +): MigrationReport { + return { + startedAt: 's', + completedAt: 'e', + migratorVersion: '0.1.1', + source: '/x/.kimi', + target: '/y/.kimi-code', + summary: { + config: { + migrated: true, + tuiExtracted: false, + droppedProviders: [], + droppedModels: [], + droppedKeys: [], + configConflicts: [], + wroteSiblingDueToConflict: false, + wroteTuiSibling: false, + migratedHooks: 0, + droppedHooks: 0, + siblingContents: { providers: [], models: [], hooks: 0 }, + }, + mcp: { mergedServers: [], keptNewForConflicts: [], droppedServers: [], wroteSiblingDueToConflict: false }, + userHistory: { copied: 12, skippedExisting: 0 }, + sessions: { + scope: 'all', + bucketsScanned: 0, + bucketsSkippedNonlocalKaos: 0, + bucketsSkippedNoWorkdirFound: 0, + sessionsAttempted: 50, + sessionsMigrated: 50, + sessionsAlreadyMigrated: 0, + sessionsSkippedPlaceholder: 0, + sessionsSkippedEmpty: 0, + sessionsSkippedMalformed: 0, + sessionsFailed: [], + sessionsConflicts: [], + ...over, + }, + ...summaryOver, + }, + notices: { + mcpOauthServersRequiringReauth: [], + oauthLoginsRequiringRelogin: [], + detectedPlugins: ['p1', 'p2'], + configConflictNotice: null, + tuiConflictNotice: null, + ...noticesOver, + }, + }; +} + +describe('MigrationScreenComponent — result phase', () => { + it('renders the report summary including plugin notices', () => { + const c = new MigrationScreenComponent({ + plan: makePlan(), + sourceHome: '/x/.kimi', + targetHome: '/y/.kimi-code', + colors: darkColors, + onComplete: () => {}, + }); + c._testShowResult(makeReport()); + const out = c.render(80).join('\n'); + expect(out).toContain('Migration complete'); + expect(out).toContain('50 sessions migrated'); + expect(out).toContain('2 kimi-cli plugins'); + }); + + it('renders migrated hooks in the ✓ line and dropped hooks as a warning', () => { + const c = new MigrationScreenComponent({ + plan: makePlan(), + sourceHome: '/x/.kimi', + targetHome: '/y/.kimi-code', + colors: darkColors, + onComplete: () => {}, + }); + c._testShowResult( + makeReport( + {}, + { + config: { + migrated: true, + tuiExtracted: false, + droppedProviders: [], + droppedModels: [], + droppedKeys: [], + configConflicts: [], + wroteSiblingDueToConflict: false, + wroteTuiSibling: false, + migratedHooks: 2, + droppedHooks: 1, + siblingContents: { providers: [], models: [], hooks: 0 }, + }, + }, + ), + ); + const out = c.render(80).join('\n'); + expect(out).toContain('· hooks'); // appears in the ✓ migrated-kinds line + expect(out).toContain('1 hooks dropped'); + }); + + it('Enter on the result screen completes with the prior decision', () => { + let result: MigrationScreenResult | undefined; + const c = new MigrationScreenComponent({ + plan: makePlan(), + sourceHome: '/x/.kimi', + targetHome: '/y/.kimi-code', + colors: darkColors, + onComplete: (r) => { + result = r; + }, + }); + c._testShowResult(makeReport()); + c.handleInput('\r'); + expect(result?.decision).toBe('now'); + expect(result?.migrated).toBe(true); + }); + + it('omits a data class from the result when it was not migrated', () => { + const c = new MigrationScreenComponent({ + plan: makePlan(), + sourceHome: '/x/.kimi', + targetHome: '/y/.kimi-code', + colors: darkColors, + onComplete: () => {}, + }); + // config skipped (e.g. a malformed legacy config.toml). + c._testShowResult( + makeReport( + {}, + { + config: { + migrated: false, + tuiExtracted: false, + droppedProviders: [], + droppedModels: [], + droppedKeys: [], + configConflicts: [], + wroteSiblingDueToConflict: false, + wroteTuiSibling: false, + migratedHooks: 0, + droppedHooks: 0, + siblingContents: { providers: [], models: [], hooks: 0 }, + }, + }, + ), + ); + const out = c.render(80).join('\n'); + // REPL history (copied) is still shown... + expect(out).toContain('REPL history'); + // ...but config must not be claimed as migrated. + expect(out).not.toContain('config'); + }); + + it('surfaces conflict and failure warnings on the result screen', () => { + const c = new MigrationScreenComponent({ + plan: makePlan(), + sourceHome: '/x/.kimi', + targetHome: '/y/.kimi-code', + colors: darkColors, + onComplete: () => {}, + }); + c._testShowResult( + makeReport( + { sessionsFailed: [{ sourcePath: '/s', reason: 'bad' }] }, + { + config: { + migrated: true, + tuiExtracted: false, + droppedProviders: [], + droppedModels: [], + droppedKeys: [], + configConflicts: [], + wroteSiblingDueToConflict: true, + wroteTuiSibling: false, + migratedHooks: 0, + droppedHooks: 0, + siblingContents: { providers: [], models: [], hooks: 0 }, + }, + mcp: { mergedServers: ['m'], keptNewForConflicts: [], droppedServers: [], wroteSiblingDueToConflict: true }, + }, + ), + ); + const out = c.render(80).join('\n'); + expect(out).toContain('config.migrated-from-kimi-cli.toml'); + expect(out).toContain('mcp.migrated-from-kimi-cli.json'); + expect(out).toContain('1 sessions failed'); + }); + + it('lists sibling-file contents in the config-fallback warning so the user knows what to merge', () => { + // When the target's `config.toml` could not be parsed and migration writes + // to `config.migrated-from-kimi-cli.toml` instead, the result screen must + // (a) name the sibling, (b) say what's in it so the user knows what to + // merge by hand, and (c) describe the trigger accurately (parse failure, + // not "unreadable"). Otherwise users have to crack the file open to find + // out — and they may not realize hooks landed in there at all. + const c = new MigrationScreenComponent({ + plan: makePlan(), + sourceHome: '/x/.kimi', + targetHome: '/y/.kimi-code', + colors: darkColors, + onComplete: () => {}, + }); + c._testShowResult( + makeReport( + {}, + { + config: { + migrated: true, + tuiExtracted: false, + droppedProviders: [], + droppedModels: [], + droppedKeys: [], + configConflicts: [], + wroteSiblingDueToConflict: true, + wroteTuiSibling: false, + migratedHooks: 0, + droppedHooks: 0, + siblingContents: { + providers: ['openai', 'managed:kimi-code'], + models: ['gpt4'], + hooks: 3, + }, + }, + }, + ), + ); + const out = c.render(80).join('\n'); + expect(out).toContain('config.migrated-from-kimi-cli.toml'); + // Accurate trigger description (file parses, not "unreadable"). + expect(out).toContain('could not be parsed'); + // Enumeration of what's inside the sibling. + expect(out).toContain('2 providers'); + expect(out).toContain('1 model'); + expect(out).toContain('3 hooks'); + }); + + it('shows skipped empty sessions as a muted line, not a failure', () => { + const c = new MigrationScreenComponent({ + plan: makePlan(), + sourceHome: '/x/.kimi', + targetHome: '/y/.kimi-code', + colors: darkColors, + onComplete: () => {}, + }); + c._testShowResult(makeReport({ sessionsSkippedEmpty: 3 })); + const out = c.render(80).join('\n'); + expect(out).toContain('3 empty sessions skipped'); + // It is informational, not a failure. + expect(out).not.toContain('3 sessions failed'); + }); + + it('lists kept config settings on the result screen when kimi-cli differed', () => { + const c = new MigrationScreenComponent({ + plan: makePlan(), + sourceHome: '/x/.kimi', + targetHome: '/y/.kimi-code', + colors: darkColors, + onComplete: () => {}, + }); + c._testShowResult( + makeReport( + {}, + { + config: { + migrated: true, + tuiExtracted: false, + droppedProviders: [], + droppedModels: [], + droppedKeys: [], + configConflicts: ['default_model', 'providers.kimi'], + wroteSiblingDueToConflict: false, + wroteTuiSibling: false, + migratedHooks: 0, + droppedHooks: 0, + siblingContents: { providers: [], models: [], hooks: 0 }, + }, + }, + ), + ); + const out = c.render(80).join('\n'); + expect(out).toContain('2 config conflicts kept yours'); + expect(out).toContain('default_model · providers.kimi'); + }); + + it('surfaces MCP servers that need re-authentication', () => { + const c = new MigrationScreenComponent({ + plan: makePlan(), + sourceHome: '/x/.kimi', + targetHome: '/y/.kimi-code', + colors: darkColors, + onComplete: () => {}, + }); + c._testShowResult(makeReport({}, {}, { mcpOauthServersRequiringReauth: ['srv-a', 'srv-b'] })); + const out = c.render(80).join('\n'); + expect(out).toContain('2 MCP servers need re-authentication'); + }); +}); + +describe('MigrationScreenComponent — execution wiring', () => { + it('runs migration after the ask phase and lands on the result phase', async () => { + const fakeReport = makeReport(); + let onCompleteResult: MigrationScreenResult | undefined; + const c = new MigrationScreenComponent({ + plan: makePlan(), + sourceHome: '/x/.kimi', + targetHome: '/y/.kimi-code', + colors: darkColors, + onComplete: (r) => { + onCompleteResult = r; + }, + // injected runner for testability — no filesystem access + runMigration: async (_input) => fakeReport, + }); + c.handleInput('\r'); // ask1: Migrate now + c.handleInput('\r'); // ask2: Config only -> begins migration + // migration is async; wait a tick + await new Promise((res) => setTimeout(res, 0)); + expect(c.render(80).join('\n')).toContain('Migration complete'); + c.handleInput('\r'); // dismiss result + expect(onCompleteResult?.decision).toBe('now'); + expect(onCompleteResult?.migrated).toBe(true); + }); + + it('lands on the failure screen when the runner rejects', async () => { + const c = new MigrationScreenComponent({ + plan: makePlan(), + sourceHome: '/x/.kimi', + targetHome: '/y/.kimi-code', + colors: darkColors, + onComplete: () => {}, + runMigration: async () => { + throw new Error('boom'); + }, + }); + c.handleInput('\r'); // ask1: Migrate now + c.handleInput('\r'); // ask2: Config only -> begins migration + await new Promise((res) => setTimeout(res, 0)); + expect(c.render(80).join('\n')).toContain('Migration failed'); + }); +}); diff --git a/apps/kimi-code/test/native/cache-base.test.ts b/apps/kimi-code/test/native/cache-base.test.ts new file mode 100644 index 000000000..896aefabc --- /dev/null +++ b/apps/kimi-code/test/native/cache-base.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from 'vitest'; + +import { getNativeCacheBase } from '#/native/native-assets'; + +describe('getNativeCacheBase precedence', () => { + const baseOptions = { homeDir: '/home/u' }; + + it('uses KIMI_CODE_CACHE_DIR when set (highest precedence)', () => { + const got = getNativeCacheBase({ + ...baseOptions, + env: { KIMI_CODE_CACHE_DIR: '/custom/cache' }, + }); + expect(got).toBe('/custom/cache'); + }); + + it('ignores KIMI_CODE_HOME (no longer affects native cache)', () => { + const got = getNativeCacheBase({ + ...baseOptions, + env: { KIMI_CODE_HOME: '/legacy' }, + platform: 'darwin', + }); + expect(got).toBe('/home/u/Library/Caches/kimi-code'); + }); + + it('uses platform default on macOS when no env set', () => { + const got = getNativeCacheBase({ + ...baseOptions, + env: {}, + platform: 'darwin', + }); + expect(got).toBe('/home/u/Library/Caches/kimi-code'); + }); + + it('uses XDG_CACHE_HOME on Linux when set', () => { + const got = getNativeCacheBase({ + ...baseOptions, + env: { XDG_CACHE_HOME: '/xdg' }, + platform: 'linux', + }); + expect(got).toBe('/xdg/kimi-code'); + }); + + it('uses LOCALAPPDATA on Windows when set', () => { + const got = getNativeCacheBase({ + ...baseOptions, + env: { LOCALAPPDATA: 'C:\\Users\\u\\AppData\\Local' }, + platform: 'win32', + }); + expect(got).toBe('C:\\Users\\u\\AppData\\Local\\kimi-code'); + }); +}); diff --git a/apps/kimi-code/test/native/cache-gc.test.ts b/apps/kimi-code/test/native/cache-gc.test.ts new file mode 100644 index 000000000..4f9203a15 --- /dev/null +++ b/apps/kimi-code/test/native/cache-gc.test.ts @@ -0,0 +1,103 @@ +import { mkdirSync, statSync, utimesSync, writeFileSync, readdirSync, mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { cleanupStaleNativeCache } from '#/native/native-assets'; + +describe('cleanupStaleNativeCache', () => { + let root: string; + + beforeEach(() => { + root = mkdtempSync(join(tmpdir(), 'kimi-cache-gc-')); + }); + + afterEach(() => { + rmSync(root, { recursive: true, force: true }); + }); + + function makeHashDir(version: string, target: string, hash: string, mtimeOffset = 0): string { + const dir = join(root, 'native', version, target, hash); + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, 'marker.txt'), hash); + const now = Math.floor(Date.now() / 1000); + utimesSync(dir, now + mtimeOffset, now + mtimeOffset); + return dir; + } + + it('keeps currentRoot + most recently modified sibling, deletes the rest', () => { + const v = '1.0.0'; + const t = 'darwin-arm64'; + const oldest = makeHashDir(v, t, 'aaaa', -300); + const middle = makeHashDir(v, t, 'bbbb', -200); + const recent = makeHashDir(v, t, 'cccc', -100); + const current = makeHashDir(v, t, 'dddd', 0); + + const result = cleanupStaleNativeCache({ + cacheBase: root, + version: v, + target: t, + currentRoot: current, + }); + + expect(result.kept.toSorted()).toEqual([recent, current].toSorted()); + expect(result.removed.toSorted()).toEqual([oldest, middle].toSorted()); + + expect(readdirSync(join(root, 'native', v, t)).toSorted()).toEqual(['cccc', 'dddd']); + }); + + it('no-op when only currentRoot exists', () => { + const v = '1.0.0'; + const t = 'darwin-arm64'; + const current = makeHashDir(v, t, 'dddd'); + + const result = cleanupStaleNativeCache({ + cacheBase: root, + version: v, + target: t, + currentRoot: current, + }); + + expect(result.kept).toEqual([current]); + expect(result.removed).toEqual([]); + }); + + it('no-op when target dir does not exist', () => { + const result = cleanupStaleNativeCache({ + cacheBase: root, + version: '1.0.0', + target: 'darwin-arm64', + currentRoot: join(root, 'native', '1.0.0', 'darwin-arm64', 'absent'), + }); + expect(result.kept).toEqual([]); + expect(result.removed).toEqual([]); + }); + + it('does not touch other targets or versions', () => { + const current = makeHashDir('1.0.0', 'darwin-arm64', 'dddd'); + const otherTarget = makeHashDir('1.0.0', 'linux-x64', 'eeee'); + const otherVersion = makeHashDir('0.9.0', 'darwin-arm64', 'ffff'); + + cleanupStaleNativeCache({ + cacheBase: root, + version: '1.0.0', + target: 'darwin-arm64', + currentRoot: current, + }); + + expect(statSync(otherTarget).isDirectory()).toBe(true); + expect(statSync(otherVersion).isDirectory()).toBe(true); + }); + + it('errors array is empty on clean run', () => { + const current = makeHashDir('1.0.0', 'darwin-arm64', 'dddd'); + const result = cleanupStaleNativeCache({ + cacheBase: root, + version: '1.0.0', + target: 'darwin-arm64', + currentRoot: current, + }); + expect(result.errors).toEqual([]); + }); +}); diff --git a/apps/kimi-code/test/native/native-assets.test.ts b/apps/kimi-code/test/native/native-assets.test.ts new file mode 100644 index 000000000..1946a6ccc --- /dev/null +++ b/apps/kimi-code/test/native/native-assets.test.ts @@ -0,0 +1,128 @@ +import { createHash } from 'node:crypto'; +import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { describe, expect, it } from 'vitest'; + +import { + getNativeCacheBase, + getNativePackageRoot, + NATIVE_ASSET_MANIFEST_VERSION, + type NativeAssetManifest, + type NativeAssetSource, +} from '#/native/native-assets'; +import { loadNativePackage } from '#/native/native-require'; + +function sha256(bytes: Buffer | string): string { + return createHash('sha256').update(bytes).digest('hex'); +} + +function fakeManifest(files: Record<string, string>): { + manifest: NativeAssetManifest; + source: NativeAssetSource; +} { + const assetEntries = Object.entries(files).map(([relativePath, content]) => { + const assetKey = `native/test-target/${relativePath}`; + return { + assetKey, + relativePath, + sha256: sha256(content), + }; + }); + const manifest: NativeAssetManifest = { + version: NATIVE_ASSET_MANIFEST_VERSION, + target: 'test-target', + packages: [ + { + name: 'fake-native', + root: 'node_modules/fake-native', + files: assetEntries, + }, + ], + }; + const manifestKey = 'native/test-target/manifest.json'; + const assets = new Map<string, Buffer>([ + [manifestKey, Buffer.from(JSON.stringify(manifest))], + ...Object.entries(files).map(([relativePath, content]) => [ + `native/test-target/${relativePath}`, + Buffer.from(content), + ] as const), + ]); + return { + manifest, + source: { + getAssetKeys: () => [...assets.keys()], + getRawAsset: (assetKey) => { + const asset = assets.get(assetKey); + if (asset === undefined) throw new Error(`missing test asset: ${assetKey}`); + return asset; + }, + }, + }; +} + +describe('native assets', () => { + it('uses KIMI_CODE_CACHE_DIR as the native cache base when present', () => { + expect( + getNativeCacheBase({ + env: { KIMI_CODE_CACHE_DIR: '/tmp/kimi-cache' }, + homeDir: '/home/kimi', + platform: 'linux', + }), + ).toBe('/tmp/kimi-cache'); + }); + + it('extracts package assets and repairs corrupted cache files', () => { + const dir = mkdtempSync(join(tmpdir(), 'kimi-native-assets-')); + try { + const { manifest, source } = fakeManifest({ + 'node_modules/fake-native/package.json': '{"main":"index.js"}', + 'node_modules/fake-native/index.js': "module.exports = { value: 'ok' };\n", + }); + + const packageRoot = getNativePackageRoot('fake-native', { + cacheBase: dir, + manifest, + source, + version: 'test', + }); + expect(packageRoot).toBe(join(dir, 'native', 'test', 'test-target', sha256(JSON.stringify(manifest)), 'node_modules', 'fake-native')); + expect(readFileSync(join(packageRoot ?? '', 'index.js'), 'utf-8')).toContain("value: 'ok'"); + + writeFileSync(join(packageRoot ?? '', 'index.js'), 'broken'); + const repairedRoot = getNativePackageRoot('fake-native', { + cacheBase: dir, + manifest, + source, + version: 'test', + }); + expect(repairedRoot).toBe(packageRoot); + expect(readFileSync(join(repairedRoot ?? '', 'index.js'), 'utf-8')).toContain("value: 'ok'"); + expect(existsSync(join(dir, 'native', 'test', 'test-target'))).toBe(true); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it('loads a package from extracted native assets', () => { + const dir = mkdtempSync(join(tmpdir(), 'kimi-native-require-')); + try { + const { manifest, source } = fakeManifest({ + 'node_modules/fake-native/package.json': '{"main":"index.js"}', + 'node_modules/fake-native/index.js': "module.exports = { value: 'ok' };\n", + }); + + const pkg = loadNativePackage<{ value: string }>('fake-native', { + cacheBase: dir, + manifest, + source, + version: 'test', + }); + + expect(pkg).toEqual({ value: 'ok' }); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); +}); diff --git a/apps/kimi-code/test/scripts/native/exec.test.ts b/apps/kimi-code/test/scripts/native/exec.test.ts new file mode 100644 index 000000000..87f581191 --- /dev/null +++ b/apps/kimi-code/test/scripts/native/exec.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from 'vitest'; + +import { commandForExecFile } from '../../../scripts/native/exec.mjs'; + +describe('commandForExecFile', () => { + it('returns command as-is on non-Windows', () => { + const result = commandForExecFile('postject', ['kimi', 'NODE_SEA_BLOB', './blob'], 'darwin'); + expect(result).toEqual({ command: 'postject', args: ['kimi', 'NODE_SEA_BLOB', './blob'] }); + }); + + it('returns command as-is on Windows for non-batch files', () => { + const result = commandForExecFile('postject.exe', ['kimi.exe'], 'win32'); + expect(result).toEqual({ command: 'postject.exe', args: ['kimi.exe'] }); + }); + + it('wraps .cmd files through cmd.exe on Windows', () => { + const result = commandForExecFile('postject.cmd', ['kimi.exe', 'NODE_SEA_BLOB'], 'win32', { + ComSpec: 'C:\\Windows\\System32\\cmd.exe', + }); + expect(result.command).toBe('C:\\Windows\\System32\\cmd.exe'); + expect(result.args).toEqual([ + '/d', + '/s', + '/c', + '""postject.cmd" "kimi.exe" "NODE_SEA_BLOB""', + ]); + expect(result.options?.windowsVerbatimArguments).toBe(true); + }); + + it('wraps .bat files through cmd.exe on Windows', () => { + const result = commandForExecFile('foo.bat', [], 'win32', { ComSpec: 'cmd.exe' }); + expect(result.command).toBe('cmd.exe'); + }); + + it('escapes embedded double quotes in args', () => { + const result = commandForExecFile('foo.cmd', ['hello "world"'], 'win32', { + ComSpec: 'cmd.exe', + }); + expect(result.args[3]).toBe('""foo.cmd" "hello ""world""""'); + }); + + it('falls back to cmd.exe when ComSpec missing', () => { + const result = commandForExecFile('foo.cmd', [], 'win32', {}); + expect(result.command).toBe('cmd.exe'); + }); +}); diff --git a/apps/kimi-code/test/scripts/native/manifest.test.ts b/apps/kimi-code/test/scripts/native/manifest.test.ts new file mode 100644 index 000000000..3f8b6892f --- /dev/null +++ b/apps/kimi-code/test/scripts/native/manifest.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from 'vitest'; + +import { + NATIVE_ASSET_MANIFEST_VERSION, + buildManifestKey, + isManifestVersionSupported, +} from '../../../scripts/native/manifest.mjs'; + +describe('NATIVE_ASSET_MANIFEST_VERSION', () => { + it('is a positive integer', () => { + expect(Number.isInteger(NATIVE_ASSET_MANIFEST_VERSION)).toBe(true); + expect(NATIVE_ASSET_MANIFEST_VERSION).toBeGreaterThan(0); + }); +}); + +describe('buildManifestKey', () => { + it('namespaces by target', () => { + expect(buildManifestKey('darwin-arm64')).toBe('native/darwin-arm64/manifest.json'); + expect(buildManifestKey('linux-x64')).toBe('native/linux-x64/manifest.json'); + }); +}); + +describe('isManifestVersionSupported', () => { + it('accepts current version', () => { + expect(isManifestVersionSupported(NATIVE_ASSET_MANIFEST_VERSION)).toBe(true); + }); + + it('rejects other versions', () => { + expect(isManifestVersionSupported(NATIVE_ASSET_MANIFEST_VERSION + 1)).toBe(false); + expect(isManifestVersionSupported(0)).toBe(false); + }); +}); diff --git a/apps/kimi-code/test/scripts/native/native-deps.test.ts b/apps/kimi-code/test/scripts/native/native-deps.test.ts new file mode 100644 index 000000000..c96b642e6 --- /dev/null +++ b/apps/kimi-code/test/scripts/native/native-deps.test.ts @@ -0,0 +1,90 @@ +import { describe, expect, it } from 'vitest'; + +import { + nativeDeps, + resolveTargetDeps, + isSupportedTarget, + SUPPORTED_TARGETS, +} from '../../../scripts/native/native-deps.mjs'; + +describe('SUPPORTED_TARGETS', () => { + it('contains the six published targets', () => { + expect([...SUPPORTED_TARGETS].toSorted()).toEqual( + [ + 'darwin-arm64', + 'darwin-x64', + 'linux-arm64', + 'linux-x64', + 'win32-arm64', + 'win32-x64', + ].toSorted(), + ); + }); +}); + +describe('isSupportedTarget', () => { + it('accepts every supported target', () => { + for (const t of SUPPORTED_TARGETS) { + expect(isSupportedTarget(t)).toBe(true); + } + }); + + it('rejects unknown targets', () => { + expect(isSupportedTarget('linux-x64-musl')).toBe(false); + expect(isSupportedTarget('darwin-arm')).toBe(false); + }); +}); + +describe('resolveTargetDeps', () => { + it('returns one descriptor per package for darwin-arm64', () => { + const deps = resolveTargetDeps('darwin-arm64'); + const names = deps.map((d) => d.resolvedName); + expect(names).toContain('@mariozechner/clipboard'); + expect(names).toContain('@mariozechner/clipboard-darwin-arm64'); + expect(names).toContain('koffi'); + }); + + it('picks the right clipboard subpackage per target', () => { + expect( + resolveTargetDeps('linux-x64').map((d) => d.resolvedName), + ).toContain('@mariozechner/clipboard-linux-x64-gnu'); + expect( + resolveTargetDeps('win32-x64').map((d) => d.resolvedName), + ).toContain('@mariozechner/clipboard-win32-x64-msvc'); + expect( + resolveTargetDeps('win32-arm64').map((d) => d.resolvedName), + ).toContain('@mariozechner/clipboard-win32-arm64-msvc'); + }); + + it('encodes koffi native file path with target triplet', () => { + const linuxKoffi = resolveTargetDeps('linux-arm64').find((d) => d.resolvedName === 'koffi'); + expect(linuxKoffi?.nativeFileRelatives).toEqual(['build/koffi/linux_arm64/koffi.node']); + const macKoffi = resolveTargetDeps('darwin-x64').find((d) => d.resolvedName === 'koffi'); + expect(macKoffi?.nativeFileRelatives).toEqual(['build/koffi/darwin_x64/koffi.node']); + const winArmKoffi = resolveTargetDeps('win32-arm64').find((d) => d.resolvedName === 'koffi'); + expect(winArmKoffi?.nativeFileRelatives).toEqual(['build/koffi/win32_arm64/koffi.node']); + }); + + it('throws on unsupported target', () => { + expect(() => resolveTargetDeps('linux-x64-musl')).toThrow(/unsupported/i); + }); +}); + +describe('nativeDeps registry shape', () => { + it('has clipboard host (collect=js-only)', () => { + const host = nativeDeps.find((d) => d.id === 'clipboard-host'); + expect(host?.collect).toBe('js-only'); + }); + + it('has clipboard-target (collect=native-files, parent=clipboard-host)', () => { + const target = nativeDeps.find((d) => d.id === 'clipboard-target'); + expect(target?.collect).toBe('native-files'); + expect(target?.parent).toBe('clipboard-host'); + }); + + it('has koffi (collect=js-and-native-file, parent=pi-tui)', () => { + const koffi = nativeDeps.find((d) => d.id === 'koffi'); + expect(koffi?.collect).toBe('js-and-native-file'); + expect(koffi?.parent).toBe('pi-tui'); + }); +}); diff --git a/apps/kimi-code/test/scripts/native/paths.test.ts b/apps/kimi-code/test/scripts/native/paths.test.ts new file mode 100644 index 000000000..80209f457 --- /dev/null +++ b/apps/kimi-code/test/scripts/native/paths.test.ts @@ -0,0 +1,101 @@ +import { describe, expect, it } from 'vitest'; + +import { + appRoot, + executableName, + nativeIntermediatesDir, + nativeBinDir, + nativeBinPath, + nativeBlobPath, + nativeJsBundlePath, + nativeManifestKey, + nativeSeaConfigPath, + targetTriple, + nativeDistRoot, + nativeManifestDir, + nativeArtifactsDir, + nativeSmokeHome, + SEA_SENTINEL_FUSE, +} from '../../../scripts/native/paths.mjs'; + +describe('targetTriple', () => { + it('returns platform-arch when env unset', () => { + expect(targetTriple({ platform: 'darwin', arch: 'arm64', env: {} })).toBe('darwin-arm64'); + expect(targetTriple({ platform: 'linux', arch: 'x64', env: {} })).toBe('linux-x64'); + expect(targetTriple({ platform: 'win32', arch: 'x64', env: {} })).toBe('win32-x64'); + }); + + it('honors KIMI_CODE_BUILD_TARGET override', () => { + expect( + targetTriple({ + platform: 'darwin', + arch: 'arm64', + env: { KIMI_CODE_BUILD_TARGET: 'linux-arm64' }, + }), + ).toBe('linux-arm64'); + }); +}); + +describe('executableName', () => { + it('returns kimi.exe on win32', () => { + expect(executableName('win32')).toBe('kimi.exe'); + }); + + it('returns kimi on other platforms', () => { + expect(executableName('darwin')).toBe('kimi'); + expect(executableName('linux')).toBe('kimi'); + }); +}); + +describe('path helpers', () => { + it('returns absolute intermediates dir under app root', () => { + expect(nativeIntermediatesDir()).toBe(`${appRoot}/dist-native/intermediates`); + }); + + it('returns absolute bin dir per target', () => { + expect(nativeBinDir('darwin-arm64')).toBe(`${appRoot}/dist-native/bin/darwin-arm64`); + }); + + it('returns absolute bin path with executable name', () => { + expect(nativeBinPath('darwin-arm64', 'darwin')).toBe( + `${appRoot}/dist-native/bin/darwin-arm64/kimi`, + ); + expect(nativeBinPath('win32-x64', 'win32')).toBe( + `${appRoot}/dist-native/bin/win32-x64/kimi.exe`, + ); + }); + + it('returns intermediate artifact paths', () => { + expect(nativeJsBundlePath()).toBe(`${appRoot}/dist-native/intermediates/main.cjs`); + expect(nativeBlobPath()).toBe(`${appRoot}/dist-native/intermediates/kimi.blob`); + expect(nativeSeaConfigPath()).toBe( + `${appRoot}/dist-native/intermediates/sea-config.json`, + ); + }); + + it('returns manifest key for target', () => { + expect(nativeManifestKey('darwin-arm64')).toBe('native/darwin-arm64/manifest.json'); + }); + + it('returns native dist root', () => { + expect(nativeDistRoot()).toBe(`${appRoot}/dist-native`); + }); + + it('returns manifest dir for target', () => { + expect(nativeManifestDir('darwin-arm64')).toBe( + `${appRoot}/dist-native/intermediates/native-assets/darwin-arm64`, + ); + }); + + it('returns artifacts dir', () => { + expect(nativeArtifactsDir()).toBe(`${appRoot}/dist-native/artifacts`); + }); + + it('returns smoke home', () => { + expect(nativeSmokeHome()).toBe(`${appRoot}/dist-native/smoke-home`); + }); + + it('has correct SEA sentinel fuse value', () => { + expect(SEA_SENTINEL_FUSE).toBe('NODE_SEA_FUSE_fce680ab2cc467b6e072b8b5df1996b2'); + }); +}); diff --git a/apps/kimi-code/test/scripts/native/release-artifacts.test.ts b/apps/kimi-code/test/scripts/native/release-artifacts.test.ts new file mode 100644 index 000000000..791fd14e0 --- /dev/null +++ b/apps/kimi-code/test/scripts/native/release-artifacts.test.ts @@ -0,0 +1,150 @@ +import { createHash } from 'node:crypto'; +import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { mkdtemp, readFile, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; +import { execFile } from 'node:child_process'; +import { promisify } from 'node:util'; +import { inflateRawSync } from 'node:zlib'; + +import { afterEach, describe, expect, it } from 'vitest'; + +import { appRoot } from '../../../scripts/native/paths.mjs'; + +const execFileAsync = promisify(execFile); +const packageScript = resolve(appRoot, 'scripts/native/package.mjs'); +const manifestScript = resolve(appRoot, 'scripts/native/produce-manifest.mjs'); +const artifactsDir = resolve(appRoot, 'dist-native/artifacts'); +const target = 'test-zip-artifact'; +const executableName = process.platform === 'win32' ? 'kimi.exe' : 'kimi'; +const fakeBinary = resolve(appRoot, 'dist-native/bin', target, executableName); + +function sha256(bytes: Buffer | string): string { + return createHash('sha256').update(bytes).digest('hex'); +} + +function zipEntryNames(zipPath: string): readonly string[] { + const zip = readFileSync(zipPath); + const eocdOffset = findEndOfCentralDirectory(zip); + const entryCount = zip.readUInt16LE(eocdOffset + 10); + let offset = zip.readUInt32LE(eocdOffset + 16); + const names: string[] = []; + + for (let i = 0; i < entryCount; i += 1) { + expect(zip.readUInt32LE(offset)).toBe(0x02014b50); + const nameLength = zip.readUInt16LE(offset + 28); + const extraLength = zip.readUInt16LE(offset + 30); + const commentLength = zip.readUInt16LE(offset + 32); + names.push(zip.subarray(offset + 46, offset + 46 + nameLength).toString('utf-8')); + offset += 46 + nameLength + extraLength + commentLength; + } + + return names; +} + +function readZipEntry(zipPath: string, expectedName: string): Buffer { + const zip = readFileSync(zipPath); + const eocdOffset = findEndOfCentralDirectory(zip); + const entryCount = zip.readUInt16LE(eocdOffset + 10); + let offset = zip.readUInt32LE(eocdOffset + 16); + + for (let i = 0; i < entryCount; i += 1) { + expect(zip.readUInt32LE(offset)).toBe(0x02014b50); + const method = zip.readUInt16LE(offset + 10); + const compressedSize = zip.readUInt32LE(offset + 20); + const nameLength = zip.readUInt16LE(offset + 28); + const extraLength = zip.readUInt16LE(offset + 30); + const commentLength = zip.readUInt16LE(offset + 32); + const localHeaderOffset = zip.readUInt32LE(offset + 42); + const name = zip.subarray(offset + 46, offset + 46 + nameLength).toString('utf-8'); + if (name === expectedName) { + return readLocalEntry(zip, localHeaderOffset, method, compressedSize); + } + offset += 46 + nameLength + extraLength + commentLength; + } + + throw new Error(`zip entry not found: ${expectedName}`); +} + +function readLocalEntry( + zip: Buffer, + localHeaderOffset: number, + method: number, + compressedSize: number, +): Buffer { + expect(zip.readUInt32LE(localHeaderOffset)).toBe(0x04034b50); + const nameLength = zip.readUInt16LE(localHeaderOffset + 26); + const extraLength = zip.readUInt16LE(localHeaderOffset + 28); + const dataStart = localHeaderOffset + 30 + nameLength + extraLength; + const compressed = zip.subarray(dataStart, dataStart + compressedSize); + if (method === 0) return compressed; + if (method === 8) return inflateRawSync(compressed); + throw new Error(`unsupported zip compression method: ${String(method)}`); +} + +function findEndOfCentralDirectory(zip: Buffer): number { + for (let offset = zip.length - 22; offset >= 0; offset -= 1) { + if (zip.readUInt32LE(offset) === 0x06054b50) return offset; + } + throw new Error('end of central directory not found'); +} + +describe('native release artifacts', () => { + afterEach(() => { + rmSync(resolve(appRoot, 'dist-native/bin', target), { recursive: true, force: true }); + rmSync(resolve(artifactsDir, `kimi-code-${target}.zip`), { force: true }); + rmSync(resolve(artifactsDir, `kimi-code-${target}.zip.sha256`), { force: true }); + }); + + it('packages the native binary as a zip archive and checksums the archive', async () => { + const binaryContent = 'native binary payload\n'; + mkdirSync(resolve(appRoot, 'dist-native/bin', target), { recursive: true }); + writeFileSync(fakeBinary, binaryContent, { mode: 0o755 }); + + await execFileAsync(process.execPath, [packageScript], { + cwd: appRoot, + env: { ...process.env, KIMI_CODE_BUILD_TARGET: target }, + }); + + const archivePath = resolve(artifactsDir, `kimi-code-${target}.zip`); + const checksumPath = `${archivePath}.sha256`; + expect(existsSync(archivePath)).toBe(true); + expect(existsSync(checksumPath)).toBe(true); + expect(zipEntryNames(archivePath)).toEqual([executableName]); + expect(readZipEntry(archivePath, executableName).toString('utf-8')).toBe(binaryContent); + expect(readFileSync(checksumPath, 'utf-8')).toBe( + `${sha256(readFileSync(archivePath))} kimi-code-${target}.zip\n`, + ); + }); + + it('produces a manifest from zip archive checksums', async () => { + const releaseDir = await mkdtemp(join(tmpdir(), 'kimi-manifest-zip-')); + const archiveBytes = Buffer.from('fake zip bytes'); + const checksum = sha256(archiveBytes); + await writeFile(join(releaseDir, 'kimi-code-darwin-arm64.zip'), archiveBytes); + await writeFile( + join(releaseDir, 'kimi-code-darwin-arm64.zip.sha256'), + `${checksum} kimi-code-darwin-arm64.zip\n`, + ); + + await execFileAsync(process.execPath, [manifestScript, releaseDir, '@moonshot-ai/kimi-code@0.5.0']); + + const manifest = JSON.parse( + await readFile(join(releaseDir, 'manifest.json'), 'utf-8'), + ) as { + version: string; + tag: string; + platforms: Record<string, { filename: string; checksum: string }>; + }; + expect(manifest).toEqual({ + version: '0.5.0', + tag: '@moonshot-ai/kimi-code@0.5.0', + platforms: { + 'darwin-arm64': { + filename: 'kimi-code-darwin-arm64.zip', + checksum, + }, + }, + }); + }); +}); diff --git a/apps/kimi-code/test/scripts/native/sign-args.test.ts b/apps/kimi-code/test/scripts/native/sign-args.test.ts new file mode 100644 index 000000000..803f1820a --- /dev/null +++ b/apps/kimi-code/test/scripts/native/sign-args.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from 'vitest'; + +import { buildCodesignArgs } from '../../../scripts/native/04-sign.mjs'; + +describe('buildCodesignArgs', () => { + it('returns ad-hoc args for identity "-"', () => { + const args = buildCodesignArgs({ + identity: '-', + executable: '/path/kimi', + entitlementsPath: '/path/entitlements.plist', + keychainPath: null, + }); + expect(args).toEqual(['--sign', '-', '/path/kimi']); + }); + + it('returns hardened-runtime args for Developer ID identity', () => { + const args = buildCodesignArgs({ + identity: 'Developer ID Application: Moonshot AI (ABCD1234)', + executable: '/path/kimi', + entitlementsPath: '/path/entitlements.plist', + keychainPath: '/tmp/sign.keychain-db', + }); + expect(args).toEqual([ + '--sign', + 'Developer ID Application: Moonshot AI (ABCD1234)', + '--options', + 'runtime', + '--entitlements', + '/path/entitlements.plist', + '--timestamp', + '--keychain', + '/tmp/sign.keychain-db', + '--force', + '/path/kimi', + ]); + }); + + it('omits --keychain when keychainPath is null but uses Developer ID otherwise', () => { + const args = buildCodesignArgs({ + identity: 'Developer ID Application: Moonshot AI (ABCD1234)', + executable: '/path/kimi', + entitlementsPath: '/path/entitlements.plist', + keychainPath: null, + }); + expect(args).toContain('--entitlements'); + expect(args).not.toContain('--keychain'); + }); +}); diff --git a/apps/kimi-code/test/scripts/nix/update-pnpm-deps.test.ts b/apps/kimi-code/test/scripts/nix/update-pnpm-deps.test.ts new file mode 100644 index 000000000..87aaf27ec --- /dev/null +++ b/apps/kimi-code/test/scripts/nix/update-pnpm-deps.test.ts @@ -0,0 +1,152 @@ +import { execFile } from 'node:child_process'; +import { chmod, mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; +import { promisify } from 'node:util'; + +import { afterEach, describe, expect, it } from 'vitest'; + +const execFileAsync = promisify(execFile); + +const repoRoot = resolve(import.meta.dirname, '../../../../..'); +const scriptPath = join(repoRoot, 'build/nix/update-pnpm-deps.sh'); + +const currentHash = 'sha256-LZ9Bkm3pG2ib7NcdqcP/kmoYWsNjXQ8PoEIlg/94oVo='; +const fakeHash = 'sha256-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA='; +const newHash = 'sha256-NNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNN='; + +const tempRoots: string[] = []; + +describe('update-pnpm-deps.sh', () => { + afterEach(async () => { + await Promise.all(tempRoots.splice(0).map((dir) => rm(dir, { recursive: true, force: true }))); + }); + + it('exits without patching fakeHash when the current pnpmDeps hash still builds', async () => { + const fixture = await createFixtureRepo(currentHash); + const result = await runUpdateScript(fixture, { currentValid: true }); + + await expect(readFile(join(fixture.root, 'flake.nix'), 'utf-8')).resolves.toContain(currentHash); + await expect(readFile(join(fixture.root, 'flake.nix'), 'utf-8')).resolves.not.toContain(fakeHash); + await expect(readFile(fixture.nixLogPath, 'utf-8')).resolves.toBe('current\n'); + expect(result.stdout).toContain('pnpmDeps hash still valid'); + + const cache = JSON.parse( + await readFile(join(fixture.root, '.git/kimi-code/pnpm-deps-hashes-v1.json'), 'utf-8'), + ) as Record<string, { hash: string }>; + expect(Object.values(cache)).toContainEqual(expect.objectContaining({ hash: currentHash })); + }); + + it('writes a local cache after discovery and verifies the cached hash on the next run', async () => { + const fixture = await createFixtureRepo(currentHash); + + await runUpdateScript(fixture, { currentValid: false }); + await expect(readFile(join(fixture.root, 'flake.nix'), 'utf-8')).resolves.toContain(newHash); + + const cache = JSON.parse( + await readFile(join(fixture.root, '.git/kimi-code/pnpm-deps-hashes-v1.json'), 'utf-8'), + ) as Record<string, { hash: string }>; + expect(Object.values(cache)).toContainEqual(expect.objectContaining({ hash: newHash })); + + await writeFile(join(fixture.root, 'flake.nix'), flakeWithHash(currentHash)); + await writeFile(fixture.nixLogPath, ''); + + const result = await runUpdateScript(fixture, { currentValid: false }); + + await expect(readFile(join(fixture.root, 'flake.nix'), 'utf-8')).resolves.toContain(newHash); + await expect(readFile(fixture.nixLogPath, 'utf-8')).resolves.toBe('current\nnew\n'); + expect(result.stdout).toContain('cache hit'); + }); +}); + +async function createFixtureRepo(hash: string): Promise<{ binDir: string; nixLogPath: string; root: string }> { + const root = await mkdtemp(join(tmpdir(), 'kimi-update-pnpm-deps-')); + tempRoots.push(root); + + await writeFile(join(root, 'flake.nix'), flakeWithHash(hash)); + await writeFile(join(root, 'flake.lock'), '{}\n'); + await writeFile(join(root, '.npmrc'), 'engine-strict=true\n'); + await writeFile(join(root, 'package.json'), '{"name":"root"}\n'); + await writeFile(join(root, 'pnpm-lock.yaml'), 'lockfileVersion: 9.0\n'); + await writeFile(join(root, 'pnpm-workspace.yaml'), 'packages:\n - "apps/*"\n'); + await mkdir(join(root, 'apps/example'), { recursive: true }); + await writeFile(join(root, 'apps/example/package.json'), '{"name":"example"}\n'); + + await execFileAsync('git', ['init'], { cwd: root }); + + const binDir = join(root, 'bin'); + const nixLogPath = join(root, 'nix.log'); + await mkdir(binDir); + await writeFile(nixLogPath, ''); + await writeFile( + join(binDir, 'nix'), + `#!/usr/bin/env bash +set -euo pipefail + +state=unknown +if grep -Fq "$CURRENT_HASH" flake.nix; then + state=current +elif grep -Fq "$FAKE_HASH" flake.nix; then + state=fake +elif grep -Fq "$NEW_HASH" flake.nix; then + state=new +fi + +printf '%s\\n' "$state" >> "$NIX_LOG" + +case "$state" in + current) + if [ "\${CURRENT_VALID:-0}" = "1" ]; then + exit 0 + fi + echo "current hash invalid" >&2 + exit 1 + ;; + fake) + echo "error: hash mismatch" >&2 + echo " got: $NEW_HASH" >&2 + exit 1 + ;; + new) + exit 0 + ;; + *) + echo "unexpected hash state: $state" >&2 + exit 1 + ;; +esac +`, + ); + await chmod(join(binDir, 'nix'), 0o755); + + return { binDir, nixLogPath, root }; +} + +async function runUpdateScript( + fixture: { binDir: string; nixLogPath: string; root: string }, + options: { currentValid: boolean }, +): Promise<{ stdout: string; stderr: string }> { + return execFileAsync('bash', [scriptPath], { + cwd: fixture.root, + env: { + ...process.env, + CURRENT_HASH: currentHash, + CURRENT_VALID: options.currentValid ? '1' : '0', + FAKE_HASH: fakeHash, + NEW_HASH: newHash, + NIX_LOG: fixture.nixLogPath, + PATH: `${fixture.binDir}:${process.env['PATH'] ?? ''}`, + }, + }); +} + +function flakeWithHash(hash: string): string { + return `{ + outputs = { self, nixpkgs }: { + packages.x86_64-linux.kimi-code-pnpm-deps = { + hash = "${hash}"; + }; + }; +} +`; +} diff --git a/apps/kimi-code/test/tui/actions/replay-hydrate-agent-group.test.ts b/apps/kimi-code/test/tui/actions/replay-hydrate-agent-group.test.ts new file mode 100644 index 000000000..31197ace8 --- /dev/null +++ b/apps/kimi-code/test/tui/actions/replay-hydrate-agent-group.test.ts @@ -0,0 +1,352 @@ + +import { Text } from '@earendil-works/pi-tui'; +import type { Session } from '@moonshot-ai/kimi-code-sdk'; +import { describe, expect, it, vi } from 'vitest'; + +import { hydrateProjectedEntries, hydrateTranscriptFromReplay } from '#/tui/actions/replay-ops'; +import { AgentGroupComponent } from '#/tui/components/messages/agent-group'; +import { ToolCallComponent } from '#/tui/components/messages/tool-call'; +import { createTUIState, type KimiTUIOptions, type TUIState } from '#/tui/kimi-tui'; +import type { AppState, ToolCallBlockData, TranscriptEntry } from '#/tui/types'; + +function makeAppState(): AppState { + return { + model: 'k2', + workDir: '/tmp/proj-a', + sessionId: 'sess-1', + yolo: false, + permissionMode: 'manual', + planMode: false, + thinking: false, + contextUsage: 0, + contextTokens: 0, + maxContextTokens: 100, + isStreaming: false, + isCompacting: false, + isReplaying: false, + streamingPhase: 'idle', + streamingStartTime: 0, + theme: 'dark', + version: '0.0.0-test', + editorCommand: null, + notifications: { enabled: true, condition: 'unfocused' }, + availableModels: {}, + availableProviders: {}, + sessionTitle: null, + }; +} + +function makeTuiState(): TUIState { + const options: KimiTUIOptions = { + initialAppState: makeAppState(), + startup: { + continueLast: false, + yolo: false, + plan: false, + }, + resolvedTheme: 'dark', + }; + const state = createTUIState(options); + vi.spyOn(state.ui, 'requestRender').mockImplementation(() => {}); + return state; +} + +function appendEntry(state: TUIState, entry: TranscriptEntry): void { + state.transcriptEntries.push(entry); + if (entry.toolCallData !== undefined) { + const tc = new ToolCallComponent( + entry.toolCallData, + entry.toolCallData.result, + state.theme.colors, + state.ui, + state.theme.markdownTheme, + ); + state.pendingToolComponents.set(entry.toolCallData.id, tc); + state.transcriptContainer.addChild(tc); + return; + } + state.transcriptContainer.addChild(new Text(entry.content, 0, 0)); +} + +function hydrate(state: TUIState, entries: readonly TranscriptEntry[]): void { + hydrateProjectedEntries(state, entries, (entry) => { + appendEntry(state, entry); + }); +} + +function setTodoList( + state: TUIState, + todos: Parameters<TUIState['todoPanel']['setTodos']>[0], +): void { + state.todoPanel.setTodos(todos); + state.todoPanelContainer.clear(); + if (!state.todoPanel.isEmpty()) { + state.todoPanelContainer.addChild(state.todoPanel); + } +} + +function sessionWithToolStore(toolStore: Record<string, unknown>): Session { + return { + getResumeState: () => ({ + sessionMetadata: {}, + agents: { + main: { + type: 'main', + config: { + modelAlias: 'k2', + provider: undefined, + modelCapabilities: { max_context_tokens: 100 }, + }, + context: { history: [], tokenCount: 0 }, + replay: [], + permission: { mode: 'manual' }, + plan: null, + usage: {}, + tools: [], + toolStore, + background: [], + }, + }, + }), + } as unknown as Session; +} + +let entryIdSeq = 0; +function makeAgentEntry( + id: string, + step: number, + turnId: string, + result?: { is_error?: boolean }, +): TranscriptEntry { + entryIdSeq += 1; + const tc: ToolCallBlockData = { + id, + name: 'Agent', + args: { description: id }, + step, + turnId, + ...(result !== undefined + ? { result: { tool_call_id: id, output: 'done', is_error: result.is_error ?? false } } + : {}), + }; + return { + id: `e${String(entryIdSeq)}`, + kind: 'tool_call', + turnId, + renderMode: 'plain', + content: '', + toolCallData: tc, + }; +} + +function makeBashEntry(id: string, step: number, turnId: string): TranscriptEntry { + entryIdSeq += 1; + const tc: ToolCallBlockData = { + id, + name: 'Bash', + args: { command: 'pwd' }, + step, + turnId, + }; + return { + id: `e${String(entryIdSeq)}`, + kind: 'tool_call', + turnId, + renderMode: 'plain', + content: '', + toolCallData: tc, + }; +} + +function makeUserEntry(turnId: string, content: string): TranscriptEntry { + entryIdSeq += 1; + return { + id: `e${String(entryIdSeq)}`, + kind: 'user', + turnId, + renderMode: 'plain', + content, + }; +} + +describe('hydrateProjectedEntries', () => { + it('hydrates the visible todo panel from resumed tool store state', async () => { + const state = makeTuiState(); + const errors: string[] = []; + + const ok = await hydrateTranscriptFromReplay( + state, + { + setAppState: (patch) => { + state.appState = { ...state.appState, ...patch }; + }, + appendEntry: (entry) => { + appendEntry(state, entry); + }, + setTodoList: (todos) => { + setTodoList(state, todos); + }, + emitError: (message) => { + errors.push(message); + }, + }, + sessionWithToolStore({ + todo: [ + { title: 'Review resume snapshot', status: 'done' }, + { title: 'Render todo panel', status: 'in_progress' }, + { title: '', status: 'pending' }, + ], + }), + ); + + expect(ok).toBe(true); + expect(errors).toEqual([]); + expect(state.todoPanel.getTodos()).toEqual([ + { title: 'Review resume snapshot', status: 'done' }, + { title: 'Render todo panel', status: 'in_progress' }, + ]); + expect(state.todoPanelContainer.children).toContain(state.todoPanel); + }); + + it('clears the todo panel when resumed state has no todo store entry', async () => { + const state = makeTuiState(); + setTodoList(state, [{ title: 'stale todo', status: 'pending' }]); + + const ok = await hydrateTranscriptFromReplay( + state, + { + setAppState: (patch) => { + state.appState = { ...state.appState, ...patch }; + }, + appendEntry: (entry) => { + appendEntry(state, entry); + }, + setTodoList: (todos) => { + setTodoList(state, todos); + }, + emitError: () => {}, + }, + sessionWithToolStore({}), + ); + + expect(ok).toBe(true); + expect(state.todoPanel.getTodos()).toEqual([]); + expect(state.todoPanelContainer.children).not.toContain(state.todoPanel); + }); + + it('groups 2 adjacent same-step Agents into a single AgentGroupComponent', () => { + const state = makeTuiState(); + const entries: TranscriptEntry[] = [ + makeAgentEntry('a1', 1, 't1', { is_error: false }), + makeAgentEntry('a2', 1, 't1', { is_error: false }), + ]; + + hydrate(state, entries); + + const children = state.transcriptContainer.children; + expect(children.length).toBe(1); + expect(children[0]).toBeInstanceOf(AgentGroupComponent); + expect((children[0] as AgentGroupComponent).size()).toBe(2); + // ToolCallComponent still registers in pendingToolComponents because + // later wire event routing depends on this mapping. + expect(state.pendingToolComponents.has('a1')).toBe(true); + expect(state.pendingToolComponents.has('a2')).toBe(true); + }); + + it('keeps cross-step Agents independent', () => { + const state = makeTuiState(); + const entries: TranscriptEntry[] = [ + makeAgentEntry('a1', 1, 't1'), + makeAgentEntry('a2', 2, 't1'), + ]; + + hydrate(state, entries); + const children = state.transcriptContainer.children; + expect(children.length).toBe(2); + expect(children[0]).toBeInstanceOf(ToolCallComponent); + expect(children[1]).toBeInstanceOf(ToolCallComponent); + }); + + it('does not group when a non-Agent tool sits between Agents', () => { + const state = makeTuiState(); + const entries: TranscriptEntry[] = [ + makeAgentEntry('a1', 1, 't1'), + makeBashEntry('b1', 1, 't1'), + makeAgentEntry('a2', 1, 't1'), + ]; + + hydrate(state, entries); + const children = state.transcriptContainer.children; + expect(children.length).toBe(3); + for (const child of children) expect(child).toBeInstanceOf(ToolCallComponent); + }); + + it('a single Agent is left as a standalone ToolCallComponent', () => { + const state = makeTuiState(); + hydrate(state, [makeAgentEntry('a1', 1, 't1')]); + const children = state.transcriptContainer.children; + expect(children.length).toBe(1); + expect(children[0]).toBeInstanceOf(ToolCallComponent); + }); + + it('user message between Agents prevents grouping', () => { + const state = makeTuiState(); + const entries: TranscriptEntry[] = [ + makeAgentEntry('a1', 1, 't1'), + makeUserEntry('t1', 'hello'), + makeAgentEntry('a2', 1, 't1'), + ]; + + hydrate(state, entries); + const children = state.transcriptContainer.children; + expect(children.length).toBe(3); + expect(children[0]).toBeInstanceOf(ToolCallComponent); + expect(children[2]).toBeInstanceOf(ToolCallComponent); + }); + + it('replay does not write pendingAgentGroup (it stays null after hydration)', () => { + const state = makeTuiState(); + hydrate(state, [makeAgentEntry('a1', 1, 't1'), makeAgentEntry('a2', 1, 't1')]); + expect(state.pendingAgentGroup).toBeNull(); + }); + + it('background Agent replay hydrates only background status rows, not AgentGroupComponent', () => { + const state = makeTuiState(); + hydrate(state, [ + { + id: 'e-background-started', + kind: 'status', + renderMode: 'plain', + content: 'explore agent started in background', + }, + { + id: 'e-background-completed', + kind: 'status', + renderMode: 'plain', + content: 'explore agent completed in background', + }, + ]); + + expect( + state.transcriptContainer.children.some((child) => child instanceof AgentGroupComponent), + ).toBe(false); + expect( + state.transcriptContainer.children.some((child) => child instanceof ToolCallComponent), + ).toBe(false); + }); + + it('group with mixed completed/failed children renders correct phase tails', () => { + const state = makeTuiState(); + hydrate(state, [ + makeAgentEntry('a1', 1, 't1', { is_error: false }), + makeAgentEntry('a2', 1, 't1', { is_error: true }), + ]); + const group = state.transcriptContainer.children[0] as AgentGroupComponent; + const out = group + .render(120) + .join('\n') + .replaceAll(/\[[0-9;]*m/g, ''); + expect(out).toContain('✓ Completed'); + expect(out).toContain('✗ Failed'); + }); +}); diff --git a/apps/kimi-code/test/tui/activity-pane.test.ts b/apps/kimi-code/test/tui/activity-pane.test.ts new file mode 100644 index 000000000..d0eef5d08 --- /dev/null +++ b/apps/kimi-code/test/tui/activity-pane.test.ts @@ -0,0 +1,112 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { KimiTUI, type KimiTUIStartupInput, type TUIState } from '#/tui/kimi-tui'; + +interface ActivityDriver { + state: TUIState; + updateActivityPane(): void; +} + +function makeStartupInput(): KimiTUIStartupInput { + return { + cliOptions: { + session: undefined, + continue: false, + yolo: false, + plan: false, + model: undefined, + outputFormat: undefined, + prompt: undefined, + skillsDirs: [], + }, + tuiConfig: { + theme: 'dark', + editorCommand: null, + notifications: { enabled: true, condition: 'unfocused' }, + }, + version: '0.0.0-test', + workDir: '/tmp/proj-a', + resolvedTheme: 'dark', + }; +} + +function makeDriverWithTerminalProgress(): { + driver: ActivityDriver; + state: TUIState; + setProgress: ReturnType<typeof vi.fn<(active: boolean) => void>>; +} { + const setProgress = vi.fn<(active: boolean) => void>(); + const driver = new KimiTUI({} as never, makeStartupInput()) as unknown as ActivityDriver; + vi.spyOn(driver.state.ui, 'requestRender').mockImplementation(() => {}); + driver.state.terminal = { columns: 80, setProgress } as unknown as TUIState['terminal']; + return { driver, state: driver.state, setProgress }; +} + +describe('updateActivityPane terminal progress', () => { + it('toggles terminal progress when the activity pane enters and leaves work mode', () => { + vi.useFakeTimers(); + try { + const { driver, state, setProgress } = makeDriverWithTerminalProgress(); + + state.livePane = { ...state.livePane, mode: 'waiting' }; + driver.updateActivityPane(); + + expect(setProgress).toHaveBeenCalledTimes(1); + expect(setProgress).toHaveBeenLastCalledWith(true); + expect(state.terminalState.progressActive).toBe(true); + + state.livePane = { ...state.livePane, mode: 'idle' }; + driver.updateActivityPane(); + + expect(setProgress).toHaveBeenCalledTimes(2); + expect(setProgress).toHaveBeenLastCalledWith(false); + expect(state.terminalState.progressActive).toBe(false); + } finally { + vi.useRealTimers(); + } + }); + + it('keeps compaction visible as terminal progress even though the pane is hidden', () => { + const { driver, state, setProgress } = makeDriverWithTerminalProgress(); + state.appState.isCompacting = true; + state.appState.streamingPhase = 'waiting'; + + driver.updateActivityPane(); + driver.updateActivityPane(); + + expect(setProgress).toHaveBeenCalledTimes(1); + expect(setProgress).toHaveBeenLastCalledWith(true); + + state.appState.isCompacting = false; + state.appState.streamingPhase = 'idle'; + driver.updateActivityPane(); + + expect(setProgress).toHaveBeenCalledTimes(2); + expect(setProgress).toHaveBeenLastCalledWith(false); + }); + + it('keeps terminal progress active without showing a thinking spinner', () => { + vi.useFakeTimers(); + try { + const { driver, state, setProgress } = makeDriverWithTerminalProgress(); + state.livePane = { ...state.livePane, mode: 'idle' }; + state.appState.streamingPhase = 'thinking'; + + driver.updateActivityPane(); + + expect(setProgress).toHaveBeenCalledTimes(1); + expect(setProgress).toHaveBeenLastCalledWith(true); + expect(state.activitySpinner).toBeUndefined(); + expect(state.activityContainer.children).toHaveLength(0); + + state.appState.streamingPhase = 'idle'; + driver.updateActivityPane(); + + expect(setProgress).toHaveBeenCalledTimes(2); + expect(setProgress).toHaveBeenLastCalledWith(false); + expect(state.activitySpinner).toBeUndefined(); + } finally { + vi.useRealTimers(); + } + }); +}); diff --git a/apps/kimi-code/test/tui/background-task-status.test.ts b/apps/kimi-code/test/tui/background-task-status.test.ts new file mode 100644 index 000000000..e8033d45f --- /dev/null +++ b/apps/kimi-code/test/tui/background-task-status.test.ts @@ -0,0 +1,104 @@ +import type { BackgroundTaskInfo, BackgroundTaskStatus } from '@moonshot-ai/kimi-code-sdk'; +import { describe, expect, it } from 'vitest'; + +import { formatBackgroundTaskTranscript } from '@/tui/utils/background-task-status'; + +function task(overrides: Partial<BackgroundTaskInfo> = {}): BackgroundTaskInfo { + return { + taskId: 'bash-abcd1234', + command: 'npm run dev', + description: 'dev server', + status: 'running', + pid: 1234, + exitCode: null, + startedAt: Date.now() - 1000, + endedAt: null, + ...overrides, + }; +} + +describe('formatBackgroundTaskTranscript', () => { + it('renders a bash started entry', () => { + const data = formatBackgroundTaskTranscript(task({ status: 'running' })); + expect(data.phase).toBe('started'); + expect(data.headline).toContain('bash task started'); + expect(data.detail).toBe('dev server'); + }); + + it('renders an agent started entry', () => { + const data = formatBackgroundTaskTranscript( + task({ taskId: 'agent-deadbeef', status: 'running' }), + ); + expect(data.headline).toContain('agent task started'); + }); + + it('renders a completed entry with exit code in detail', () => { + const data = formatBackgroundTaskTranscript( + task({ status: 'completed', exitCode: 0, endedAt: Date.now() }), + ); + expect(data.phase).toBe('completed'); + expect(data.headline).toContain('completed'); + expect(data.detail).toContain('exit 0'); + }); + + it('renders a failed entry with non-zero exit', () => { + const data = formatBackgroundTaskTranscript( + task({ status: 'failed', exitCode: 2, endedAt: Date.now() }), + ); + expect(data.phase).toBe('failed'); + expect(data.headline).toContain('failed'); + expect(data.detail).toContain('exit 2'); + }); + + it('renders a killed entry with stop reason', () => { + const data = formatBackgroundTaskTranscript( + task({ status: 'killed', stopReason: 'user', endedAt: Date.now() }), + ); + expect(data.phase).toBe('failed'); + expect(data.headline).toContain('stopped'); + expect(data.detail).toContain('user'); + }); + + it('renders a lost entry with restart note', () => { + const data = formatBackgroundTaskTranscript(task({ status: 'lost', endedAt: Date.now() })); + expect(data.phase).toBe('failed'); + expect(data.headline).toContain('lost'); + expect(data.detail).toContain('session restarted'); + }); + + it('surfaces awaiting_approval reason', () => { + const data = formatBackgroundTaskTranscript( + task({ status: 'awaiting_approval', approvalReason: 'needs network' }), + ); + expect(data.phase).toBe('started'); + expect(data.headline).toContain('awaiting'); + expect(data.detail).toContain('needs network'); + }); + + it('surfaces timedOut for agent deadlines', () => { + const data = formatBackgroundTaskTranscript( + task({ + taskId: 'agent-aaaaaaaa', + status: 'failed', + exitCode: 1, + timedOut: true, + endedAt: Date.now(), + }), + ); + expect(data.detail).toContain('timed out'); + }); + + it('handles every BackgroundTaskStatus without throwing', () => { + const statuses: BackgroundTaskStatus[] = [ + 'running', + 'awaiting_approval', + 'completed', + 'failed', + 'killed', + 'lost', + ]; + for (const status of statuses) { + expect(() => formatBackgroundTaskTranscript(task({ status }))).not.toThrow(); + } + }); +}); diff --git a/apps/kimi-code/test/tui/commands/registry.test.ts b/apps/kimi-code/test/tui/commands/registry.test.ts new file mode 100644 index 000000000..3685857b8 --- /dev/null +++ b/apps/kimi-code/test/tui/commands/registry.test.ts @@ -0,0 +1,105 @@ +import { + BUILTIN_SLASH_COMMANDS, + findBuiltInSlashCommand, + parseSlashInput, + resolveSlashCommandAvailability, + sortSlashCommands, + type KimiSlashCommand, +} from '#/tui/commands/index'; +import { describe, expect, it } from 'vitest'; + +describe('parseSlashInput', () => { + it('parses command names and trimmed args', () => { + expect(parseSlashInput('/help')).toEqual({ name: 'help', args: '' }); + expect(parseSlashInput('/model kimi-k2 ')).toEqual({ + name: 'model', + args: 'kimi-k2', + }); + }); + + it('returns null for non-commands and path-like input', () => { + expect(parseSlashInput('hello')).toBeNull(); + expect(parseSlashInput('/')).toBeNull(); + expect(parseSlashInput('/ ')).toBeNull(); + expect(parseSlashInput('/some/path')).toBeNull(); + expect(parseSlashInput('/some/path with args')).toBeNull(); + }); +}); + +describe('built-in slash command registry', () => { + it('finds built-ins by name or alias', () => { + expect(findBuiltInSlashCommand('exit')?.name).toBe('exit'); + expect(findBuiltInSlashCommand('quit')?.name).toBe('exit'); + expect(findBuiltInSlashCommand('q')?.name).toBe('exit'); + expect(findBuiltInSlashCommand('clear')?.name).toBe('new'); + expect(findBuiltInSlashCommand('mcp')?.name).toBe('mcp'); + expect(findBuiltInSlashCommand('status')?.name).toBe('status'); + expect(findBuiltInSlashCommand('usage')?.aliases).not.toContain('status'); + expect(findBuiltInSlashCommand('unknown')).toBeUndefined(); + }); + + it('marks plan clear as idle-only while normal plan toggles are always available', () => { + const plan = findBuiltInSlashCommand('plan'); + expect(plan).toBeDefined(); + expect(resolveSlashCommandAvailability(plan!, '')).toBe('always'); + expect(resolveSlashCommandAvailability(plan!, 'on')).toBe('always'); + expect(resolveSlashCommandAvailability(plan!, 'clear')).toBe('idle-only'); + }); + + it('defaults commands without explicit availability to idle-only', () => { + const command: KimiSlashCommand = { + name: 'example', + aliases: [], + description: 'Example command', + }; + + expect(resolveSlashCommandAvailability(command, '')).toBe('idle-only'); + }); + + it('sorts commands by priority descending and name ascending', () => { + const commands: KimiSlashCommand[] = [ + { name: 'zebra', aliases: [], description: 'Z', priority: 100 }, + { name: 'alpha', aliases: [], description: 'A', priority: 100 }, + { name: 'middle', aliases: [], description: 'M', priority: 50 }, + { name: 'plain', aliases: [], description: 'P' }, + ]; + + expect(sortSlashCommands(commands).map((command) => command.name)).toEqual([ + 'alpha', + 'zebra', + 'middle', + 'plain', + ]); + }); + + it('contains the expected command names once', () => { + const names = BUILTIN_SLASH_COMMANDS.map((command) => command.name); + + expect(new Set(names).size).toBe(names.length); + expect(names).toEqual( + expect.arrayContaining([ + 'compact', + 'editor', + 'exit', + 'fork', + 'help', + 'init', + 'login', + 'logout', + 'mcp', + 'model', + 'new', + 'permission', + 'plan', + 'sessions', + 'settings', + 'status', + 'theme', + 'title', + 'usage', + 'version', + 'yolo', + ]), + ); + }); +}); diff --git a/apps/kimi-code/test/tui/commands/resolve.test.ts b/apps/kimi-code/test/tui/commands/resolve.test.ts new file mode 100644 index 000000000..ae043d5f4 --- /dev/null +++ b/apps/kimi-code/test/tui/commands/resolve.test.ts @@ -0,0 +1,117 @@ +import { + resolveSkillCommand, + resolveSlashCommandInput, + slashBusyMessage, + slashCommandBusyReason, +} from '#/tui/commands/index'; +import { describe, expect, it } from 'vitest'; + +function resolve( + input: string, + overrides: Partial<Parameters<typeof resolveSlashCommandInput>[0]> = {}, +) { + return resolveSlashCommandInput({ + input, + skillCommandMap: new Map<string, string>(), + isStreaming: false, + isCompacting: false, + ...overrides, + }); +} + +describe('resolveSlashCommandInput', () => { + it('returns not-command for normal text', () => { + expect(resolve('hello')).toEqual({ kind: 'not-command' }); + }); + + it('resolves built-in commands by name and alias', () => { + expect(resolve('/help')).toMatchObject({ kind: 'builtin', name: 'help', args: '' }); + expect(resolve('/q')).toMatchObject({ kind: 'builtin', name: 'exit', args: '' }); + expect(resolve('/clear')).toMatchObject({ kind: 'builtin', name: 'new', args: '' }); + expect(resolve('/fork')).toMatchObject({ kind: 'builtin', name: 'fork', args: '' }); + expect(resolve('/title New title')).toMatchObject({ + kind: 'builtin', + name: 'title', + args: 'New title', + }); + expect(resolve('/init')).toMatchObject({ kind: 'builtin', name: 'init', args: '' }); + }); + + it('blocks idle-only built-ins while streaming', () => { + expect(resolve('/new', { isStreaming: true })).toEqual({ + kind: 'blocked', + commandName: 'new', + reason: 'streaming', + }); + expect(resolve('/init', { isStreaming: true })).toEqual({ + kind: 'blocked', + commandName: 'init', + reason: 'streaming', + }); + }); + + it('allows always-available built-ins while streaming', () => { + expect(resolve('/plan on', { isStreaming: true })).toMatchObject({ + kind: 'builtin', + name: 'plan', + args: 'on', + }); + expect(resolve('/mcp', { isStreaming: true })).toMatchObject({ + kind: 'builtin', + name: 'mcp', + args: '', + }); + expect(resolve('/mcp', { isCompacting: true })).toMatchObject({ + kind: 'builtin', + name: 'mcp', + args: '', + }); + }); + + it('blocks plan clear while compacting because it is idle-only', () => { + expect(resolve('/plan clear', { isCompacting: true })).toEqual({ + kind: 'blocked', + commandName: 'plan', + reason: 'compacting', + }); + }); + + it('resolves skill commands and blocks them while busy', () => { + const skillCommandMap = new Map([['skill:review', 'review']]); + + expect(resolve('/skill:review src/app.ts', { skillCommandMap })).toEqual({ + kind: 'skill', + commandName: 'skill:review', + skillName: 'review', + args: 'src/app.ts', + }); + expect(resolve('/skill:review src/app.ts', { skillCommandMap, isStreaming: true })).toEqual({ + kind: 'blocked', + commandName: 'skill:review', + reason: 'streaming', + }); + }); + + it('returns message for unknown slash input', () => { + expect(resolve('/does-not-exist arg')).toEqual({ + kind: 'message', + input: '/does-not-exist arg', + }); + }); +}); + +describe('slash command busy helpers', () => { + it('resolves skill command aliases with and without skill prefix', () => { + const map = new Map([['skill:review', 'review']]); + + expect(resolveSkillCommand(map, 'skill:review')).toBe('review'); + expect(resolveSkillCommand(map, 'review')).toBe('review'); + }); + + it('formats busy messages', () => { + expect(slashCommandBusyReason({ isStreaming: true, isCompacting: false })).toBe('streaming'); + expect(slashCommandBusyReason({ isStreaming: false, isCompacting: true })).toBe('compacting'); + expect(slashBusyMessage('new', 'streaming')).toContain('Cannot /new while streaming'); + expect(slashBusyMessage('new', 'compacting')).toContain('Cannot /new while compacting'); + }); +}); diff --git a/apps/kimi-code/test/tui/commands/skills.test.ts b/apps/kimi-code/test/tui/commands/skills.test.ts new file mode 100644 index 000000000..81a176813 --- /dev/null +++ b/apps/kimi-code/test/tui/commands/skills.test.ts @@ -0,0 +1,57 @@ +import { buildSkillSlashCommands, isUserActivatableSkill } from '#/tui/commands/index'; +import type { SkillSummary } from '@moonshot-ai/kimi-code-sdk'; +import { describe, expect, it } from 'vitest'; + +function skill( + name: string, + type?: SkillSummary['type'], + extra: Partial<SkillSummary> = {}, +): SkillSummary { + return { + name, + type, + description: `${name} skill`, + ...extra, + } as SkillSummary; +} + +describe('skill slash commands', () => { + it('allows user-activatable skill types', () => { + expect(isUserActivatableSkill(skill('default'))).toBe(true); + expect(isUserActivatableSkill(skill('prompt', 'prompt'))).toBe(true); + expect(isUserActivatableSkill(skill('inline', 'inline'))).toBe(true); + expect(isUserActivatableSkill(skill('flow', 'flow'))).toBe(true); + }); + + it('filters non-user-activatable skill types', () => { + expect(isUserActivatableSkill(skill('agent', 'agent'))).toBe(false); + }); + + it('builds slash commands and command map entries with skill prefixes', () => { + const built = buildSkillSlashCommands([ + skill('review', 'prompt'), + skill('agent-only', 'agent'), + skill('commit', 'flow'), + ]); + + expect(built.commands.map((command) => command.name)).toEqual(['skill:review', 'skill:commit']); + expect(built.commands[0]).toMatchObject({ + name: 'skill:review', + aliases: [], + description: 'review skill', + }); + expect([...built.commandMap.entries()]).toEqual([ + ['skill:review', 'review'], + ['skill:commit', 'commit'], + ]); + }); + + it('keeps disableModelInvocation skills slash-invocable', () => { + const built = buildSkillSlashCommands([ + skill('mcp-config', 'inline', { disableModelInvocation: true, source: 'builtin' }), + ]); + + expect(built.commands.map((command) => command.name)).toEqual(['skill:mcp-config']); + expect(built.commandMap.get('skill:mcp-config')).toBe('mcp-config'); + }); +}); diff --git a/apps/kimi-code/test/tui/components/chrome/device-code-box.test.ts b/apps/kimi-code/test/tui/components/chrome/device-code-box.test.ts new file mode 100644 index 000000000..f7cea9b92 --- /dev/null +++ b/apps/kimi-code/test/tui/components/chrome/device-code-box.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from 'vitest'; + +import { DeviceCodeBoxComponent } from '#/tui/components/chrome/device-code-box'; +import { darkColors } from '#/tui/theme/colors'; + +function strip(text: string): string { + return text.replaceAll(/\[[0-9;]*m/g, ''); +} + +const url = 'https://www.kimi.com/code/authorize_device?user_code=N32D-W3YD'; +const code = 'N32D-W3YD'; +const title = 'Sign in to Kimi Code'; +const hint = 'Press Ctrl-C to cancel'; + +describe('DeviceCodeBoxComponent', () => { + it('renders a rounded border that frames the title, url and code', () => { + const component = new DeviceCodeBoxComponent({ + title, + url, + code, + hint, + colors: darkColors, + }); + + const lines = component.render(80).map(strip); + const joined = lines.join('\n'); + + expect(lines[1]?.startsWith('╭')).toBe(true); + expect(lines[1]?.endsWith('╮')).toBe(true); + expect(lines.at(-2)?.startsWith('╰')).toBe(true); + expect(lines.at(-2)?.endsWith('╯')).toBe(true); + + expect(joined).toContain(title); + expect(joined).toContain(url); + expect(joined).toContain(code); + expect(joined).toContain(hint); + expect(joined).toContain('Verification code'); + }); + + it('truncates long urls when the terminal is narrow', () => { + const component = new DeviceCodeBoxComponent({ + title, + url, + code, + colors: darkColors, + }); + + const lines = component.render(40).map(strip); + const urlLine = lines.find((line) => line.includes('https://')); + expect(urlLine).toBeDefined(); + expect(urlLine).toContain('…'); + expect(urlLine?.length).toBeLessThanOrEqual(40); + }); + + it('omits the hint row when no hint is provided', () => { + const component = new DeviceCodeBoxComponent({ + title, + url, + code, + colors: darkColors, + }); + + const joined = component.render(80).map(strip).join('\n'); + expect(joined).not.toContain('Press Ctrl-C'); + }); +}); diff --git a/apps/kimi-code/test/tui/components/chrome/gutter-container.test.ts b/apps/kimi-code/test/tui/components/chrome/gutter-container.test.ts new file mode 100644 index 000000000..c26e97a17 --- /dev/null +++ b/apps/kimi-code/test/tui/components/chrome/gutter-container.test.ts @@ -0,0 +1,57 @@ +import type { Component } from '@earendil-works/pi-tui'; +import { describe, expect, it, vi } from 'vitest'; + +import { GutterContainer } from '#/tui/components/chrome/gutter-container'; + +class FakeChild implements Component { + constructor( + private readonly lines: (innerWidth: number) => string[], + ) {} + invalidate(): void {} + render(width: number): string[] { + return this.lines(width); + } +} + +describe('GutterContainer', () => { + it('prefixes every child line with `left` spaces', () => { + const c = new GutterContainer(2, 2); + c.addChild(new FakeChild(() => ['hello', 'world'])); + expect(c.render(20)).toEqual([' hello', ' world']); + }); + + it('shrinks the width passed to children by left + right', () => { + const seenWidth = vi.fn<(w: number) => string[]>(() => ['x']); + const c = new GutterContainer(2, 3); + c.addChild(new FakeChild(seenWidth)); + c.render(20); + expect(seenWidth).toHaveBeenCalledWith(15); + }); + + it('clamps inner width to at least 1 when gutters would otherwise consume it', () => { + const seenWidth = vi.fn<(w: number) => string[]>(() => ['x']); + const c = new GutterContainer(5, 5); + c.addChild(new FakeChild(seenWidth)); + c.render(2); + expect(seenWidth).toHaveBeenCalledWith(1); + }); + + it('stacks lines from multiple children in order', () => { + const c = new GutterContainer(1, 0); + c.addChild(new FakeChild(() => ['a1', 'a2'])); + c.addChild(new FakeChild(() => ['b1'])); + expect(c.render(10)).toEqual([' a1', ' a2', ' b1']); + }); + + it('returns an empty array when there are no children', () => { + const c = new GutterContainer(2, 2); + expect(c.render(20)).toEqual([]); + }); + + it('preserves ANSI sequences within child lines (only the leading pad is plain)', () => { + const colored = 'red'; + const c = new GutterContainer(2, 2); + c.addChild(new FakeChild(() => [colored])); + expect(c.render(20)).toEqual([` ${colored}`]); + }); +}); diff --git a/apps/kimi-code/test/tui/components/dialogs/approval-panel.test.ts b/apps/kimi-code/test/tui/components/dialogs/approval-panel.test.ts new file mode 100644 index 000000000..451c08ba5 --- /dev/null +++ b/apps/kimi-code/test/tui/components/dialogs/approval-panel.test.ts @@ -0,0 +1,414 @@ +import { CURSOR_MARKER } from '@earendil-works/pi-tui'; +import { describe, expect, it } from 'vitest'; + +import { ApprovalPanelComponent } from '#/tui/components/dialogs/approval-panel'; +import type { PendingApproval } from '#/tui/reverse-rpc/types'; +import { getColorPalette } from '#/tui/theme/colors'; + +import { captureProcessWrite } from '../../../helpers/process'; + +const COLORS = getColorPalette('dark'); + +function strip(text: string): string { + return text.replaceAll(/\u001B\[[0-9;]*m/g, ''); +} + +function makePending(): PendingApproval { + return { + data: { + id: 'approval_1', + tool_call_id: 'tool_1', + tool_name: 'WriteFile', + action: 'write a file', + description: 'Update README.md', + display: [], + choices: [ + { label: 'Approve once', response: 'approved' }, + { label: 'Approve for this session', response: 'approved_for_session' }, + { label: 'Reject', response: 'rejected' }, + { label: 'Reject with feedback', response: 'rejected', requires_feedback: true }, + ], + }, + }; +} + +function makeDialog(): { + dialog: ApprovalPanelComponent; + responses: Array<{ + response: string; + feedback?: string | undefined; + selected_label?: string | undefined; + }>; +} { + const responses: Array<{ + response: string; + feedback?: string | undefined; + selected_label?: string | undefined; + }> = []; + const dialog = new ApprovalPanelComponent( + makePending(), + (response) => responses.push(response), + COLORS, + ); + return { dialog, responses }; +} + +describe('ApprovalPanelComponent', () => { + it('renders only numeric approval shortcuts in the hint', () => { + const { dialog } = makeDialog(); + const out = strip(dialog.render(80).join('\n')); + expect(out).toContain('1/2/3/4 choose'); + expect(out).not.toContain('y/a/n/f'); + }); + + it('renders dangerous shell warnings with simple copy and no icon', () => { + const pending: PendingApproval = { + data: { + id: 'approval_danger', + tool_call_id: 'tool_danger', + tool_name: 'Bash', + action: 'run', + description: '', + display: [ + { + type: 'shell', + language: 'bash', + command: 'rm -rf /tmp/cache', + danger: 'recursive delete', + }, + ], + choices: [{ label: 'Approve once', response: 'approved' }], + }, + }; + const dialog = new ApprovalPanelComponent(pending, () => {}, COLORS); + + const out = strip(dialog.render(80).join('\n')); + expect(out).toContain('Dangerous: recursive delete'); + expect(out).not.toContain('potentially destructive'); + expect(out).not.toContain('⚠'); + }); + + it('numeric shortcuts still drive approval actions', () => { + const { dialog, responses } = makeDialog(); + dialog.handleInput('2'); + expect(responses).toEqual([{ response: 'approved_for_session', feedback: undefined }]); + }); + + it('shortcut 4 enters feedback mode and submits the typed feedback', () => { + const { dialog, responses } = makeDialog(); + dialog.handleInput('4'); + dialog.handleInput('n'); + dialog.handleInput('o'); + dialog.handleInput('\r'); + expect(responses).toEqual([{ response: 'rejected', feedback: 'no' }]); + }); + + it('renders feedback input inline with the selected choice', () => { + const { dialog } = makeDialog(); + dialog.handleInput('4'); + + const out = strip(dialog.render(80).join('\n')); + expect(out).toContain('▶ 4. Reject with feedback'); + expect(out).not.toContain('\n > '); + }); + + it('legacy y/a/n/f shortcuts no longer trigger approval actions', () => { + for (const key of ['y', 'a', 'n', 'f']) { + const { dialog, responses } = makeDialog(); + dialog.handleInput(key); + expect(responses).toEqual([]); + } + }); + + it('feedback input supports left/right cursor editing', () => { + const { dialog, responses } = makeDialog(); + dialog.handleInput('4'); + dialog.handleInput('n'); + dialog.handleInput('o'); + dialog.handleInput('\u001B[D'); + dialog.handleInput('!'); + dialog.handleInput('\r'); + expect(responses).toEqual([{ response: 'rejected', feedback: 'n!o' }]); + }); + + it('feedback input keeps editor shortcuts like ctrl+b / ctrl+f', () => { + const { dialog, responses } = makeDialog(); + dialog.handleInput('4'); + dialog.handleInput('a'); + dialog.handleInput('b'); + dialog.handleInput('c'); + dialog.handleInput('\u0002'); + dialog.handleInput('\u0002'); + dialog.handleInput('X'); + dialog.handleInput('\u0006'); + dialog.handleInput('Y'); + dialog.handleInput('\r'); + expect(responses).toEqual([{ response: 'rejected', feedback: 'aXbYc' }]); + }); + + it('renders an IME cursor marker while editing feedback', () => { + const { dialog } = makeDialog(); + dialog.focused = true; + dialog.handleInput('4'); + + const out = dialog.render(80).join('\n'); + expect(out).toContain(CURSOR_MARKER); + }); + + it.each(['\u0003', '\u0004', '\u001B'])( + 'shortcut %j rejects approval immediately', + (key) => { + const { dialog, responses } = makeDialog(); + dialog.handleInput(key); + expect(responses).toEqual([{ response: 'rejected' }]); + }, + ); + + it('renders ExitPlanMode with plan-specific header and plan-review choices', () => { + const pending: PendingApproval = { + data: { + id: 'approval_plan', + tool_call_id: 'tool_plan', + tool_name: 'ExitPlanMode', + action: 'review plan', + description: '', + display: [], + choices: [ + { label: 'Approve', response: 'approved' }, + { label: 'Reject', response: 'rejected' }, + { label: 'Revise', response: 'rejected', requires_feedback: true }, + ], + }, + }; + const dialog = new ApprovalPanelComponent(pending, () => {}, COLORS); + + const out = strip(dialog.render(80).join('\n')); + expect(out).toContain('Ready to build with this plan?'); + expect(out).not.toContain('Approve ExitPlanMode?'); + expect(out).toContain('Approve'); + expect(out).toContain('Reject'); + expect(out).toContain('Revise'); + expect(out).not.toContain('Approve for this session'); + expect(out).not.toContain('Investigate'); + }); + + it('renders an Edit diff collapsed by default and expands on ctrl+e', () => { + const responses: Array<{ response: string }> = []; + const oldLines: string[] = []; + const newLines: string[] = []; + for (let i = 1; i <= 30; i++) { + oldLines.push(`old${String(i)}`); + newLines.push(`new${String(i)}`); + } + const pending: PendingApproval = { + data: { + id: 'approval_diff', + tool_call_id: 'tool_diff', + tool_name: 'Edit', + action: 'edit', + description: '', + display: [ + { + type: 'diff', + path: 'src/foo.ts', + old_text: oldLines.join('\n'), + new_text: newLines.join('\n'), + }, + ], + choices: [{ label: 'Approve once', response: 'approved' }], + }, + }; + let globalToggleCalls = 0; + const dialog = new ApprovalPanelComponent( + pending, + (r) => responses.push(r), + COLORS, + () => globalToggleCalls++, + ); + + const collapsed = strip(dialog.render(120).join('\n')); + expect(collapsed).not.toMatch(/\bedit\s+src\/foo\.ts\b/); + expect(collapsed).toContain('+30'); + expect(collapsed).toContain('-30'); + expect(collapsed).toContain('ctrl+e expand'); + expect(collapsed).toContain('ctrl+e to expand'); + expect(collapsed).toMatch(/old\d+|new\d+/); + expect(collapsed).not.toContain('new30'); + + dialog.handleInput('\u0005'); // Ctrl+E — local toggle, no global callback. + + const expanded = strip(dialog.render(120).join('\n')); + expect(expanded).toContain('new30'); + expect(expanded).toContain('ctrl+e collapse'); + expect(expanded).not.toContain('more changes hidden'); + expect(globalToggleCalls).toBe(0); + expect(responses).toEqual([]); + }); + + it('forwards ctrl+o to the global tool-output toggle without changing local expansion', () => { + const pending: PendingApproval = { + data: { + id: 'approval_forward', + tool_call_id: 'tool_forward', + tool_name: 'Edit', + action: 'edit', + description: '', + display: [ + { + type: 'diff', + path: 'src/foo.ts', + old_text: Array.from({ length: 30 }, (_, i) => `old${String(i + 1)}`).join('\n'), + new_text: Array.from({ length: 30 }, (_, i) => `new${String(i + 1)}`).join('\n'), + }, + ], + choices: [{ label: 'Approve once', response: 'approved' }], + }, + }; + let globalToggleCalls = 0; + const dialog = new ApprovalPanelComponent(pending, () => {}, COLORS, () => globalToggleCalls++); + + dialog.handleInput('\u000F'); // Ctrl+O — forwarded; local stays collapsed. + + const after = strip(dialog.render(120).join('\n')); + expect(globalToggleCalls).toBe(1); + expect(after).toContain('ctrl+e expand'); + expect(after).not.toContain('new30'); + }); + + it('also forwards ctrl+e to the global plan-expand toggle while toggling local content', () => { + const pending: PendingApproval = { + data: { + id: 'approval_plan_forward', + tool_call_id: 'tool_plan_forward', + tool_name: 'Edit', + action: 'edit', + description: '', + display: [ + { + type: 'diff', + path: 'src/foo.ts', + old_text: Array.from({ length: 30 }, (_, i) => `old${String(i + 1)}`).join('\n'), + new_text: Array.from({ length: 30 }, (_, i) => `new${String(i + 1)}`).join('\n'), + }, + ], + choices: [{ label: 'Approve once', response: 'approved' }], + }, + }; + let planToggles = 0; + const dialog = new ApprovalPanelComponent( + pending, + () => {}, + COLORS, + undefined, + () => planToggles++, + ); + + dialog.handleInput('\u0005'); // Ctrl+E + const out = strip(dialog.render(120).join('\n')); + expect(planToggles).toBe(1); + expect(out).toContain('ctrl+e collapse'); // local also expanded + expect(out).toContain('new30'); + }); + + it('renders Write as a syntax-highlighted code block (file_content), not a diff', () => { + const responses: Array<{ response: string }> = []; + const lines: string[] = []; + for (let i = 1; i <= 30; i++) lines.push(`const x${String(i)} = ${String(i)};`); + const pending: PendingApproval = { + data: { + id: 'approval_write', + tool_call_id: 'tool_write', + tool_name: 'Write', + action: 'write', + description: '', + display: [{ type: 'file_content', path: 'src/new.ts', content: lines.join('\n') }], + choices: [{ label: 'Approve once', response: 'approved' }], + }, + }; + const dialog = new ApprovalPanelComponent(pending, (r) => responses.push(r), COLORS); + + const collapsed = strip(dialog.render(120).join('\n')); + // No diff markers, no +N -M header. + expect(collapsed).not.toMatch(/^\s*\+\d+/m); + expect(collapsed).not.toMatch(/^\s*-\d+/m); + expect(collapsed).toContain('src/new.ts'); + expect(collapsed).toContain('const x1 = 1;'); + expect(collapsed).toContain('const x10 = 10;'); + expect(collapsed).not.toContain('const x25 = 25;'); + expect(collapsed).toContain('20 more lines hidden (ctrl+e to expand)'); + expect(collapsed).toContain('ctrl+e expand'); + + dialog.handleInput('\u0005'); // Ctrl+E + const expanded = strip(dialog.render(120).join('\n')); + expect(expanded).toContain('const x30 = 30;'); + expect(expanded).not.toContain('more lines hidden'); + expect(responses).toEqual([]); + }); + + it('renders unknown file_content extensions as plain text without stderr noise', () => { + const pending: PendingApproval = { + data: { + id: 'approval_unknown_write', + tool_call_id: 'tool_unknown_write', + tool_name: 'Write', + action: 'write', + description: '', + display: [{ type: 'file_content', path: 'demo.abcxyz', content: 'hello\nworld' }], + choices: [{ label: 'Approve once', response: 'approved' }], + }, + }; + const stderr = captureProcessWrite('stderr'); + try { + const dialog = new ApprovalPanelComponent(pending, () => {}, COLORS); + const collapsed = strip(dialog.render(120).join('\n')); + expect(collapsed).toContain('hello'); + + dialog.handleInput('\u0005'); // Ctrl+E + const expanded = strip(dialog.render(120).join('\n')); + expect(expanded).toContain('world'); + expect(stderr.text()).not.toContain('Could not find the language'); + } finally { + stderr.restore(); + } + }); + + it('returns feedback for plan-review revise choice', () => { + const responses: Array<{ + response: string; + feedback?: string | undefined; + selected_label?: string | undefined; + }> = []; + const pending: PendingApproval = { + data: { + id: 'approval_plan', + tool_call_id: 'tool_plan', + tool_name: 'ExitPlanMode', + action: 'review plan', + description: '', + display: [], + choices: [ + { label: 'Approve', response: 'approved' }, + { + label: 'Revise', + response: 'rejected', + selected_label: 'Revise', + requires_feedback: true, + }, + ], + }, + }; + const dialog = new ApprovalPanelComponent( + pending, + (response) => responses.push(response), + COLORS, + ); + + dialog.handleInput('2'); + dialog.handleInput('n'); + dialog.handleInput('o'); + dialog.handleInput('\r'); + expect(responses).toEqual([ + { response: 'rejected', feedback: 'no', selected_label: 'Revise' }, + ]); + }); +}); diff --git a/apps/kimi-code/test/tui/components/dialogs/choice-picker.test.ts b/apps/kimi-code/test/tui/components/dialogs/choice-picker.test.ts new file mode 100644 index 000000000..9f6d453c1 --- /dev/null +++ b/apps/kimi-code/test/tui/components/dialogs/choice-picker.test.ts @@ -0,0 +1,202 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { ChoicePickerComponent } from '#/tui/components/dialogs/choice-picker'; +import { EditorSelectorComponent } from '#/tui/components/dialogs/editor-selector'; +import { ModelSelectorComponent } from '#/tui/components/dialogs/model-selector'; +import { PermissionSelectorComponent } from '#/tui/components/dialogs/permission-selector'; +import { SettingsSelectorComponent } from '#/tui/components/dialogs/settings-selector'; +import { ThemeSelectorComponent } from '#/tui/components/dialogs/theme-selector'; +import { darkColors } from '#/tui/theme/colors'; + +const ANSI_SGR = /\u001B\[[0-9;]*m/g; + +function strip(text: string): string { + return text.replaceAll(ANSI_SGR, ''); +} + +describe('ChoicePickerComponent', () => { + it('renders optional descriptions below choice labels', () => { + const picker = new ChoicePickerComponent({ + title: 'Select permission mode', + options: [ + { + value: 'manual', + label: 'Manual', + description: 'Ask before commands, edits, and other risky actions.', + }, + { + value: 'auto', + label: 'Auto', + description: 'Automatically approve tool actions and plan transitions.', + }, + ], + currentValue: 'manual', + colors: darkColors, + onSelect: vi.fn(), + onCancel: vi.fn(), + }); + + const out = picker.render(120).map(strip); + + expect(out).toContain(' ❯ Manual ← current'); + expect(out).toContain(' Ask before commands, edits, and other risky actions.'); + expect(out).toContain(' Automatically approve tool actions and plan transitions.'); + }); + + it('renders domain selector wrappers with their configured options', () => { + const onSelect = vi.fn(); + const onCancel = vi.fn(); + + const editor = new EditorSelectorComponent({ + currentValue: 'vim', + colors: darkColors, + onSelect, + onCancel, + }); + expect(editor.render(120).map(strip)).toContain(' ❯ Vim ← current'); + + const model = new ModelSelectorComponent({ + models: { + kimi: { + provider: 'managed:kimi-code', + model: 'kimi-k2', + maxContextSize: 200_000, + displayName: 'Kimi K2', + capabilities: ['thinking'], + }, + }, + currentValue: 'kimi', + currentThinking: true, + colors: darkColors, + onSelect, + onCancel, + }); + const modelOutput = model.render(120).map(strip); + expect(modelOutput).toContain(' ❯ Kimi K2 (Kimi Code) ← current'); + expect(modelOutput).toContain(' Thinking'); + expect(modelOutput).toContain(' [ On ] Off '); + + const theme = new ThemeSelectorComponent({ + currentValue: 'light', + colors: darkColors, + onSelect, + onCancel, + }); + expect(theme.render(120).map(strip)).toContain(' ❯ Light ← current'); + + const permission = new PermissionSelectorComponent({ + currentValue: 'manual', + colors: darkColors, + onSelect, + onCancel, + }); + expect(permission.render(120).map(strip)).toContain(' ❯ Manual ← current'); + + const settings = new SettingsSelectorComponent({ + colors: darkColors, + onSelect, + onCancel, + }); + const settingsOutput = settings.render(120).map(strip); + expect(settingsOutput).toContain(' ❯ Model'); + expect(settingsOutput).toContain(' Switch the active model and thinking mode.'); + }); + + it('submits the selected model and inline thinking state', () => { + const onSelect = vi.fn(); + const picker = new ModelSelectorComponent({ + models: { + kimi: { + provider: 'managed:kimi-code', + model: 'kimi-k2', + maxContextSize: 200_000, + displayName: 'Kimi K2', + capabilities: ['thinking'], + }, + }, + currentValue: 'kimi', + currentThinking: true, + colors: darkColors, + onSelect, + onCancel: vi.fn(), + }); + + picker.handleInput('\u001B[C'); + picker.handleInput('\r'); + + expect(onSelect).toHaveBeenCalledWith({ alias: 'kimi', thinking: false }); + }); + + it('forces always-thinking models on and unsupported models off', () => { + const onSelect = vi.fn(); + const picker = new ModelSelectorComponent({ + models: { + always: { + provider: 'managed:kimi-code', + model: 'kimi-thinking', + maxContextSize: 200_000, + displayName: 'Kimi Thinking', + capabilities: ['always_thinking'], + }, + plain: { + provider: 'managed:kimi-code', + model: 'kimi-plain', + maxContextSize: 200_000, + displayName: 'Kimi Plain', + capabilities: ['tool_use'], + }, + }, + currentValue: 'always', + currentThinking: false, + colors: darkColors, + onSelect, + onCancel: vi.fn(), + }); + + expect(picker.render(120).map(strip)).toContain(' [ Always on ]'); + picker.handleInput('\u001B[C'); + picker.handleInput('\r'); + expect(onSelect).toHaveBeenLastCalledWith({ alias: 'always', thinking: true }); + + picker.handleInput('\u001B[B'); + expect(picker.render(120).map(strip)).toContain(' [ Off ] unsupported'); + picker.handleInput('\u001B[D'); + picker.handleInput('\r'); + expect(onSelect).toHaveBeenLastCalledWith({ alias: 'plain', thinking: false }); + }); + + it('keeps the thinking draft when moving across models', () => { + const onSelect = vi.fn(); + const picker = new ModelSelectorComponent({ + models: { + plain: { + provider: 'managed:kimi-code', + model: 'kimi-plain', + maxContextSize: 200_000, + displayName: 'Kimi Plain', + capabilities: ['tool_use'], + }, + thinking: { + provider: 'managed:kimi-code', + model: 'kimi-thinking', + maxContextSize: 200_000, + displayName: 'Kimi Thinking', + capabilities: ['thinking'], + }, + }, + currentValue: 'plain', + currentThinking: false, + colors: darkColors, + onSelect, + onCancel: vi.fn(), + }); + + picker.handleInput('\u001B[B'); + picker.handleInput('\u001B[D'); + picker.handleInput('\u001B[A'); + picker.handleInput('\u001B[B'); + picker.handleInput('\r'); + + expect(onSelect).toHaveBeenCalledWith({ alias: 'thinking', thinking: true }); + }); +}); diff --git a/apps/kimi-code/test/tui/components/dialogs/compaction.test.ts b/apps/kimi-code/test/tui/components/dialogs/compaction.test.ts new file mode 100644 index 000000000..b49501978 --- /dev/null +++ b/apps/kimi-code/test/tui/components/dialogs/compaction.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from 'vitest'; + +import { CompactionComponent } from '#/tui/components/dialogs/compaction'; +import { darkColors } from '#/tui/theme/colors'; + +function strip(text: string): string { + return text.replaceAll(/\u001B\[[0-9;]*m/g, ''); +} + +describe('CompactionComponent', () => { + it('renders the custom instruction below the compacting label', () => { + const component = new CompactionComponent(darkColors, undefined, 'keep the recent files only'); + + try { + const lines = component.render(120).map(strip); + const text = lines.join('\n'); + + expect(text).toContain('Compacting context...'); + expect(text).toContain(' keep the recent files only'); + } finally { + component.dispose(); + } + }); + + it('renders a cancelled terminal state', () => { + const component = new CompactionComponent(darkColors); + + try { + component.markCanceled(); + const lines = component.render(120).map(strip); + const text = lines.join('\n'); + + expect(text).toContain('Compaction cancelled'); + expect(text).not.toContain('Compacting context...'); + } finally { + component.dispose(); + } + }); +}); diff --git a/apps/kimi-code/test/tui/components/dialogs/feedback-input-dialog.test.ts b/apps/kimi-code/test/tui/components/dialogs/feedback-input-dialog.test.ts new file mode 100644 index 000000000..13fcdd589 --- /dev/null +++ b/apps/kimi-code/test/tui/components/dialogs/feedback-input-dialog.test.ts @@ -0,0 +1,108 @@ +import chalk from 'chalk'; +import { beforeAll, describe, expect, it } from 'vitest'; + +import { + FeedbackInputDialogComponent, + type FeedbackInputDialogResult, +} from '#/tui/components/dialogs/feedback-input-dialog'; +import { darkColors } from '#/tui/theme/colors'; + +const ESC = String.fromCodePoint(27); +const CTRL_C = String.fromCodePoint(3); +const ANSI_RE = new RegExp(`${ESC}\\[[0-9;]*m`, 'g'); + +function strip(text: string): string { + return text.replaceAll(ANSI_RE, ''); +} + +beforeAll(() => { + chalk.level = 3; +}); + +function makeDialog(): { + dialog: FeedbackInputDialogComponent; + collected: FeedbackInputDialogResult[]; +} { + const collected: FeedbackInputDialogResult[] = []; + const dialog = new FeedbackInputDialogComponent((result) => { + collected.push(result); + }, darkColors); + dialog.focused = true; + return { dialog, collected }; +} + +describe('FeedbackInputDialogComponent', () => { + it('renders a blue rounded box with title, subtitle, and footer', () => { + const { dialog } = makeDialog(); + const text = strip(dialog.render(60).join('\n')); + + expect(text).toContain('╭'); + expect(text).toContain('╮'); + expect(text).toContain('╰'); + expect(text).toContain('╯'); + expect(text).toContain('Send feedback to Kimi Code'); + expect(text).toContain("Tell us what's working or what's not."); + expect(text).toContain('Enter to submit'); + expect(text).toContain('Esc to cancel'); + }); + + it('uses the primary color for the border', () => { + const { dialog } = makeDialog(); + const rendered = dialog.render(60).join('\n'); + const sample = chalk.hex(darkColors.primary)('x'); + const ansiOpen = sample.split('x')[0]!; + expect(rendered).toContain(`${ansiOpen}╭`); + expect(rendered).toContain(`${ansiOpen}╰`); + }); + + it('typing then pressing Enter submits the trimmed value', () => { + const { dialog, collected } = makeDialog(); + for (const ch of 'hello world ') { + dialog.handleInput(ch); + } + dialog.handleInput('\r'); + expect(collected).toEqual([{ kind: 'ok', value: 'hello world' }]); + }); + + it('Enter on an empty input does not submit and shows an inline empty hint', () => { + const { dialog, collected } = makeDialog(); + dialog.handleInput('\r'); + expect(collected).toEqual([]); + const text = strip(dialog.render(60).join('\n')); + expect(text).toContain('Feedback cannot be empty.'); + expect(text).not.toContain("Tell us what's working or what's not."); + }); + + it('typing again after the empty hint clears the hint', () => { + const { dialog, collected } = makeDialog(); + dialog.handleInput('\r'); + expect(strip(dialog.render(60).join('\n'))).toContain('Feedback cannot be empty.'); + dialog.handleInput('a'); + const text = strip(dialog.render(60).join('\n')); + expect(text).toContain("Tell us what's working or what's not."); + expect(text).not.toContain('Feedback cannot be empty.'); + expect(collected).toEqual([]); + }); + + it('Esc cancels the dialog', () => { + const { dialog, collected } = makeDialog(); + dialog.handleInput(ESC); + expect(collected).toEqual([{ kind: 'cancel' }]); + }); + + it('Ctrl-C cancels the dialog', () => { + const { dialog, collected } = makeDialog(); + dialog.handleInput(CTRL_C); + expect(collected).toEqual([{ kind: 'cancel' }]); + }); + + it('does not fire onDone twice', () => { + const { dialog, collected } = makeDialog(); + dialog.handleInput('h'); + dialog.handleInput('i'); + dialog.handleInput('\r'); + dialog.handleInput('\r'); + dialog.handleInput(ESC); + expect(collected).toEqual([{ kind: 'ok', value: 'hi' }]); + }); +}); diff --git a/apps/kimi-code/test/tui/components/dialogs/question-dialog.test.ts b/apps/kimi-code/test/tui/components/dialogs/question-dialog.test.ts new file mode 100644 index 000000000..6a492f406 --- /dev/null +++ b/apps/kimi-code/test/tui/components/dialogs/question-dialog.test.ts @@ -0,0 +1,439 @@ +import { CURSOR_MARKER } from '@earendil-works/pi-tui'; +import chalk from 'chalk'; +import { beforeAll, describe, expect, it } from 'vitest'; + +import { QuestionDialogComponent } from '#/tui/components/dialogs/question-dialog'; +import type { PendingQuestion } from '#/tui/reverse-rpc/types'; +import { darkColors } from '#/tui/theme/colors'; + +function strip(text: string): string { + return text.replaceAll(/\u001B\[[0-9;]*m/g, ''); +} + +beforeAll(() => { + chalk.level = 3; +}); + +function makePending( + questions: PendingQuestion['data']['questions'], + requestId = 'q_1', +): PendingQuestion { + return { + data: { + id: requestId, + tool_call_id: 'tc_1', + questions, + }, + }; +} + +function makeDialog( + pending: PendingQuestion, + onToggleToolOutput?: () => void, + onTogglePlanExpand?: () => void, +): { + dialog: QuestionDialogComponent; + collected: string[][]; + methods: Array<string | undefined>; +} { + const collected: string[][] = []; + const methods: Array<string | undefined> = []; + const dialog = new QuestionDialogComponent( + pending, + (response) => { + collected.push(response.answers); + methods.push(response.method); + }, + darkColors, + 6, + onToggleToolOutput, + onTogglePlanExpand, + ); + return { dialog, collected, methods }; +} + +describe('QuestionDialogComponent', () => { + it('single-select answers auto-advance and only submit from the review tab', () => { + const pending = makePending([ + { + question: 'Q1?', + multi_select: false, + options: [{ label: 'A1' }, { label: 'B1' }], + }, + { + question: 'Q2?', + multi_select: false, + options: [{ label: 'A2' }, { label: 'B2' }], + }, + ]); + const { dialog, collected, methods } = makeDialog(pending); + + dialog.handleInput('2'); + expect(collected).toEqual([]); + expect(strip(dialog.render(80).join('\n'))).toMatch(/Q2\?/); + + dialog.handleInput('\r'); + expect(collected).toEqual([]); + + const reviewRaw = dialog.render(80).join('\n'); + const review = strip(reviewRaw); + expect(review).toContain('Review your answer before submit'); + expect(review).toContain('Ready to submit your answers?'); + expect(review).not.toContain('? Ready to submit your answers?'); + expect(review).not.toContain('Please answer all questions before submitting.'); + expect(reviewRaw).toContain( + chalk.hex(darkColors.text).bold(' Review your answer before submit'), + ); + expect(reviewRaw).toContain(chalk.hex(darkColors.text)(' Ready to submit your answers?')); + expect(review).toContain('B1'); + expect(review).toContain('A2'); + + dialog.handleInput('1'); + expect(collected).toEqual([['B1', 'A2']]); + expect(methods).toEqual(['enter']); + }); + + it('last single-select question goes straight to review instead of wrapping back', () => { + const pending = makePending([ + { + question: 'Q1?', + multi_select: false, + options: [{ label: 'A1' }, { label: 'B1' }], + }, + { + question: 'Q2?', + multi_select: false, + options: [{ label: 'A2' }, { label: 'B2' }], + }, + ]); + const { dialog, collected } = makeDialog(pending); + + dialog.handleInput('\t'); + dialog.handleInput('2'); + + const review = strip(dialog.render(80).join('\n')); + expect(review).toContain('Review your answer before submit'); + expect(review).toContain('Some questions are still unanswered.'); + expect(review).toContain('B2'); + expect(review).toContain('Not answered'); + expect(collected).toEqual([]); + }); + + it('renders optional body text above options', () => { + const pending = makePending([ + { + question: 'Approve this plan?', + body: '# Plan\n\n1. Make the focused change.', + multi_select: false, + options: [{ label: 'Approve' }, { label: 'Reject' }], + }, + ]); + const { dialog } = makeDialog(pending); + const out = strip(dialog.render(80).join('\n')); + expect(out).toContain('# Plan'); + expect(out).toContain('1. Make the focused change.'); + expect(out).toContain('Approve'); + expect(out).toContain('Other'); + }); + + it('multi-select uses space and number keys to toggle choices', () => { + const pending = makePending([ + { + question: 'Pick many?', + multi_select: true, + options: [{ label: 'A' }, { label: 'B' }, { label: 'C' }], + }, + ]); + const { dialog, collected } = makeDialog(pending); + + dialog.handleInput(' '); + dialog.handleInput('\u001B[B'); + dialog.handleInput('\u001B[B'); + dialog.handleInput('3'); + dialog.handleInput('\t'); + + const review = strip(dialog.render(80).join('\n')); + expect(review).toContain('A, C'); + expect(review).not.toContain('Not answered'); + expect(collected).toEqual([]); + }); + + it('review shows an unanswered warning and still allows submit', () => { + const pending = makePending([ + { + question: 'Q1?', + multi_select: false, + options: [{ label: 'A1' }, { label: 'B1' }], + }, + { + question: 'Q2?', + multi_select: false, + options: [{ label: 'A2' }, { label: 'B2' }], + }, + ]); + const { dialog, collected } = makeDialog(pending); + + dialog.handleInput('\t'); + dialog.handleInput('2'); + + const before = strip(dialog.render(80).join('\n')); + expect(before).toContain('Not answered'); + expect(before).toContain('Some questions are still unanswered.'); + + dialog.handleInput('\r'); + expect(collected).toHaveLength(1); + expect(collected[0]?.[0]).toBeUndefined(); + expect(collected[0]?.[1]).toBe('B2'); + }); + + it('review cancel dismisses the whole request', () => { + const pending = makePending([ + { + question: 'Pick one?', + multi_select: false, + options: [{ label: 'A' }, { label: 'B' }], + }, + ]); + const { dialog, collected } = makeDialog(pending); + + dialog.handleInput('\t'); + dialog.handleInput('2'); + + expect(collected).toEqual([[]]); + }); + + it('single-select Other input is inline and auto-advances after commit', () => { + const pending = makePending([ + { + question: 'Pick one?', + multi_select: false, + other_label: 'Custom', + other_description: 'Type your own answer', + options: [{ label: 'A' }, { label: 'B' }], + }, + ]); + const { dialog, collected, methods } = makeDialog(pending); + + dialog.handleInput('3'); + let out = strip(dialog.render(80).join('\n')); + expect(out).toContain('→ [3] Custom:'); + expect(out).not.toContain('Type your own answer'); + + dialog.handleInput('H'); + dialog.handleInput('i'); + dialog.handleInput('\r'); + + out = strip(dialog.render(80).join('\n')); + expect(out).toContain('Review your answer before submit'); + expect(out).toContain('Ready to submit your answers?'); + expect(out).not.toContain('? Ready to submit your answers?'); + expect(out).toContain('Hi'); + + dialog.handleInput('1'); + expect(collected).toEqual([['Hi']]); + expect(methods).toEqual(['enter']); + }); + + it('Other input supports left/right cursor editing before commit', () => { + const pending = makePending([ + { + question: 'Pick one?', + multi_select: false, + other_label: 'Custom', + options: [{ label: 'A' }, { label: 'B' }], + }, + ]); + const { dialog } = makeDialog(pending); + + dialog.handleInput('3'); + dialog.handleInput('H'); + dialog.handleInput('i'); + dialog.handleInput('\u001B[D'); + dialog.handleInput('!'); + dialog.handleInput('\r'); + + const out = strip(dialog.render(80).join('\n')); + expect(out).toContain('H!i'); + expect(out).toContain('Review your answer before submit'); + }); + + it('renders an IME cursor marker while editing Other when focused', () => { + const pending = makePending([ + { + question: 'Pick one?', + multi_select: false, + other_label: 'Custom', + options: [{ label: 'A' }, { label: 'B' }], + }, + ]); + const { dialog } = makeDialog(pending); + + dialog.focused = true; + dialog.handleInput('3'); + + const out = dialog.render(80).join('\n'); + expect(out).toContain(CURSOR_MARKER); + }); + + it('keeps selected options green even when the cursor returns to them', () => { + const pending = makePending([ + { + question: 'Pick one?', + multi_select: false, + options: [{ label: 'A' }, { label: 'B' }], + }, + ]); + const { dialog } = makeDialog(pending); + + dialog.handleInput('\r'); + dialog.handleInput('\u001B[D'); + + const out = dialog.render(80).join('\n'); + expect(out).toContain(chalk.hex(darkColors.success).bold(' → [1] A')); + expect(out).not.toContain(chalk.hex(darkColors.primary)(' → [1] A')); + }); + + it('stretches the border to the full available width', () => { + const pending = makePending([ + { + question: 'Pick one?', + multi_select: false, + options: [{ label: 'A' }, { label: 'B' }], + }, + ]); + const { dialog } = makeDialog(pending); + + const lines = dialog.render(80); + expect(strip(lines[0] ?? '')).toHaveLength(80); + expect(strip(lines.at(-1) ?? '')).toHaveLength(80); + }); + + it('does not show the submit tab as completed when all questions are answered', () => { + const pending = makePending([ + { + question: 'Q1?', + multi_select: false, + options: [{ label: 'A1' }, { label: 'B1' }], + }, + { + question: 'Q2?', + multi_select: false, + options: [{ label: 'A2' }, { label: 'B2' }], + }, + ]); + const { dialog } = makeDialog(pending); + + dialog.handleInput('\r'); + dialog.handleInput('\r'); + + const out = strip(dialog.render(80).join('\n')); + expect(out).toContain(' Submit '); + expect(out).not.toContain('(✓) Submit'); + expect(out).not.toContain('(○) Submit'); + }); + + it('renders the active tab with a highlighted background instead of the circle marker', () => { + const pending = makePending([ + { + question: 'Q1?', + header: 'First', + multi_select: false, + options: [{ label: 'A1' }, { label: 'B1' }], + }, + { + question: 'Q2?', + header: 'Second', + multi_select: false, + options: [{ label: 'A2' }, { label: 'B2' }], + }, + ]); + const { dialog } = makeDialog(pending); + + const out = dialog.render(80).join('\n'); + expect(out).toContain(chalk.bgHex(darkColors.primary).hex(darkColors.text).bold(' First ')); + expect(out).not.toContain('(●) First'); + }); + + it('preserves Other drafts across tabs and question navigation', () => { + const pending = makePending([ + { + question: 'Pick toppings?', + multi_select: true, + options: [{ label: 'Cheese' }, { label: 'Pepperoni' }], + }, + ]); + const { dialog } = makeDialog(pending); + + dialog.handleInput('3'); + dialog.handleInput('M'); + dialog.handleInput('u'); + dialog.handleInput('s'); + dialog.handleInput('h'); + dialog.handleInput('r'); + dialog.handleInput('o'); + dialog.handleInput('o'); + dialog.handleInput('m'); + dialog.handleInput('\t'); + + let out = strip(dialog.render(80).join('\n')); + expect(out).toContain('Not answered'); + + dialog.handleInput('\u001B[D'); + out = strip(dialog.render(80).join('\n')); + expect(out).toContain('Other: Mushroom'); + + dialog.handleInput('\r'); + dialog.handleInput('\r'); + dialog.handleInput('\t'); + out = strip(dialog.render(80).join('\n')); + expect(out).toContain('Mushroom'); + }); + + it('escape dismisses with empty answers array', () => { + const pending = makePending([ + { + question: 'Pick one?', + multi_select: false, + options: [{ label: 'A' }, { label: 'B' }], + }, + ]); + const { dialog, collected } = makeDialog(pending); + dialog.handleInput('\u001B'); + expect(collected).toEqual([[]]); + }); + + it.each(['\u0003', '\u0004'])('ctrl shortcut %j dismisses question dialog', (key) => { + const pending = makePending([ + { + question: 'Pick one?', + multi_select: false, + options: [{ label: 'A' }, { label: 'B' }], + }, + ]); + const { dialog, collected } = makeDialog(pending); + dialog.handleInput(key); + expect(collected).toEqual([[]]); + }); + it('forwards ctrl+o to the global tool-output toggle without answering', () => { + let toggles = 0; + const pending = makePending([ + { question: 'Q?', multi_select: false, options: [{ label: 'A' }] }, + ]); + const { dialog, collected } = makeDialog(pending, () => toggles++); + dialog.handleInput('\u000F'); // Ctrl+O + expect(toggles).toBe(1); + expect(collected).toEqual([]); + }); + + it('forwards ctrl+e to the global plan-expand toggle without answering', () => { + let planToggles = 0; + const pending = makePending([ + { question: 'Q?', multi_select: false, options: [{ label: 'A' }] }, + ]); + const { dialog, collected } = makeDialog(pending, undefined, () => planToggles++); + dialog.handleInput('\u0005'); // Ctrl+E + expect(planToggles).toBe(1); + expect(collected).toEqual([]); + }); + +}); diff --git a/apps/kimi-code/test/tui/components/dialogs/session-picker.test.ts b/apps/kimi-code/test/tui/components/dialogs/session-picker.test.ts new file mode 100644 index 000000000..c8e919637 --- /dev/null +++ b/apps/kimi-code/test/tui/components/dialogs/session-picker.test.ts @@ -0,0 +1,264 @@ +import { visibleWidth } from '@earendil-works/pi-tui'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { SessionPickerComponent } from '#/tui/components/dialogs/session-picker'; +import { getColorPalette } from '#/tui/theme/colors'; + +function stripAnsi(text: string): string { + return text.replaceAll(/\[[0-?]*[ -/]*[@-~]/g, ''); +} + +function renderPlain(component: SessionPickerComponent, width = 120): string { + return stripAnsi(component.render(width).join('\n')); +} + +describe('SessionPickerComponent', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('renders millisecond updated_at timestamps as relative times', () => { + const now = new Date('2026-05-11T12:00:00.000Z').getTime(); + vi.spyOn(Date, 'now').mockReturnValue(now); + + const component = new SessionPickerComponent({ + sessions: [ + { + id: 'ses_minutes', + title: 'minutes old', + work_dir: '/tmp/project', + updated_at: now - 2 * 60 * 1000, + }, + { + id: 'ses_hours', + title: 'hours old', + work_dir: '/tmp/project', + updated_at: now - 3 * 60 * 60 * 1000, + }, + ], + loading: false, + currentSessionId: 'ses_other', + colors: getColorPalette('dark'), + onSelect: vi.fn(), + onCancel: vi.fn(), + }); + + const output = renderPlain(component); + + expect(output).toContain('2m ago'); + expect(output).toContain('3h ago'); + expect(output).not.toContain('just now'); + }); + + it('renders title, full session id, work_dir, and last_prompt for each session', () => { + const now = new Date('2026-05-11T12:00:00.000Z').getTime(); + vi.spyOn(Date, 'now').mockReturnValue(now); + + const component = new SessionPickerComponent({ + sessions: [ + { + id: 'ses_01HXYABCDEFGHIJK', + title: 'Refactor sessions list', + last_prompt: 'please redesign the picker UI', + work_dir: '/tmp/project', + updated_at: now - 60 * 1000, + }, + ], + loading: false, + currentSessionId: 'ses_other', + colors: getColorPalette('dark'), + onSelect: vi.fn(), + onCancel: vi.fn(), + }); + + const output = renderPlain(component); + + expect(output).toContain('Refactor sessions list'); + // Session id is rendered in full, never abbreviated with an ellipsis. + expect(output).toContain('ses_01HXYABCDEFGHIJK'); + expect(output).not.toMatch(/ses_01\S*…/); + expect(output).toContain('/tmp/project'); + expect(output).toContain('please redesign the picker UI'); + }); + + it('omits the last-prompt row when last_prompt is missing', () => { + const now = new Date('2026-05-11T12:00:00.000Z').getTime(); + vi.spyOn(Date, 'now').mockReturnValue(now); + + const component = new SessionPickerComponent({ + sessions: [ + { + id: 'ses_no_prompt', + title: 'no prompt yet', + work_dir: '/tmp/project', + updated_at: now - 60 * 1000, + }, + ], + loading: false, + currentSessionId: 'ses_other', + colors: getColorPalette('dark'), + onSelect: vi.fn(), + onCancel: vi.fn(), + }); + + const output = renderPlain(component); + + expect(output).not.toMatch(/^\s*›/m); + }); + + it('truncates overly long last_prompt content', () => { + const now = new Date('2026-05-11T12:00:00.000Z').getTime(); + vi.spyOn(Date, 'now').mockReturnValue(now); + + const longPrompt = 'a'.repeat(500); + const component = new SessionPickerComponent({ + sessions: [ + { + id: 'ses_long', + title: 'long prompt', + last_prompt: longPrompt, + work_dir: '/tmp/project', + updated_at: now - 60 * 1000, + }, + ], + loading: false, + currentSessionId: 'ses_other', + colors: getColorPalette('dark'), + onSelect: vi.fn(), + onCancel: vi.fn(), + }); + + const lines = component.render(60).map((line) => stripAnsi(line)); + const promptLine = lines.find((line) => line.trimStart().startsWith('›')); + expect(promptLine).toBeDefined(); + expect(promptLine!.length).toBeLessThanOrEqual(60); + expect(promptLine!.endsWith('…')).toBe(true); + expect(promptLine).not.toContain(longPrompt); + }); + + it('marks the current session with a (current) badge', () => { + const now = new Date('2026-05-11T12:00:00.000Z').getTime(); + vi.spyOn(Date, 'now').mockReturnValue(now); + + const component = new SessionPickerComponent({ + sessions: [ + { + id: 'ses_current', + title: 'this is current', + work_dir: '/tmp/project', + updated_at: now, + }, + { + id: 'ses_other', + title: 'not current', + work_dir: '/tmp/project', + updated_at: now - 60 * 1000, + }, + ], + loading: false, + currentSessionId: 'ses_current', + colors: getColorPalette('dark'), + onSelect: vi.fn(), + onCancel: vi.fn(), + }); + + const lines = component.render(120).map((line) => stripAnsi(line)); + const currentLine = lines.find((line) => line.includes('this is current')); + const otherLine = lines.find((line) => line.includes('not current')); + expect(currentLine).toContain('(current)'); + expect(otherLine).not.toContain('(current)'); + }); + + it('places the relative time on the same line as the title, not right-aligned', () => { + const now = new Date('2026-05-11T12:00:00.000Z').getTime(); + vi.spyOn(Date, 'now').mockReturnValue(now); + + const component = new SessionPickerComponent({ + sessions: [ + { + id: 'ses_inline_time', + title: 'Short title', + work_dir: '/tmp/project', + updated_at: now - 5 * 60 * 1000, + }, + ], + loading: false, + currentSessionId: 'ses_other', + colors: getColorPalette('dark'), + onSelect: vi.fn(), + onCancel: vi.fn(), + }); + + const lines = component.render(120).map((line) => stripAnsi(line)); + const headerLine = lines.find((line) => line.includes('Short title')); + expect(headerLine).toBeDefined(); + // Title and time sit side-by-side with only the small inline separator. + expect(headerLine).toMatch(/Short title\s{1,4}5m ago/); + // No long run of trailing spaces, i.e. not right-aligned. + expect(headerLine).not.toMatch(/Short title\s{8,}/); + }); + + it('prepends [imported] badge before the title for sessions migrated from kimi-cli', () => { + const now = new Date('2026-05-11T12:00:00.000Z').getTime(); + vi.spyOn(Date, 'now').mockReturnValue(now); + + const component = new SessionPickerComponent({ + sessions: [ + { + id: 'ses_imported', + title: 'Migrated session', + work_dir: '/tmp/project', + updated_at: now - 60 * 1000, + metadata: { imported_from_kimi_cli: true }, + }, + { + id: 'ses_native', + title: 'Fresh session', + work_dir: '/tmp/project', + updated_at: now - 60 * 1000, + }, + ], + loading: false, + currentSessionId: 'ses_other', + colors: getColorPalette('dark'), + onSelect: vi.fn(), + onCancel: vi.fn(), + }); + + const lines = component.render(120).map((line) => stripAnsi(line)); + const importedLine = lines.find((line) => line.includes('Migrated session')); + const nativeLine = lines.find((line) => line.includes('Fresh session')); + expect(importedLine).toContain('[imported] Migrated session'); + expect(nativeLine).not.toContain('[imported]'); + }); + + it('keeps every rendered line within the terminal width even for CJK content', () => { + const now = new Date('2026-05-11T12:00:00.000Z').getTime(); + vi.spyOn(Date, 'now').mockReturnValue(now); + + const component = new SessionPickerComponent({ + sessions: [ + { + id: 'ses_cjk_long_session_id_value', + title: '现在要重构一下 TUI 的 sessions 列表,要渲染几个字段,让 UI 更好看', + last_prompt: + '我们要渲染几个:sessionid title lastPrompt。工作目录,修改时间。需要重新设计下 UI。', + work_dir: '/Users/someone/Desktop/中文目录/very-long-project-folder-name', + updated_at: now - 5 * 60 * 1000, + }, + ], + loading: false, + currentSessionId: 'ses_cjk_long_session_id_value', + colors: getColorPalette('dark'), + onSelect: vi.fn(), + onCancel: vi.fn(), + }); + + for (const width of [40, 80, 120, 238]) { + const lines = component.render(width); + for (const line of lines) { + expect(visibleWidth(line)).toBeLessThanOrEqual(width); + } + } + }); +}); diff --git a/apps/kimi-code/test/tui/components/editor/custom-editor.test.ts b/apps/kimi-code/test/tui/components/editor/custom-editor.test.ts new file mode 100644 index 000000000..58469bf21 --- /dev/null +++ b/apps/kimi-code/test/tui/components/editor/custom-editor.test.ts @@ -0,0 +1,102 @@ +import type { + AutocompleteItem, + AutocompleteProvider, + AutocompleteSuggestions, + TUI, +} from '@earendil-works/pi-tui'; +import { describe, expect, it, vi } from 'vitest'; + +import { CustomEditor } from '#/tui/components/editor/custom-editor'; +import { getColorPalette } from '#/tui/theme/index'; + +function makeEditor(): CustomEditor { + const tui = { + requestRender: vi.fn(), + } as unknown as TUI; + return new CustomEditor(tui, { ...getColorPalette('dark') }); +} + +async function flushAutocomplete(): Promise<void> { + await Promise.resolve(); + await Promise.resolve(); +} + +function providerReturning(items: AutocompleteItem[]): AutocompleteProvider { + return { + getSuggestions: vi.fn(async () => ({ items, prefix: '/' })), + applyCompletion: vi.fn((lines, cursorLine, cursorCol) => ({ lines, cursorLine, cursorCol })), + }; +} + +describe('CustomEditor autocomplete Escape handling', () => { + it('escape closes a visible slash command menu without firing app-level escape', async () => { + const editor = makeEditor(); + const onEscape = vi.fn(); + editor.onEscape = onEscape; + editor.setAutocompleteProvider(providerReturning([{ value: 'help', label: 'help' }])); + + editor.handleInput('/'); + await flushAutocomplete(); + + expect(editor.isShowingAutocomplete()).toBe(true); + + editor.handleInput('\u001B'); + + expect(editor.isShowingAutocomplete()).toBe(false); + expect(onEscape).not.toHaveBeenCalled(); + }); + + it('escape cancels an in-flight slash command menu request', async () => { + const editor = makeEditor(); + const onEscape = vi.fn(); + let resolveSuggestions: (items: AutocompleteItem[]) => void = () => {}; + const provider: AutocompleteProvider = { + getSuggestions: vi.fn( + () => + new Promise<AutocompleteSuggestions | null>((resolve) => { + resolveSuggestions = (items) =>{ resolve({ items, prefix: '/' }); }; + }), + ), + applyCompletion: vi.fn((lines, cursorLine, cursorCol) => ({ lines, cursorLine, cursorCol })), + }; + editor.onEscape = onEscape; + editor.setAutocompleteProvider(provider); + + editor.handleInput('/'); + await flushAutocomplete(); + editor.handleInput('\u001B'); + resolveSuggestions([{ value: 'help', label: 'help' }]); + await flushAutocomplete(); + + expect(editor.isShowingAutocomplete()).toBe(false); + expect(onEscape).not.toHaveBeenCalled(); + }); +}); + +describe('CustomEditor Kitty key release handling', () => { + it('ignores Kitty key release events instead of inserting their CSI-u payload', () => { + const editor = makeEditor(); + + editor.handleInput('\u001B[47;1:3u'); + editor.handleInput('\u001B[110;1:3u'); + + expect(editor.getText()).toBe(''); + }); +}); + +describe('CustomEditor shortcut telemetry hooks', () => { + it('reports newline and undo shortcuts before delegating to the base editor', () => { + const editor = makeEditor(); + const onInsertNewline = vi.fn(); + const onUndo = vi.fn(); + editor.onInsertNewline = onInsertNewline; + editor.onUndo = onUndo; + + editor.handleInput('a'); + editor.handleInput('\n'); + editor.handleInput('\u001F'); + + expect(onInsertNewline).toHaveBeenCalledOnce(); + expect(onUndo).toHaveBeenCalledOnce(); + }); +}); diff --git a/apps/kimi-code/test/tui/components/editor/file-mention-provider.test.ts b/apps/kimi-code/test/tui/components/editor/file-mention-provider.test.ts new file mode 100644 index 000000000..496aa4ce3 --- /dev/null +++ b/apps/kimi-code/test/tui/components/editor/file-mention-provider.test.ts @@ -0,0 +1,215 @@ +import { describe, it, expect } from 'vitest'; + +import { FileMentionProvider } from '#/tui/components/editor/file-mention-provider'; +import type { GitLsFilesCache, GitSnapshot } from '#/utils/git/git-ls-files'; + +function stubGitCache( + files: string[] | null, + opts: { mtimes?: Record<string, number>; recency?: string[] } = {}, +): GitLsFilesCache { + const snapshot: GitSnapshot | null = + files === null + ? null + : { + files, + mtimeByPath: new Map(Object.entries(opts.mtimes ?? {})), + recencyOrder: new Map((opts.recency ?? []).map((p, i) => [p, i])), + }; + return { + isGitRepo: () => files !== null, + getSnapshot: () => snapshot, + list: () => (files === null ? null : files.slice()), + }; +} + +function ctrl(): AbortSignal { + return new AbortController().signal; +} + +const NO_FD = null; + +describe('FileMentionProvider — @ prefix detection + git-backed suggestions', () => { + it('returns null when there is no @ mention and the dir is empty', async () => { + const provider = new FileMentionProvider([], '/nonexistent', NO_FD, stubGitCache([])); + const result = await provider.getSuggestions(['hello world'], 0, 11, { signal: ctrl() }); + // pi-tui inner will also return null for non-path plain text. + expect(result).toBeNull(); + }); + + it('bare @ surfaces the first files as a starting list', async () => { + const files = ['a.ts', 'b.ts', 'src/c.ts']; + const provider = new FileMentionProvider([], '/repo', NO_FD, stubGitCache(files)); + const result = await provider.getSuggestions(['@'], 0, 1, { signal: ctrl() }); + expect(result).not.toBeNull(); + expect(result!.prefix).toBe('@'); + expect(result!.items.map((i) => i.value)).toEqual(['@a.ts', '@b.ts', '@src/c.ts']); + }); + + it('ranks basename-prefix > substring > fuzzy', async () => { + const files = [ + 'docs/readme.md', // basename starts with "read" + 'src/readability.ts', // basename starts with "read" + 'lib/threader.ts', // basename contains "read" (substring) + ]; + const provider = new FileMentionProvider([], '/repo', NO_FD, stubGitCache(files)); + const result = await provider.getSuggestions(['@read'], 0, 5, { signal: ctrl() }); + expect(result).not.toBeNull(); + const values = result!.items.map((i) => i.value); + const readabilityIdx = values.indexOf('@src/readability.ts'); + const readmeIdx = values.indexOf('@docs/readme.md'); + const threadIdx = values.indexOf('@lib/threader.ts'); + // Both starts-with entries rank ahead of the substring entry. + expect(readabilityIdx).toBeGreaterThanOrEqual(0); + expect(readmeIdx).toBeGreaterThanOrEqual(0); + expect(threadIdx).toBeGreaterThan(Math.max(readabilityIdx, readmeIdx)); + }); + + it('empty query prefers recently-edited files over everything else', async () => { + const files = ['a.ts', 'b.ts', 'c.ts', 'd.ts', 'e.ts']; + const provider = new FileMentionProvider( + [], + '/repo', + NO_FD, + stubGitCache(files, { + recency: ['d.ts', 'b.ts'], // d most recent, then b + }), + ); + const result = await provider.getSuggestions(['@'], 0, 1, { signal: ctrl() }); + const values = result!.items.map((i) => i.value); + // Recency layer fills first, then alphabetical layer. + expect(values.slice(0, 2)).toEqual(['@d.ts', '@b.ts']); + expect(values.slice(2)).toEqual(['@a.ts', '@c.ts', '@e.ts']); + }); + + it('empty query falls back to mtime when no recency info', async () => { + const files = ['old.ts', 'newer.ts', 'newest.ts']; + const provider = new FileMentionProvider( + [], + '/repo', + NO_FD, + stubGitCache(files, { + mtimes: { 'old.ts': 1000, 'newer.ts': 2000, 'newest.ts': 3000 }, + }), + ); + const result = await provider.getSuggestions(['@'], 0, 1, { signal: ctrl() }); + const values = result!.items.map((i) => i.value); + expect(values).toEqual(['@newest.ts', '@newer.ts', '@old.ts']); + }); + + it('empty query falls back to basename alphabetical when no signals', async () => { + const files = ['zoo/apple.ts', 'banana.ts', 'cherry.ts']; + const provider = new FileMentionProvider([], '/repo', NO_FD, stubGitCache(files)); + const result = await provider.getSuggestions(['@'], 0, 1, { signal: ctrl() }); + const values = result!.items.map((i) => i.value); + // Sorted by basename alphabetical: apple, banana, cherry + expect(values).toEqual(['@zoo/apple.ts', '@banana.ts', '@cherry.ts']); + }); + + it('hides dot-dir files from the default list', async () => { + const files = ['.agents/skills/x.md', '.github/workflows/y.yml', 'src/a.ts', 'README.md']; + const provider = new FileMentionProvider([], '/repo', NO_FD, stubGitCache(files)); + const result = await provider.getSuggestions(['@'], 0, 1, { signal: ctrl() }); + const values = result!.items.map((i) => i.value); + expect(values).toContain('@README.md'); + expect(values).toContain('@src/a.ts'); + expect(values).not.toContain('@.agents/skills/x.md'); + expect(values).not.toContain('@.github/workflows/y.yml'); + }); + + it('shows dot-dir files when the query explicitly opts in (starts with .)', async () => { + const files = ['.agents/skills/foo.md', '.agents/README.md', 'src/a.ts']; + const provider = new FileMentionProvider([], '/repo', NO_FD, stubGitCache(files)); + const result = await provider.getSuggestions(['@.agents'], 0, 8, { signal: ctrl() }); + expect(result).not.toBeNull(); + const values = result!.items.map((i) => i.value); + expect(values.some((v) => v.startsWith('@.agents/'))).toBe(true); + }); + + it('within a category, recency ranks higher than mtime', async () => { + const files = ['older-recent.ts', 'never-touched-but-new.ts']; + const provider = new FileMentionProvider( + [], + '/repo', + NO_FD, + stubGitCache(files, { + mtimes: { 'older-recent.ts': 1000, 'never-touched-but-new.ts': 9999 }, + recency: ['older-recent.ts'], + }), + ); + // Query hits both via fuzzy (they both contain letters from 'nr'). + // Use basename-startswith shared prefix to force cat 0 tie. + const tied = ['aa-recent.ts', 'aa-newer.ts']; + const provider2 = new FileMentionProvider( + [], + '/repo', + NO_FD, + stubGitCache(tied, { + mtimes: { 'aa-recent.ts': 1000, 'aa-newer.ts': 9999 }, + recency: ['aa-recent.ts'], + }), + ); + const result = await provider2.getSuggestions(['@aa'], 0, 3, { signal: ctrl() }); + const values = result!.items.map((i) => i.value); + expect(values[0]).toBe('@aa-recent.ts'); + expect(values[1]).toBe('@aa-newer.ts'); + void provider; // silence unused + }); + + it('scoped @src/ limits to files under src/', async () => { + const files = ['src/a.ts', 'src/b.ts', 'lib/c.ts']; + const provider = new FileMentionProvider([], '/repo', NO_FD, stubGitCache(files)); + const result = await provider.getSuggestions(['@src/'], 0, 5, { signal: ctrl() }); + expect(result).not.toBeNull(); + const values = result!.items.map((i) => i.value); + // Empty query after src/ shouldn't match lib/c.ts via basename ranking; + // but our git-backed path doesn't apply scope directly — the query is + // "src/" and we fall back to fuzzy on the raw path. Both src/ paths + // contain "src/" and rank higher than lib/c.ts. + expect(values[0]).toMatch(/^@src\//); + expect(values[1]).toMatch(/^@src\//); + }); + + it('does not trigger the @ branch when @ is preceded by a non-delimiter', async () => { + // "email@example" — @ is not at a token boundary; our extractAtPrefix + // returns null and the inner provider handles the text. + const provider = new FileMentionProvider([], '/nonexistent', NO_FD, stubGitCache(['a.ts'])); + const result = await provider.getSuggestions(['email@example'], 0, 13, { signal: ctrl() }); + // Inner provider returns null for this kind of free text. + expect(result).toBeNull(); + }); + + it('handles multiple @ mentions on one line by completing the last one', async () => { + const files = ['alpha.ts', 'beta.ts']; + const provider = new FileMentionProvider([], '/repo', NO_FD, stubGitCache(files)); + // "read @alpha.ts and @bet" — cursor at end, inside the second @. + const line = 'read @alpha.ts and @bet'; + const result = await provider.getSuggestions([line], 0, line.length, { signal: ctrl() }); + expect(result).not.toBeNull(); + expect(result!.prefix).toBe('@bet'); + const values = result!.items.map((i) => i.value); + expect(values).toContain('@beta.ts'); + expect(values).not.toContain('@alpha.ts'); + }); + + it('applyCompletion delegates to inner (replaces prefix with value)', () => { + const provider = new FileMentionProvider([], '/repo', NO_FD, stubGitCache(['src/a.ts'])); + const out = provider.applyCompletion( + ['hey @src'], + 0, + 8, // cursor just after @src + { value: '@src/a.ts', label: 'a.ts' }, + '@src', + ); + // pi-tui appends a trailing space after a non-directory completion + // so the user can type the next token immediately. + expect(out.lines[0]).toBe('hey @src/a.ts '); + }); + + it('falls through to inner when the git cache is null (non-git dir)', async () => { + const provider = new FileMentionProvider([], '/nonexistent', NO_FD, stubGitCache(null)); + // No files visible via readdir either, but it shouldn't throw. + const result = await provider.getSuggestions(['@foo'], 0, 4, { signal: ctrl() }); + // pi-tui readdir on a nonexistent basePath returns [] → null. + expect(result).toBeNull(); + }); +}); diff --git a/apps/kimi-code/test/tui/components/editor/normalize-caps-locked-ctrl.test.ts b/apps/kimi-code/test/tui/components/editor/normalize-caps-locked-ctrl.test.ts new file mode 100644 index 000000000..2d2151f72 --- /dev/null +++ b/apps/kimi-code/test/tui/components/editor/normalize-caps-locked-ctrl.test.ts @@ -0,0 +1,76 @@ +import { describe, it, expect } from 'vitest'; + +import { normalizeCapsLockedCtrl } from '#/tui/components/editor/custom-editor'; + +// Kitty keyboard protocol emits `ESC[<codepoint>;<modifier+1>[:eventType]u`. +// Modifier mask bits: shift=1, alt=2, ctrl=4, super=8, hyper=16, meta=32, +// caps_lock=64, num_lock=128. The pi-tui bug this helper works around is +// that when caps_lock is on, terminals report the codepoint of ctrl+letter +// as the *uppercase* ASCII letter (e.g. 68 = 'D' instead of 100 = 'd'), +// which pi-tui's matcher compares literally and fails. We rewrite the +// sequence back to the unlocked form before dispatching. + +describe('normalizeCapsLockedCtrl', () => { + it('rewrites ctrl+D reported with caps_lock back to ctrl+d', () => { + // ctrl(4) + caps_lock(64) = 68, reported = 69 + expect(normalizeCapsLockedCtrl('\u001B[68;69u')).toBe('\u001B[100;5u'); + }); + + it('rewrites ctrl+C / ctrl+O / ctrl+S under caps_lock', () => { + expect(normalizeCapsLockedCtrl('\u001B[67;69u')).toBe('\u001B[99;5u'); + expect(normalizeCapsLockedCtrl('\u001B[79;69u')).toBe('\u001B[111;5u'); + expect(normalizeCapsLockedCtrl('\u001B[83;69u')).toBe('\u001B[115;5u'); + }); + + it('preserves the trailing event-type sub-parameter', () => { + // `:3u` = key release event + expect(normalizeCapsLockedCtrl('\u001B[68;69:3u')).toBe('\u001B[100;5:3u'); + // multiple sub-parameters still survive + expect(normalizeCapsLockedCtrl('\u001B[68;69:3:1u')).toBe('\u001B[100;5:3:1u'); + }); + + it('leaves plain uppercase typed with only caps_lock alone', () => { + // User intentionally typing an uppercase letter — do not downgrade. + // caps_lock(64) alone = 64, reported = 65 + expect(normalizeCapsLockedCtrl('\u001B[68;65u')).toBe('\u001B[68;65u'); + }); + + it('leaves ctrl+letter without caps_lock alone', () => { + // ctrl(4) alone = 4, reported = 5 + expect(normalizeCapsLockedCtrl('\u001B[68;5u')).toBe('\u001B[68;5u'); + expect(normalizeCapsLockedCtrl('\u001B[100;5u')).toBe('\u001B[100;5u'); + }); + + it('leaves ctrl+shift+letter with caps_lock alone', () => { + // shift(1) + ctrl(4) + caps_lock(64) = 69, reported = 70. + // User explicitly wants the shifted form — don't rewrite. + expect(normalizeCapsLockedCtrl('\u001B[68;70u')).toBe('\u001B[68;70u'); + }); + + it('rewrites ctrl+alt+letter under caps_lock (shift not involved)', () => { + // alt(2) + ctrl(4) + caps_lock(64) = 70, reported = 71 + expect(normalizeCapsLockedCtrl('\u001B[68;71u')).toBe('\u001B[100;7u'); + }); + + it('ignores codepoints outside A-Z even with ctrl+caps_lock', () => { + // Digit '1' (49) — uppercase mapping would be nonsense. + expect(normalizeCapsLockedCtrl('\u001B[49;69u')).toBe('\u001B[49;69u'); + // Symbol '[' (91) — just past 'Z'. + expect(normalizeCapsLockedCtrl('\u001B[91;69u')).toBe('\u001B[91;69u'); + // '@' (64) — just before 'A'. + expect(normalizeCapsLockedCtrl('\u001B[64;69u')).toBe('\u001B[64;69u'); + }); + + it('passes through non-CSI-u input unchanged', () => { + // Plain printable character + expect(normalizeCapsLockedCtrl('H')).toBe('H'); + // Legacy ctrl+d control byte + expect(normalizeCapsLockedCtrl('\u0004')).toBe('\u0004'); + // Legacy ctrl+c control byte + expect(normalizeCapsLockedCtrl('\u0003')).toBe('\u0003'); + // Arrow key CSI (not CSI-u) + expect(normalizeCapsLockedCtrl('\u001B[A')).toBe('\u001B[A'); + // Empty string + expect(normalizeCapsLockedCtrl('')).toBe(''); + }); +}); diff --git a/apps/kimi-code/test/tui/components/editor/prompt-symbol.test.ts b/apps/kimi-code/test/tui/components/editor/prompt-symbol.test.ts new file mode 100644 index 000000000..c7537551d --- /dev/null +++ b/apps/kimi-code/test/tui/components/editor/prompt-symbol.test.ts @@ -0,0 +1,37 @@ +import { describe, it, expect } from 'vitest'; + +import { injectPromptSymbol } from '#/tui/components/editor/custom-editor'; + +describe('injectPromptSymbol', () => { + it('places a "> " prompt at columns 2-3 (col 0 = border, col 1 = single-space gap)', () => { + expect(injectPromptSymbol(' hello world')).toBe(' > hello world'); + }); + + it('preserves overall visible width (prompt occupies padding slots)', () => { + const original = ' hello '; + expect(injectPromptSymbol(original)).toHaveLength(original.length); + }); + + it('preserves trailing ANSI escapes (e.g. cursor inverse marker)', () => { + const line = ' [7m [0m '; + const out = injectPromptSymbol(line); + expect(out).toBe(' > [7m [0m '); + }); + + it('emits no SGR (terminal default foreground renders the symbol)', () => { + const out = injectPromptSymbol(' hello'); + expect(out).not.toMatch(/\[/); + }); + + it('returns undefined when the line is too short', () => { + expect(injectPromptSymbol(' ')).toBeUndefined(); + expect(injectPromptSymbol('')).toBeUndefined(); + }); + + it('returns undefined when the leading four characters are not all spaces', () => { + expect(injectPromptSymbol('x hello')).toBeUndefined(); + expect(injectPromptSymbol(' x hello')).toBeUndefined(); + expect(injectPromptSymbol(' x hello')).toBeUndefined(); + expect(injectPromptSymbol(' xhello')).toBeUndefined(); + }); +}); diff --git a/apps/kimi-code/test/tui/components/editor/side-borders.test.ts b/apps/kimi-code/test/tui/components/editor/side-borders.test.ts new file mode 100644 index 000000000..11801aa71 --- /dev/null +++ b/apps/kimi-code/test/tui/components/editor/side-borders.test.ts @@ -0,0 +1,70 @@ +import { describe, it, expect } from 'vitest'; + +import { wrapWithSideBorders } from '#/tui/components/editor/custom-editor'; + +const id = (s: string): string => s; + +describe('wrapWithSideBorders', () => { + it('turns the top horizontal border into a ╭…╮ run', () => { + const out = wrapWithSideBorders(['──────────', ' hi ', '──────────'], id); + expect(out[0]).toBe('╭────────╮'); + }); + + it('turns the bottom horizontal border into a ╰…╯ run', () => { + const out = wrapWithSideBorders(['──────────', ' hi ', '──────────'], id); + expect(out[2]).toBe('╰────────╯'); + }); + + it('wraps content lines with │ … │, replacing the outer padding columns', () => { + // ' hi ' is 10 chars; first/last spaces become │, middle keeps its length + const out = wrapWithSideBorders(['──────────', ' hi ', '──────────'], id); + expect(out[1]).toBe('│ hi │'); + expect(out[1]).toHaveLength(' hi '.length); + }); + + it('treats scroll-indicator lines (── ↑ N more ──) as horizontal borders', () => { + const top = '─── ↑ 5 more ────'; + const bot = '─── ↓ 3 more ────'; + const out = wrapWithSideBorders([top, ' x ', bot], id); + expect(out[0]?.startsWith('╭')).toBe(true); + expect(out[0]?.endsWith('╮')).toBe(true); + expect(out[2]?.startsWith('╰')).toBe(true); + expect(out[2]?.endsWith('╯')).toBe(true); + // body of the indicator is preserved + expect(out[0]).toContain('↑ 5 more'); + expect(out[2]).toContain('↓ 3 more'); + }); + + it('handles autocomplete rows that come after the bottom border (still wrap with │)', () => { + const lines = [ + '──────────', + ' q ', + '──────────', + ' item1 ', + ' item2 ', + ]; + const out = wrapWithSideBorders(lines, id); + expect(out[0]?.startsWith('╭')).toBe(true); + expect(out[2]?.startsWith('╰')).toBe(true); + // ' item1 ' → first/last spaces become │ + expect(out[3]).toBe('│ item1 │'); + expect(out[4]).toBe('│ item2 │'); + }); + + it('paints corners and side borders through the provided borderColor', () => { + const paint = (s: string): string => `<${s}>`; + const out = wrapWithSideBorders(['─────', ' x ', '─────'], paint); + // corners and horizontals routed through paint + expect(out[0]).toBe('<╭───╮>'); + expect(out[2]).toBe('<╰───╯>'); + // side bars on content lines also painted + expect(out[1]).toBe('<│> x <│>'); + }); + + it('does not clobber non-space content sitting in the outer column (e.g. cursor overflow)', () => { + // last column holds a non-space character — leave it as-is rather than overwrite with │ + const out = wrapWithSideBorders(['─────', ' abc', '─────'], id); + // first column was a space → replaced with │; last column was 'c' → kept + expect(out[1]).toBe('│ abc'); + }); +}); diff --git a/apps/kimi-code/test/tui/components/editor/slash-highlight.test.ts b/apps/kimi-code/test/tui/components/editor/slash-highlight.test.ts new file mode 100644 index 000000000..04826e604 --- /dev/null +++ b/apps/kimi-code/test/tui/components/editor/slash-highlight.test.ts @@ -0,0 +1,66 @@ +import chalk from 'chalk'; +import { describe, it, expect, beforeAll } from 'vitest'; + +import { highlightFirstSlashToken } from '#/tui/components/editor/custom-editor'; + +beforeAll(() => { + // Vitest runs without a TTY so chalk auto-detects colour support as + // 0 (no colours). Force full colour so the highlighter actually + // emits the SGR escapes we're asserting on. + chalk.level = 3; +}); + +function strip(s: string): string { + return s.replaceAll(/\u001B\[[0-9;]*m/g, ''); +} + +describe('highlightFirstSlashToken', () => { + it('colours /cmd when line starts with a slash', () => { + const out = highlightFirstSlashToken(' /help rest of input', '#ff00aa'); + expect(out).toBeDefined(); + // Visible text unchanged + expect(strip(out!)).toBe(' /help rest of input'); + // SGR escapes surround /help + expect(out!).toMatch(/\u001B\[.*m\/help\u001B\[/); + }); + + it('returns undefined when the line has no slash', () => { + expect(highlightFirstSlashToken('hello world', '#ff00aa')).toBeUndefined(); + }); + + it('returns undefined when slash is not at the leading position', () => { + expect(highlightFirstSlashToken(' hello /not-cmd', '#ff00aa')).toBeUndefined(); + }); + + it('returns undefined for path-like slash tokens', () => { + expect(highlightFirstSlashToken('/user/desktop/ foo', '#ff00aa')).toBeUndefined(); + }); + + it('handles /token at end of line (no trailing whitespace)', () => { + const out = highlightFirstSlashToken('/exit', '#ff00aa'); + expect(out).toBeDefined(); + expect(strip(out!)).toBe('/exit'); + }); + + it('passes through pre-existing ANSI (e.g. cursor inverse) in the tail', () => { + // Simulate pi-tui Editor inserting an inverse-video cursor marker + // somewhere after the slash token. + const line = '/help x\u001B[7m \u001B[0m'; + const out = highlightFirstSlashToken(line, '#ff00aa'); + expect(out).toBeDefined(); + // Stripped visible content unchanged + expect(strip(out!)).toBe(strip(line)); + // Inverse cursor SGR is still present afterwards + expect(out!.includes('\u001B[7m')).toBe(true); + }); + + it('only paints the first token, not other slashes further along', () => { + const out = highlightFirstSlashToken('/a /b', '#ff00aa'); + expect(out).toBeDefined(); + // Count the SGR opens — should be exactly one for /a. + const opens = (out!.match(/\u001B\[[0-9;]+m/g) ?? []).length; + expect(opens).toBeGreaterThanOrEqual(2); // chalk hex+bold open and reset(s) + // /b should remain plain — the substring " /b" exists verbatim. + expect(out!).toContain(' /b'); + }); +}); diff --git a/apps/kimi-code/test/tui/components/media/code-highlight.test.ts b/apps/kimi-code/test/tui/components/media/code-highlight.test.ts new file mode 100644 index 000000000..b6df1cf22 --- /dev/null +++ b/apps/kimi-code/test/tui/components/media/code-highlight.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from 'vitest'; + +import { highlightLines, langFromPath } from '#/tui/components/media/code-highlight'; + +import { captureProcessWrite } from '../../../helpers/process'; + +describe('code-highlight', () => { + it('maps known file extensions to supported highlight languages', () => { + expect(langFromPath('src/foo.ts')).toBe('typescript'); + expect(langFromPath('src/foo.TS')).toBe('typescript'); + }); + + it('treats unsupported file extensions as plain text', () => { + expect(langFromPath('src/foo.abcxyz')).toBeUndefined(); + }); + + it('does not call cli-highlight for unsupported languages', () => { + const stderr = captureProcessWrite('stderr'); + try { + expect(highlightLines('hello\nworld', 'abcxyz')).toEqual(['hello', 'world']); + expect(stderr.text()).not.toContain('Could not find the language'); + } finally { + stderr.restore(); + } + }); +}); diff --git a/apps/kimi-code/test/tui/components/media/diff-preview.test.ts b/apps/kimi-code/test/tui/components/media/diff-preview.test.ts new file mode 100644 index 000000000..f2a43aabd --- /dev/null +++ b/apps/kimi-code/test/tui/components/media/diff-preview.test.ts @@ -0,0 +1,180 @@ +import { describe, expect, it } from 'vitest'; + +import { + computeDiffLines, + renderDiffLines, + renderDiffLinesClustered, +} from '#/tui/components/media/diff-preview'; +import { getColorPalette } from '#/tui/theme/colors'; + +const COLORS = getColorPalette('dark'); + +function stripAnsi(text: string): string { + return text.replaceAll(/\u001B\[[0-9;]*m/g, ''); +} + +describe('computeDiffLines', () => { + it('renders a complete diff when isIncomplete is false', () => { + const lines = computeDiffLines('A\nB\nC\nD', 'A\nB', 1, 1, false); + const kinds = lines.map((l) => l.kind); + expect(kinds).toEqual(['context', 'context', 'delete', 'delete']); + }); + + it('suppresses trailing deletes when isIncomplete is true', () => { + const lines = computeDiffLines('A\nB\nC\nD', 'A\nB', 1, 1, true); + const kinds = lines.map((l) => l.kind); + expect(kinds).toEqual(['context', 'context']); + }); + + it('suppresses all deletes when everything would be deleted and incomplete', () => { + const lines = computeDiffLines('A\nB\nC', '', 1, 1, true); + expect(lines).toEqual([]); + }); + + it('keeps trailing adds when isIncomplete is true', () => { + const lines = computeDiffLines('A\nB\nC', 'A\nB\nX', 1, 1, true); + const kinds = lines.map((l) => l.kind); + expect(kinds).toEqual(['context', 'context', 'delete', 'add']); + }); + + it('keeps internal delete blocks that are not trailing', () => { + const lines = computeDiffLines('A\nB\nC\nD', 'A\nC', 1, 1, true); + const kinds = lines.map((l) => l.kind); + expect(kinds).toEqual(['context', 'delete', 'context']); + }); +}); + +describe('renderDiffLines', () => { + it('does not show removed count for suppressed trailing deletes', () => { + const output = renderDiffLines('A\nB\nC\nD', 'A\nB', 'test.ts', COLORS, true, 1, 1); + const text = stripAnsi(output.join('\n')); + expect(text).toContain('test.ts'); + expect(text).not.toContain('-2'); + expect(text).not.toContain('C'); + expect(text).not.toContain('D'); + // When trailing deletes are suppressed, only context lines remain; + // renderDiffLines only emits changed lines, so the body is empty. + expect(text).not.toContain('A'); + expect(text).not.toContain('B'); + }); + + it('shows removed count for complete diffs', () => { + const output = renderDiffLines('A\nB\nC\nD', 'A\nB', 'test.ts', COLORS, false, 1, 1); + const text = stripAnsi(output.join('\n')); + expect(text).toContain('-2'); + expect(text).toContain('C'); + expect(text).toContain('D'); + }); +}); + +describe('renderDiffLinesClustered', () => { + it('renders header with file path and counts', () => { + const out = renderDiffLinesClustered('A\nB\nC', 'A\nX\nC', 'foo.ts', COLORS); + const text = stripAnsi(out[0]!); + expect(text).toContain('+1'); + expect(text).toContain('-1'); + expect(text).toContain('foo.ts'); + }); + + it('returns header only when there are no changes', () => { + const out = renderDiffLinesClustered('A\nB', 'A\nB', 'foo.ts', COLORS); + expect(out).toHaveLength(1); + expect(stripAnsi(out[0]!)).toContain('foo.ts'); + }); + + it('shows context lines around a single change cluster', () => { + // Five lines, change line 3 only — context is 1 each side. + const oldText = ['L1', 'L2', 'L3', 'L4', 'L5'].join('\n'); + const newText = ['L1', 'L2', 'L3X', 'L4', 'L5'].join('\n'); + const text = stripAnsi( + renderDiffLinesClustered(oldText, newText, 'f.ts', COLORS, { contextLines: 1 }).join('\n'), + ); + expect(text).toContain('L2'); + expect(text).toContain('L3'); + expect(text).toContain('L3X'); + expect(text).toContain('L4'); + expect(text).not.toContain('L1'); + expect(text).not.toContain('L5'); + }); + + it('elides unchanged middle between two clusters with a separator', () => { + const oldLines: string[] = []; + for (let i = 1; i <= 30; i++) oldLines.push(`L${String(i)}`); + const newLines = oldLines.slice(); + newLines[1] = 'L2X'; // change near top + newLines[28] = 'L29X'; // change near bottom + const text = stripAnsi( + renderDiffLinesClustered(oldLines.join('\n'), newLines.join('\n'), 'f.ts', COLORS, { + contextLines: 2, + }).join('\n'), + ); + expect(text).toContain('L2X'); + expect(text).toContain('L29X'); + expect(text).toMatch(/… \d+ unchanged lines? …/); + // Middle untouched lines (e.g. L15) should not appear. + expect(text).not.toContain('L15'); + }); + + it('merges nearby change clusters when the gap is within context window', () => { + const oldLines: string[] = []; + for (let i = 1; i <= 10; i++) oldLines.push(`L${String(i)}`); + const newLines = oldLines.slice(); + newLines[2] = 'L3X'; + newLines[5] = 'L6X'; // gap of 2 lines between change indices 2 and 5 → merges with contextLines=2 (mergeGap=4) + const out = renderDiffLinesClustered(oldLines.join('\n'), newLines.join('\n'), 'f.ts', COLORS, { + contextLines: 2, + }).join('\n'); + const text = stripAnsi(out); + expect(text).not.toMatch(/unchanged lines? …/); + expect(text).toContain('L3X'); + expect(text).toContain('L6X'); + }); + + it('emits a partial body even when a single cluster exceeds maxLines', () => { + // Worst case from prod: 100 lines fully replaced inline → single huge + // cluster of ~200 diff entries. With maxLines=10 the renderer must + // still emit ~10 leading body rows, not just the truncation footer. + const oldLines: string[] = []; + const newLines: string[] = []; + for (let i = 1; i <= 100; i++) { + oldLines.push(`old${String(i)}`); + newLines.push(`new${String(i)}`); + } + const out = renderDiffLinesClustered( + oldLines.join('\n'), + newLines.join('\n'), + 'big.ts', + COLORS, + { + contextLines: 3, + maxLines: 10, + }, + ); + // header + 10 body rows + truncation footer + expect(out.length).toBe(12); + const text = stripAnsi(out.join('\n')); + expect(text).toContain('+100'); + expect(text).toContain('-100'); + expect(text).toMatch(/old\d+|new\d+/); + expect(text).toContain('ctrl+o to expand'); + }); + + it('truncates at cluster boundary and appends the ctrl+o footer when maxLines is set', () => { + const oldLines: string[] = []; + for (let i = 1; i <= 50; i++) oldLines.push(`L${String(i)}`); + const newLines = oldLines.slice(); + newLines[1] = 'L2X'; + newLines[20] = 'L21X'; + newLines[40] = 'L41X'; + const text = stripAnsi( + renderDiffLinesClustered(oldLines.join('\n'), newLines.join('\n'), 'f.ts', COLORS, { + contextLines: 2, + maxLines: 6, + }).join('\n'), + ); + expect(text).toContain('L2X'); + expect(text).toMatch(/more change/); + expect(text).toContain('ctrl+o to expand'); + expect(text).not.toContain('L41X'); + }); +}); diff --git a/apps/kimi-code/test/tui/components/messages/assistant-message.test.ts b/apps/kimi-code/test/tui/components/messages/assistant-message.test.ts new file mode 100644 index 000000000..47d0836a9 --- /dev/null +++ b/apps/kimi-code/test/tui/components/messages/assistant-message.test.ts @@ -0,0 +1,53 @@ +import { visibleWidth } from '@earendil-works/pi-tui'; +import { describe, expect, it } from 'vitest'; + +import { AssistantMessageComponent } from '#/tui/components/messages/assistant-message'; +import { STATUS_BULLET } from '#/tui/constant/symbols'; +import { darkColors } from '#/tui/theme/colors'; +import { createMarkdownTheme } from '#/tui/theme/pi-tui-theme'; + +import { captureProcessWrite } from '../../../helpers/process'; + +function strip(text: string): string { + return text.replaceAll(/\u001B\[[0-9;]*m/g, ''); +} + +describe('AssistantMessageComponent', () => { + it('defines the shared status bullet as a stable non-emoji glyph', () => { + expect(STATUS_BULLET).toBe('● '); + expect(visibleWidth(STATUS_BULLET)).toBe(2); + }); + + it('uses the stable status bullet without stealing content width', () => { + const component = new AssistantMessageComponent(createMarkdownTheme(darkColors), darkColors); + + component.updateContent('abcdef'); + + const lines = component.render(8).map(strip); + expect(lines).toEqual(['', `${STATUS_BULLET}abcdef`]); + expect(visibleWidth(lines[1] ?? '')).toBe(8); + }); + + it('renders unknown markdown fence languages as plain text without stderr noise', () => { + const stderr = captureProcessWrite('stderr'); + try { + const theme = createMarkdownTheme(darkColors); + expect(theme.highlightCode?.('hello\nworld', 'abcxyz')).toEqual(['hello', 'world']); + expect(stderr.text()).not.toContain('Could not find the language'); + } finally { + stderr.restore(); + } + }); + + it('preserves literal hook result XML in normal assistant text', () => { + const component = new AssistantMessageComponent(createMarkdownTheme(darkColors), darkColors); + + component.updateContent('<hook_result hook_event="UserPromptSubmit">\n{}\n</hook_result>'); + + const text = component.render(80).map(strip).join('\n'); + expect(text).toContain('<hook_result hook_event="UserPromptSubmit">'); + expect(text).toContain('{}'); + expect(text).toContain('</hook_result>'); + expect(text).not.toContain('UserPromptSubmit hook'); + }); +}); diff --git a/apps/kimi-code/test/tui/components/messages/background-agent-status.test.ts b/apps/kimi-code/test/tui/components/messages/background-agent-status.test.ts new file mode 100644 index 000000000..89def6690 --- /dev/null +++ b/apps/kimi-code/test/tui/components/messages/background-agent-status.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from 'vitest'; + +import { BackgroundAgentStatusComponent } from '#/tui/components/messages/background-agent-status'; +import { STATUS_BULLET } from '#/tui/constant/symbols'; +import { darkColors } from '#/tui/theme/colors'; + +function strip(text: string): string { + return text.replaceAll(/\u001B\[[0-9;]*m/g, ''); +} + +describe('BackgroundAgentStatusComponent', () => { + it('renders started/completed with the shared bullet and failed with a red x marker', () => { + const started = new BackgroundAgentStatusComponent( + { + phase: 'started', + headline: 'explore agent started in background', + detail: 'Explore project structure', + }, + darkColors, + ); + const completed = new BackgroundAgentStatusComponent( + { + phase: 'completed', + headline: 'explore agent completed in background', + detail: 'Explore project structure', + }, + darkColors, + ); + const failed = new BackgroundAgentStatusComponent( + { + phase: 'failed', + headline: 'explore agent failed in background', + detail: 'Explore project structure · boom', + }, + darkColors, + ); + + const startedLines = started.render(120).map((line) => strip(line).trimEnd()); + const completedLines = completed.render(120).map((line) => strip(line).trimEnd()); + const failedLines = failed.render(120).map((line) => strip(line).trimEnd()); + + expect(startedLines[0]).toBe(''); + expect(completedLines[0]).toBe(''); + expect(failedLines[0]).toBe(''); + + expect(startedLines[1]).toBe( + `${STATUS_BULLET}explore agent started in background (Explore project structure)`, + ); + expect(completedLines[1]).toBe( + `${STATUS_BULLET}explore agent completed in background (Explore project structure)`, + ); + expect(failedLines[1]).toBe( + '✗ explore agent failed in background (Explore project structure · boom)', + ); + }); +}); diff --git a/apps/kimi-code/test/tui/components/messages/notice.test.ts b/apps/kimi-code/test/tui/components/messages/notice.test.ts new file mode 100644 index 000000000..a7a9f05e3 --- /dev/null +++ b/apps/kimi-code/test/tui/components/messages/notice.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from 'vitest'; + +import { NoticeMessageComponent } from '#/tui/components/messages/status-message'; +import { darkColors } from '#/tui/theme/colors'; + +function strip(text: string): string { + return text.replaceAll(/\u001B\[[0-9;]*m/g, ''); +} + +describe('NoticeComponent', () => { + it('renders top and bottom spacing around the notice copy', () => { + const component = new NoticeMessageComponent( + 'Plan mode: ON', + 'Plan will be created here: /tmp/plans/test-plan.md', + darkColors, + ); + + const lines = component.render(120).map((line) => strip(line)); + expect(lines[0]).toBe(''); + expect(lines[1]).toContain('Plan mode: ON'); + expect(lines[2]).toContain('Plan will be created here: /tmp/plans/test-plan.md'); + }); +}); diff --git a/apps/kimi-code/test/tui/components/messages/shell-execution.test.ts b/apps/kimi-code/test/tui/components/messages/shell-execution.test.ts new file mode 100644 index 000000000..86e31ded1 --- /dev/null +++ b/apps/kimi-code/test/tui/components/messages/shell-execution.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from 'vitest'; + +import { ShellExecutionComponent } from '#/tui/components/messages/shell-execution'; +import { darkColors } from '#/tui/theme/colors'; + +function strip(text: string): string { + return text.replaceAll(/\u001B\[[0-9;]*m/g, ''); +} + +describe('ShellExecutionComponent', () => { + it('renders shell command previews with prompt indentation', () => { + const component = new ShellExecutionComponent({ + command: 'printf hello\nprintf world', + colors: darkColors, + showCommand: true, + }); + + const output = component.render(100).map((line) => strip(line).trimEnd()); + + expect(output).toContain(' $ printf hello'); + expect(output).toContain(' printf world'); + }); + + it('keeps collapsed shell output short and expands on demand', () => { + const collapsed = new ShellExecutionComponent({ + result: { + tool_call_id: 'call_shell', + output: ['line1', 'line2', 'line3', 'line4', 'line5'].join('\n'), + is_error: false, + }, + colors: darkColors, + }); + + const collapsedOutput = collapsed.render(100).map(strip).join('\n'); + expect(collapsedOutput).toContain('line1'); + expect(collapsedOutput).toContain('line3'); + expect(collapsedOutput).not.toContain('line4'); + expect(collapsedOutput).toContain('... (2 more lines, ctrl+o to expand)'); + + const expanded = new ShellExecutionComponent({ + result: { + tool_call_id: 'call_shell', + output: ['line1', 'line2', 'line3', 'line4', 'line5'].join('\n'), + is_error: false, + }, + colors: darkColors, + expanded: true, + }); + + const expandedOutput = expanded.render(100).map(strip).join('\n'); + expect(expandedOutput).toContain('line4'); + expect(expandedOutput).toContain('line5'); + expect(expandedOutput).not.toContain('ctrl+o to expand'); + }); +}); diff --git a/apps/kimi-code/test/tui/components/messages/status-panel.test.ts b/apps/kimi-code/test/tui/components/messages/status-panel.test.ts new file mode 100644 index 000000000..0fec0bddf --- /dev/null +++ b/apps/kimi-code/test/tui/components/messages/status-panel.test.ts @@ -0,0 +1,97 @@ +import { describe, expect, it } from 'vitest'; + +import { buildStatusReportLines } from '#/tui/components/messages/status-panel'; +import { darkColors } from '#/tui/theme/colors'; + +function strip(text: string): string { + return text.replaceAll(/\u001B\[[0-9;]*m/g, ''); +} + +describe('status panel report lines', () => { + it('formats runtime status, context, and managed usage without account or AGENTS.md rows', () => { + const lines = buildStatusReportLines({ + colors: darkColors, + version: '1.2.3', + model: 'k2', + workDir: '/tmp/project', + sessionId: 'ses-1', + sessionTitle: 'Implement status', + thinking: true, + permissionMode: 'manual', + planMode: false, + contextUsage: 0.25, + contextTokens: 2500, + maxContextTokens: 10000, + availableModels: { + k2: { + provider: 'managed:kimi-code', + model: 'kimi-k2', + maxContextSize: 10000, + displayName: 'Kimi K2', + }, + }, + status: { + model: 'k2', + thinkingLevel: 'high', + permission: 'auto', + planMode: true, + contextTokens: 3000, + maxContextTokens: 12000, + contextUsage: 0.25, + }, + managedUsage: { + summary: null, + limits: [ + { + label: '5h limit', + used: 8, + limit: 100, + resetHint: 'resets in 1h', + }, + ], + }, + }).map(strip); + + const output = lines.join('\n'); + expect(output).toContain('>_ Kimi Code (v1.2.3)'); + expect(output).toContain('Model Kimi K2 (thinking on)'); + expect(output).toContain('Directory /tmp/project'); + expect(output).toContain('Permissions auto'); + expect(output).toContain('Plan mode on'); + expect(output).toContain('Session ses-1'); + expect(output).toContain('Title Implement status'); + expect(output).toContain('Context window'); + expect(output).toContain('25.0%'); + expect(output).toContain('(3.0k / 12.0k)'); + expect(output).toContain('Plan usage'); + expect(output).toContain('92% left'); + expect(output).not.toContain('Account'); + expect(output).not.toContain('AGENTS.md'); + expect(output).not.toContain('Runtime'); + }); + + it('falls back to app state and shows status load errors as warnings', () => { + const lines = buildStatusReportLines({ + colors: darkColors, + version: '1.2.3', + model: '', + workDir: '/tmp/project', + sessionId: '', + sessionTitle: null, + thinking: false, + permissionMode: 'manual', + planMode: false, + contextUsage: 0, + contextTokens: 0, + maxContextTokens: 0, + availableModels: {}, + statusError: 'No active session', + }).map(strip); + + const output = lines.join('\n'); + expect(output).toContain('Model not set'); + expect(output).toContain('Session none'); + expect(output).toContain('Warning No active session'); + expect(output).toContain('No context window data available.'); + }); +}); diff --git a/apps/kimi-code/test/tui/components/messages/thinking.test.ts b/apps/kimi-code/test/tui/components/messages/thinking.test.ts new file mode 100644 index 000000000..9aa0688a8 --- /dev/null +++ b/apps/kimi-code/test/tui/components/messages/thinking.test.ts @@ -0,0 +1,82 @@ +import type { TUI } from '@earendil-works/pi-tui'; +import { describe, expect, it, vi } from 'vitest'; + +import { ThinkingComponent } from '#/tui/components/messages/thinking'; +import { STATUS_BULLET } from '#/tui/constant/symbols'; +import { darkColors } from '#/tui/theme/colors'; + +function strip(text: string): string { + return text.replaceAll(/\u001B\[[0-9;]*m/g, ''); +} + +const longThinking = ['line1', 'line2', 'line3', 'line4', 'line5', 'line6', 'line7'].join('\n'); + +describe('ThinkingComponent', () => { + it('shows the live spinner header before thinking content', () => { + const component = new ThinkingComponent('working it out', darkColors, true, 'live'); + const out = strip(component.render(80).join('\n')); + + expect(out).toContain('⠋ thinking...'); + expect(out).not.toContain(' ⠋ thinking...'); + expect(out).not.toContain(`${STATUS_BULLET}⠋`); + expect(out).toContain(' working it out'); + }); + + it('keeps live thinking height-limited to the tail', () => { + const component = new ThinkingComponent(longThinking, darkColors, true, 'live'); + const out = strip(component.render(80).join('\n')); + + expect(out).not.toContain('line1'); + expect(out).not.toContain('line4'); + expect(out).toContain('line5'); + expect(out).toContain('line7'); + expect(out).not.toContain('ctrl+o to expand'); + }); + + it('animates the live spinner and stops on finalize', () => { + vi.useFakeTimers(); + const requestRender = vi.fn(); + const component = new ThinkingComponent('step', darkColors, true, 'live', { + requestRender, + } as unknown as TUI); + + expect(strip(component.render(80).join('\n'))).toContain('⠋ thinking...'); + + vi.advanceTimersByTime(80); + expect(requestRender).toHaveBeenCalled(); + expect(strip(component.render(80).join('\n'))).toContain('⠙ thinking...'); + + component.finalize(); + requestRender.mockClear(); + vi.advanceTimersByTime(160); + expect(requestRender).not.toHaveBeenCalled(); + vi.useRealTimers(); + }); + + it('finalizes in place into a collapsed preview', () => { + const component = new ThinkingComponent(longThinking, darkColors, true, 'live'); + + component.finalize(); + + const out = strip(component.render(80).join('\n')); + expect(out).toContain('line1'); + expect(out).toContain('line3'); + expect(out).not.toContain('line4'); + expect(out).toContain('... (4 more lines, ctrl+o to expand)'); + }); + + it('expands and collapses after finalization', () => { + const component = new ThinkingComponent(longThinking, darkColors, true, 'live'); + component.finalize(); + + component.setExpanded(true); + const expanded = strip(component.render(80).join('\n')); + expect(expanded).toContain('line7'); + expect(expanded).not.toContain('ctrl+o to expand'); + + component.setExpanded(false); + const collapsed = strip(component.render(80).join('\n')); + expect(collapsed).not.toContain('line7'); + expect(collapsed).toContain('ctrl+o to expand'); + }); +}); diff --git a/apps/kimi-code/test/tui/components/messages/tool-call.test.ts b/apps/kimi-code/test/tui/components/messages/tool-call.test.ts new file mode 100644 index 000000000..a71c6619b --- /dev/null +++ b/apps/kimi-code/test/tui/components/messages/tool-call.test.ts @@ -0,0 +1,1068 @@ +import type { TUI } from '@earendil-works/pi-tui'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { ToolCallComponent } from '#/tui/components/messages/tool-call'; +import { STATUS_BULLET } from '#/tui/constant/symbols'; +import { darkColors } from '#/tui/theme/colors'; +import { createMarkdownTheme } from '#/tui/theme/pi-tui-theme'; + +import { captureProcessWrite } from '../../../helpers/process'; + +const ESC = String.fromCodePoint(0x1b); +const BEL = String.fromCodePoint(0x07); + +function strip(text: string): string { + return text + .replaceAll(/\u001B\[[0-9;]*m/g, '') + .replaceAll(new RegExp(`${ESC}\\]8;;[^${BEL}]*${BEL}`, 'g'), ''); +} + +function stubTui(rows: number): TUI { + return { + terminal: { rows }, + requestRender: () => {}, + } as unknown as TUI; +} + +describe('ToolCallComponent', () => { + afterEach(() => { + vi.useRealTimers(); + }); + + it('uses the shared non-emoji tool status bullet', () => { + const component = new ToolCallComponent( + { + id: 'call_read_marker', + name: 'Read', + args: { path: 'foo.ts' }, + }, + { + tool_call_id: 'call_read_marker', + output: 'content', + is_error: false, + }, + darkColors, + ); + + const out = strip(component.render(100).join('\n')); + expect(out).toContain(`${STATUS_BULLET}Used Read`); + expect(out).not.toContain(`\u23FA Used Read`); + expect(out).not.toContain(`${String.fromCodePoint(0x23fa, 0xfe0e)} Used Read`); + }); + + it('keeps collapsed tool results short and expands on demand', () => { + const component = new ToolCallComponent( + { + id: 'call_shell', + name: 'Bash', + args: { command: 'printf output' }, + }, + { + tool_call_id: 'call_shell', + output: ['line1', 'line2', 'line3', 'line4', 'line5'].join('\n'), + is_error: false, + }, + darkColors, + ); + + const collapsed = strip(component.render(100).join('\n')); + expect(collapsed).toContain('line1'); + expect(collapsed).toContain('line2'); + expect(collapsed).toContain('line3'); + expect(collapsed).not.toContain('line4'); + expect(collapsed).toContain('... (2 more lines, ctrl+o to expand)'); + + component.setExpanded(true); + + const expanded = strip(component.render(100).join('\n')); + expect(expanded).toContain('line4'); + expect(expanded).toContain('line5'); + expect(expanded).not.toContain('ctrl+o to expand'); + }); + + it('hides tool output bodies that start with a <system tag', () => { + const reminderOutput = + '<system-reminder>\nThe task tools have not been used recently.\n</system-reminder>'; + const component = new ToolCallComponent( + { + id: 'call_hidden', + name: 'Bash', + args: { command: 'echo hi' }, + }, + { + tool_call_id: 'call_hidden', + output: reminderOutput, + is_error: false, + }, + darkColors, + ); + + const collapsed = strip(component.render(100).join('\n')); + expect(collapsed).toContain(`${STATUS_BULLET}Used Bash`); + expect(collapsed).not.toContain('system-reminder'); + expect(collapsed).not.toContain('task tools'); + + component.setExpanded(true); + const expanded = strip(component.render(100).join('\n')); + expect(expanded).not.toContain('system-reminder'); + expect(expanded).not.toContain('task tools'); + }); + + it('hides <system-prefixed output even when the tool result is an error', () => { + const component = new ToolCallComponent( + { + id: 'call_hidden_err', + name: 'Bash', + args: { command: 'false' }, + }, + { + tool_call_id: 'call_hidden_err', + output: '<system-reminder>do not show</system-reminder>', + is_error: true, + }, + darkColors, + ); + + const out = strip(component.render(100).join('\n')); + expect(out).not.toContain('system-reminder'); + expect(out).not.toContain('do not show'); + }); + + it('still renders tool output when the body merely contains <system later on', () => { + const component = new ToolCallComponent( + { + id: 'call_inline', + name: 'Bash', + args: { command: 'echo hi' }, + }, + { + tool_call_id: 'call_inline', + output: 'first line\n<system-reminder>nope</system-reminder>', + is_error: false, + }, + darkColors, + ); + + const out = strip(component.render(100).join('\n')); + expect(out).toContain('first line'); + }); + + it('renders ExitPlanMode plan from result output when args.plan is absent', () => { + const component = new ToolCallComponent( + { + id: 'call_exit', + name: 'ExitPlanMode', + args: {}, + }, + { + tool_call_id: 'call_exit', + output: + 'Exited plan mode. Plan mode deactivated. All tools are now available.\n' + + 'Plan saved to: /tmp/plan.md\n\n' + + '## Approved Plan:\n# File Plan\n\n1. Do the focused fix.', + is_error: false, + }, + darkColors, + ); + + const out = strip(component.render(100).join('\n')); + expect(out).toContain('Current plan'); + expect(out).toContain('# File Plan'); + expect(out).toContain('1. Do the focused fix.'); + expect(out).not.toContain('Plan saved to: /tmp/plan.md'); + }); + + it('setPlanInfo injects plan body when args.plan is empty (plan-file mode)', () => { + const component = new ToolCallComponent( + { + id: 'call_exit_async', + name: 'ExitPlanMode', + args: {}, + }, + undefined, + darkColors, + undefined, + createMarkdownTheme(darkColors), + ); + + // A fresh tool card only shows the 'Current plan' title; no plan box renders yet. + const before = strip(component.render(100).join('\n')); + expect(before).toContain('Current plan'); + expect(before).not.toContain('Refactor session'); + + component.setPlanInfo({ plan: '# Refactor session\n\n- step', path: '/tmp/refactor.md' }); + + const after = strip(component.render(100).join('\n')); + expect(after).toContain('Refactor session'); + expect(after).toContain('plan:'); + expect(after).toContain('refactor.md'); + // Directory portion of the path must not leak into the visible header. + expect(after).not.toContain('/tmp/refactor.md'); + }); + + it('caps the plan preview to the terminal height and expands on ctrl+e', () => { + const longPlan = `# Refactor session\n\n${Array.from({ length: 40 }, (_, i) => `- step ${String(i + 1)}`).join('\n')}`; + const component = new ToolCallComponent( + { + id: 'call_exit_long', + name: 'ExitPlanMode', + args: { plan: longPlan }, + }, + undefined, + darkColors, + stubTui(24), + createMarkdownTheme(darkColors), + ); + + const collapsed = strip(component.render(100).join('\n')); + expect(collapsed).toContain('step 1'); + expect(collapsed).toMatch(/\.\.\. \(\d+ more lines, ctrl\+e to expand\)/); + expect(collapsed).not.toContain('step 40'); + + expect(component.setPlanExpanded(true)).toBe(true); + const expanded = strip(component.render(100).join('\n')); + expect(expanded).toContain('step 40'); + expect(expanded).not.toContain('ctrl+e to expand'); + }); + + it('plan preview controls are no-ops for non-ExitPlanMode tool calls', () => { + const component = new ToolCallComponent( + { + id: 'call_bash_plan', + name: 'Bash', + args: { command: 'echo hi' }, + }, + undefined, + darkColors, + undefined, + createMarkdownTheme(darkColors), + ); + + expect(component.setPlanExpanded(true)).toBe(false); + component.setPlanInfo({ plan: 'should be ignored', path: '/etc/hosts' }); + + const out = strip(component.render(100).join('\n')); + expect(out).not.toContain('should be ignored'); + expect(out).not.toContain('plan:'); + }); + + it('ctrl+o does not affect the plan preview cap', () => { + const longPlan = `# P\n\n${Array.from({ length: 40 }, (_, i) => `- step ${String(i + 1)}`).join('\n')}`; + const component = new ToolCallComponent( + { + id: 'call_exit_isolation', + name: 'ExitPlanMode', + args: { plan: longPlan }, + }, + undefined, + darkColors, + stubTui(24), + createMarkdownTheme(darkColors), + ); + component.setExpanded(true); + const out = strip(component.render(100).join('\n')); + expect(out).toContain('ctrl+e to expand'); + expect(out).not.toContain('step 40'); + }); + + it('header chips an Approved status when ExitPlanMode result indicates approval', () => { + const component = new ToolCallComponent( + { + id: 'call_exit_approved', + name: 'ExitPlanMode', + args: {}, + }, + { + tool_call_id: 'call_exit_approved', + output: + 'Exited plan mode. Plan mode deactivated. All tools are now available.\n' + + 'Plan saved to: /tmp/plan.md\n\n' + + '## Approved Plan:\n# Plan body', + is_error: false, + }, + darkColors, + ); + + const header = strip(component.render(100).join('\n')).split('\n')[1] ?? ''; + expect(header).toMatch(/Current plan · Approved\s*$/); + }); + + it('header chips approved option label when the user picked one', () => { + const component = new ToolCallComponent( + { + id: 'call_exit_chosen', + name: 'ExitPlanMode', + args: {}, + }, + { + tool_call_id: 'call_exit_chosen', + output: + 'Exited plan mode. Selected approach: Pragmatic refactor\n' + + 'Execute ONLY the selected approach. Do not execute any unselected alternatives.\n\n' + + 'Plan mode deactivated. All tools are now available.\n' + + 'Plan saved to: /tmp/plan.md\n\n' + + '## Approved Plan:\n# body', + is_error: false, + }, + darkColors, + ); + + const header = strip(component.render(100).join('\n')).split('\n')[1] ?? ''; + expect(header).toContain('Current plan · Approved: Pragmatic refactor'); + }); + + it('header chips Rejected and renders feedback for reject-with-suggestion', () => { + const component = new ToolCallComponent( + { + id: 'call_exit_reject_fb', + name: 'ExitPlanMode', + args: {}, + }, + { + tool_call_id: 'call_exit_reject_fb', + output: 'User rejected the plan. Feedback:\n\nplease rethink step 2', + is_error: false, + }, + darkColors, + ); + + const out = strip(component.render(100).join('\n')); + expect(out).toContain('Current plan · Rejected'); + expect(out).toContain('↪ Suggestion'); + expect(out).toContain('please rethink step 2'); + }); + + it('header chips Rejected without feedback when user rejected outright', () => { + const component = new ToolCallComponent( + { + id: 'call_exit_reject', + name: 'ExitPlanMode', + args: {}, + }, + { + tool_call_id: 'call_exit_reject', + output: 'Plan rejected by user. Plan mode remains active.', + is_error: false, + }, + darkColors, + ); + + const out = strip(component.render(100).join('\n')); + expect(out).toContain('Current plan · Rejected'); + expect(out).not.toContain('Plan mode remains active.'); + }); + + it('suppresses EnterPlanMode success body so prompt scaffolding does not leak into the transcript', () => { + const component = new ToolCallComponent( + { + id: 'call_enter', + name: 'EnterPlanMode', + args: { reason: 'plan a refactor' }, + }, + { + tool_call_id: 'call_enter', + output: + 'Plan mode is now active. Your workflow:\n\n' + + 'Plan file: /tmp/plan.md\n\n' + + '1. Use read-only tools (Read, Grep, Glob) to investigate the codebase.\n' + + '2. Design a concrete, step-by-step plan.\n' + + '3. Write the plan to the plan file with Write or Edit.\n' + + '4. When the plan is ready, call ExitPlanMode for user approval.\n\n' + + 'Do NOT edit files other than the plan file while plan mode is active.', + is_error: false, + }, + darkColors, + ); + + const out = strip(component.render(100).join('\n')); + expect(out).toContain('Used EnterPlanMode'); + expect(out).not.toContain('Plan mode is now active'); + expect(out).not.toContain('Plan file:'); + expect(out).not.toContain('read-only tools'); + }); + + it('still surfaces EnterPlanMode error output', () => { + const component = new ToolCallComponent( + { + id: 'call_enter_err', + name: 'EnterPlanMode', + args: {}, + }, + { + tool_call_id: 'call_enter_err', + output: 'Plan mode is already active. Use ExitPlanMode when the plan is ready.', + is_error: true, + }, + darkColors, + ); + + const out = strip(component.render(100).join('\n')); + expect(out).toContain('Plan mode is already active'); + }); + + it('renders AskUserQuestion with a friendly header instead of the raw tool name', () => { + const component = new ToolCallComponent( + { + id: 'call_question', + name: 'AskUserQuestion', + args: {}, + }, + { + tool_call_id: 'call_question', + output: JSON.stringify({ + answers: { + 'Favorite editor?': 'Vim', + }, + }), + is_error: false, + }, + darkColors, + ); + + const out = strip(component.render(100).join('\n')); + expect(out).toContain('Collected your answers'); + expect(out).toContain('Favorite editor?'); + expect(out).toContain('Vim'); + expect(out).not.toContain('AskUserQuestion'); + }); + + it('appends a chip to the header once a result arrives', () => { + const component = new ToolCallComponent( + { + id: 'call_read', + name: 'Read', + args: { path: 'foo.ts' }, + }, + { + tool_call_id: 'call_read', + output: '1\tfoo\n2\tbar\n3\tbaz', + is_error: false, + }, + darkColors, + ); + + const out = strip(component.render(100).join('\n')); + expect(out).toContain('Used Read'); + expect(out).toContain('· 3 lines'); + }); + + it('truncates a long file path from the head so the filename stays visible', () => { + const longPath = + 'apps/kimi-code/src/tui/components/messages/tool-renderers/long-path/example/final-file.ts'; + const component = new ToolCallComponent( + { + id: 'call_long_path', + name: 'Read', + args: { path: longPath }, + }, + undefined, + darkColors, + ); + + const out = strip(component.render(100).join('\n')); + expect(out).toContain('final-file.ts'); + expect(out).toContain('…'); + expect(out).not.toContain('apps/kimi-code/src/tui/components/messages/tool-renderers/long-pa…'); + }); + + it('shows Read paths relative to the active workspace', () => { + const component = new ToolCallComponent( + { + id: 'call_workspace_read', + name: 'Read', + args: { path: '/tmp/proj-a/apps/kimi-code/src/main.ts' }, + }, + { + tool_call_id: 'call_workspace_read', + output: '1\tcontent', + is_error: false, + }, + darkColors, + undefined, + undefined, + '/tmp/proj-a', + ); + + const out = strip(component.render(100).join('\n')); + expect(out).toContain('Used Read (apps/kimi-code/src/main.ts)'); + expect(out).not.toContain('/tmp/proj-a/apps'); + expect(component.getReadSnapshot().filePath).toBe('apps/kimi-code/src/main.ts'); + }); + + it('keeps Read paths outside the active workspace absolute', () => { + const component = new ToolCallComponent( + { + id: 'call_external_read', + name: 'Read', + args: { path: '/tmp/proj-ab/src/main.ts' }, + }, + undefined, + darkColors, + undefined, + undefined, + '/tmp/proj-a', + ); + + const out = strip(component.render(100).join('\n')); + expect(out).toContain('Using Read (/tmp/proj-ab/src/main.ts)'); + expect(component.getReadSnapshot().filePath).toBe('/tmp/proj-ab/src/main.ts'); + }); + + it('does not append a chip while a tool is still running', () => { + const component = new ToolCallComponent( + { + id: 'call_pending', + name: 'Read', + args: { path: 'foo.ts' }, + }, + undefined, + darkColors, + ); + + const out = strip(component.render(100).join('\n')); + expect(out).toContain('Using Read'); + expect(out).not.toContain('lines'); + }); + + it('renders a single foreground subagent without the generic Agent tool header', () => { + vi.useFakeTimers(); + vi.setSystemTime(10_000); + const component = new ToolCallComponent( + { + id: 'call_agent', + name: 'Agent', + args: { description: 'explore project xxx' }, + }, + undefined, + darkColors, + ); + + component.onSubagentSpawned({ + agentId: 'sub_explore_123456', + agentName: 'explore', + runInBackground: false, + }); + + let out = strip(component.render(120).join('\n')); + expect(out).toContain('Explore Agent Starting (explore project xxx) · 0 tools · 0s'); + expect(out).not.toContain('Using Agent'); + expect(out).not.toContain('Used Agent'); + + vi.setSystemTime(20_000); + component.appendSubagentText('think1\nthink2\nthink3', 'thinking'); + component.appendSubagentText('answer1\nanswer2\nanswer3', 'text'); + component.appendSubToolCall({ + id: 'sub_explore_123456:read', + name: 'Read', + args: { path: 'apps/kimi-code/src/tui/utils/background-agent-status.ts' }, + }); + + out = strip(component.render(120).join('\n')); + expect(out).toContain('Explore Agent Running (explore project xxx) · 1 tool · 10s'); + expect(out).toContain('Using Read (apps/kimi-code/src/tui/utils/background-agent-status.ts)'); + expect(out).not.toContain('think1'); + expect(out).not.toContain('think2'); + expect(out).toContain('think3'); + expect(out).toContain('◌ think3'); + expect(out).not.toContain('answer1'); + expect(out).not.toContain('answer2'); + expect(out).toContain('answer3'); + expect(out).toContain('└ answer3'); + + vi.setSystemTime(22_000); + component.onSubagentCompleted({ resultSummary: 'summary fallback' }); + component.setResult({ + tool_call_id: 'call_agent', + output: 'parent duplicate result', + is_error: false, + }); + vi.setSystemTime(30_000); + + out = strip(component.render(120).join('\n')); + expect(out).toContain('Explore Agent Completed (explore project xxx) · 1 tool · 12s'); + expect(out).not.toContain('think3'); + expect(out).toContain('└ answer3'); + expect(out).not.toContain('Used Agent'); + expect(out).not.toContain('parent duplicate result'); + expect(out).not.toContain('summary fallback'); + }); + + it('keeps the single subagent tool area to the latest four activities', () => { + vi.useFakeTimers(); + vi.setSystemTime(0); + const component = new ToolCallComponent( + { + id: 'call_agent_tools', + name: 'Agent', + args: { description: 'inspect tools' }, + }, + undefined, + darkColors, + ); + component.onSubagentSpawned({ + agentId: 'sub_tools', + agentName: 'explore', + runInBackground: false, + }); + + for (let i = 1; i <= 4; i++) { + const id = `sub_tools:read-${String(i)}`; + component.appendSubToolCall({ id, name: 'Read', args: { path: `file${String(i)}.ts` } }); + component.finishSubToolCall({ tool_call_id: id, output: 'ok', is_error: false }); + } + component.appendSubToolCall({ + id: 'sub_tools:grep', + name: 'Grep', + args: { pattern: 'auth' }, + }); + + const out = strip(component.render(120).join('\n')); + expect(out).toContain('Explore Agent Running (inspect tools) · 5 tools · 0s'); + expect(out).not.toContain('file1.ts'); + expect(out).toContain('Used Read (file2.ts)'); + expect(out).toContain('Used Read (file3.ts)'); + expect(out).toContain('Used Read (file4.ts)'); + expect(out).not.toContain('… Using Grep (auth)'); + expect(out).toContain('• Using Grep (auth)'); + expect(out).toContain('Using Grep (auth)'); + }); + + it('keeps the single subagent tool window stable when older tools update', () => { + vi.useFakeTimers(); + vi.setSystemTime(0); + const component = new ToolCallComponent( + { + id: 'call_agent_stable_tools', + name: 'Agent', + args: { description: 'inspect tools' }, + }, + undefined, + darkColors, + ); + component.onSubagentSpawned({ + agentId: 'sub_tools', + agentName: 'explore', + runInBackground: false, + }); + + for (let i = 1; i <= 5; i++) { + component.appendSubToolCall({ + id: `sub_tools:read-${String(i)}`, + name: 'Read', + args: { path: `file${String(i)}.ts` }, + }); + } + component.appendSubToolCallDelta({ + id: 'sub_tools:read-1', + name: 'Read', + argumentsPart: '{"path":"file1-updated.ts"}', + }); + component.finishSubToolCall({ + tool_call_id: 'sub_tools:read-1', + output: 'ok', + is_error: false, + }); + + const out = strip(component.render(120).join('\n')); + expect(out).not.toContain('file1-updated.ts'); + expect(out).toContain('Using Read (file2.ts)'); + expect(out).toContain('Using Read (file3.ts)'); + expect(out).toContain('Using Read (file4.ts)'); + expect(out).toContain('Using Read (file5.ts)'); + }); + + it('wraps single subagent thinking and output with hanging indentation', () => { + vi.useFakeTimers(); + vi.setSystemTime(0); + const component = new ToolCallComponent( + { + id: 'call_agent_wrapped_text', + name: 'Agent', + args: { description: 'inspect wrapping' }, + }, + undefined, + darkColors, + ); + component.onSubagentSpawned({ + agentId: 'sub_wrapped', + agentName: 'explore', + runInBackground: false, + }); + component.appendSubagentText( + 'thinking words that should wrap with a clean hanging indent', + 'thinking', + ); + component.appendSubagentText( + 'output words that should also wrap with a clean hanging indent', + 'text', + ); + + const lines = strip(component.render(34).join('\n')).split('\n'); + expect(lines).toContain(' ◌ thinking words that should '); + expect(lines).toContain(' wrap with a clean hanging '); + expect(lines).toContain(' indent '); + expect(lines).toContain(' └ output words that should also '); + expect(lines).toContain(' wrap with a clean hanging '); + }); + + it('renders failed single subagents with the dedicated header and error text', () => { + vi.useFakeTimers(); + vi.setSystemTime(1000); + const component = new ToolCallComponent( + { + id: 'call_agent_failed', + name: 'Agent', + args: { description: 'check failure' }, + }, + undefined, + darkColors, + ); + component.onSubagentSpawned({ + agentId: 'sub_failed', + agentName: 'explore', + runInBackground: false, + }); + + vi.setSystemTime(4000); + component.onSubagentFailed({ error: 'subagent exceeded max_steps' }); + + const out = strip(component.render(120).join('\n')); + expect(out).toContain('Explore Agent Failed (check failure) · 0 tools · 3s'); + expect(out).toContain('└ subagent exceeded max_steps'); + expect(out).not.toContain('Using Agent'); + expect(out).not.toContain('Used Agent'); + }); + + it('renders the full Write content during the streaming delta window (no cap)', () => { + const lines: string[] = []; + for (let i = 1; i <= 30; i++) lines.push(`line${String(i)}`); + const escaped = lines.join('\\n'); + const component = new ToolCallComponent( + { + id: 'call_write_stream', + name: 'Write', + args: { file_path: 'foo.ts', content: lines.join('\n') }, + streamingArguments: `{"file_path":"foo.ts","content":"${escaped}`, + }, + undefined, + darkColors, + ); + + const out = strip(component.render(100).join('\n')); + expect(out).toContain('Using Write'); + // All 30 lines must be present — no 10-line cap during streaming. + expect(out).toContain('line1'); + expect(out).toContain('line25'); + expect(out).toContain('line30'); + expect(out).not.toContain('ctrl+o to expand'); + }); + + it('switches a streaming tool call to Truncated when the step ended with max_tokens', () => { + const lines: string[] = []; + for (let i = 1; i <= 10; i++) lines.push(`line${String(i)}`); + const escaped = lines.join('\\n'); + const component = new ToolCallComponent( + { + id: 'call_write_truncated', + name: 'Write', + args: { file_path: 'foo.ts', content: lines.join('\n') }, + streamingArguments: `{"file_path":"foo.ts","content":"${escaped}`, + truncated: true, + }, + undefined, + darkColors, + ); + + const out = strip(component.render(100).join('\n')); + expect(out).toContain('Truncated Write'); + expect(out).not.toContain('Preparing Write'); + expect(out).toContain('Tool call arguments truncated by max_tokens'); + // The live argument preview must NOT render once the call is + // truncated — leaving the half-streamed Write content on screen + // was the original "preparing write" bug. + expect(out).not.toContain('line1'); + expect(out).not.toContain('line10'); + }); + + it('renders a stable Edit progress placeholder during the streaming delta window', () => { + vi.useFakeTimers(); + vi.setSystemTime(4000); + const oldLines: string[] = []; + const newLines: string[] = []; + for (let i = 1; i <= 20; i++) { + oldLines.push(`old${String(i)}`); + newLines.push(`new${String(i)}`); + } + const oldEscaped = oldLines.join('\\n'); + const newEscaped = newLines.join('\\n'); + const streaming = `{"file_path":"foo.ts","old_string":"${oldEscaped}","new_string":"${newEscaped}`; + const component = new ToolCallComponent( + { + id: 'call_edit_stream', + name: 'Edit', + args: { + file_path: 'foo.ts', + old_string: oldLines.join('\n'), + new_string: newLines.join('\n'), + }, + streamingArguments: streaming, + streamingStartedAtMs: 0, + }, + undefined, + darkColors, + ); + + const out = strip(component.render(100).join('\n')); + expect(out).toContain('Using Edit'); + expect(out).toContain('foo.ts'); + expect(out).toContain('Preparing changes for foo.ts...'); + expect(out).toContain('4s elapsed'); + expect(out).toMatch(/\d+(?:\.\d+)? (?:B|KB|MB)/); + expect(out).not.toContain('old20'); + expect(out).not.toContain('new20'); + expect(out).not.toMatch(/^\s*\d+\s+[+-]\s/m); + expect(out).not.toContain('ctrl+o to expand'); + }); + + it('keeps Write preview uncapped between finalized args and result (approval window)', () => { + // The wire sequence is: tool.call.delta → ... → tool.call (final + // args, no streamingArguments) → approval pending ... → tool.result. + // Between tool.call and tool.result the user may sit in an approval + // panel; the preview must stay fully visible during that gap, only + // collapsing once setResult fires (bullet turns green). + const lines: string[] = []; + for (let i = 1; i <= 30; i++) lines.push(`line${String(i)}`); + const component = new ToolCallComponent( + { + id: 'call_write_pending', + name: 'Write', + args: { file_path: 'foo.ts', content: lines.join('\n') }, + // No streamingArguments → finalized args; no result yet. + }, + undefined, + darkColors, + ); + const out = strip(component.render(100).join('\n')); + expect(out).toContain('line1'); + expect(out).toContain('line25'); + expect(out).toContain('line30'); + expect(out).not.toContain('ctrl+o to expand'); + }); + + it('snaps a long Write preview to the collapsed cap when the result arrives', () => { + const lines: string[] = []; + for (let i = 1; i <= 30; i++) lines.push(`line${String(i)}`); + const escaped = lines.join('\\n'); + const component = new ToolCallComponent( + { + id: 'call_write_snap', + name: 'Write', + args: { file_path: 'big.txt', content: lines.join('\n') }, + streamingArguments: `{"file_path":"big.txt","content":"${escaped}"}`, + }, + undefined, + darkColors, + ); + expect(strip(component.render(100).join('\n'))).toContain('line25'); + + component.setResult({ + tool_call_id: 'call_write_snap', + output: 'Wrote big.txt', + is_error: false, + }); + + const after = strip(component.render(100).join('\n')); + expect(after).toContain('line1'); + expect(after).not.toContain('line25'); + expect(after).toContain('ctrl+o to expand'); + }); + + it('refreshes the header when file_path arrives in a later streaming delta', () => { + // First delta: only an opening brace, no file_path yet. + const component = new ToolCallComponent( + { + id: 'call_write_path', + name: 'Write', + args: {}, + streamingArguments: '{', + }, + undefined, + darkColors, + ); + const before = strip(component.render(100).join('\n')); + expect(before).toContain('Using Write'); + expect(before).not.toContain('foo.ts'); + + // Later delta: file_path is now parseable from streamingArguments. + component.updateToolCall({ + id: 'call_write_path', + name: 'Write', + args: { file_path: 'foo.ts' }, + streamingArguments: '{"file_path":"foo.ts","content":"hello', + }); + const after = strip(component.render(100).join('\n')); + expect(after).toContain('foo.ts'); + }); + + it('builds the call preview when finalized args arrive after streaming', () => { + // Mimic the wire sequence: tool.call.delta → ... → tool.call (finalized). + const component = new ToolCallComponent( + { + id: 'call_write_seq', + name: 'Write', + args: { file_path: 'foo.ts', content: 'a\nb' }, + streamingArguments: '{"file_path":"foo.ts","content":"a\\nb', + }, + undefined, + darkColors, + ); + // While streaming, body is rendered live from streamingArguments. + expect(strip(component.render(100).join('\n'))).toMatch(/^\s*1\s+a\s*$/m); + + // Finalized tool.call: streamingArguments is undefined; the body + // re-renders from finalized args, content unchanged. + component.updateToolCall({ + id: 'call_write_seq', + name: 'Write', + args: { file_path: 'foo.ts', content: 'a\nb' }, + }); + const out = strip(component.render(100).join('\n')); + expect(out).toMatch(/^\s*1\s+a\s*$/m); + expect(out).toMatch(/^\s*2\s+b\s*$/m); + }); + + it('builds the Edit diff when finalized args arrive after streaming', () => { + const component = new ToolCallComponent( + { + id: 'call_edit_seq', + name: 'Edit', + args: { file_path: 'foo.ts' }, + streamingArguments: '{"file_path":"foo.ts","old_string":"a\\nb","new_string":"a\\nB', + streamingStartedAtMs: Date.now(), + }, + undefined, + darkColors, + ); + expect(strip(component.render(100).join('\n'))).toContain('Preparing changes'); + expect(strip(component.render(100).join('\n'))).not.toMatch(/^\s*\d+\s+[+-]\s/m); + + component.updateToolCall({ + id: 'call_edit_seq', + name: 'Edit', + args: { file_path: 'foo.ts', old_string: 'a\nb', new_string: 'a\nB' }, + }); + const out = strip(component.render(100).join('\n')); + expect(out).toContain('foo.ts'); + expect(out).toMatch(/^\s*2\s+- b\s*$/m); + expect(out).toMatch(/^\s*2\s+\+ B\s*$/m); + }); + + it('refreshes and stops the Edit streaming progress timer', () => { + vi.useFakeTimers(); + vi.setSystemTime(0); + const ui = { requestRender: vi.fn() }; + const component = new ToolCallComponent( + { + id: 'call_edit_timer', + name: 'Edit', + args: { file_path: 'foo.ts' }, + streamingArguments: '{"file_path":"foo.ts","old_string":"a', + streamingStartedAtMs: 0, + }, + undefined, + darkColors, + ui as never, + ); + + expect(strip(component.render(100).join('\n'))).toContain('0s elapsed'); + vi.advanceTimersByTime(1000); + expect(ui.requestRender).toHaveBeenCalled(); + expect(strip(component.render(100).join('\n'))).toContain('1s elapsed'); + + ui.requestRender.mockClear(); + component.setResult({ + tool_call_id: 'call_edit_timer', + output: 'Replaced 1 occurrence in foo.ts', + is_error: false, + }); + vi.advanceTimersByTime(1000); + expect(ui.requestRender).not.toHaveBeenCalled(); + + const componentToDispose = new ToolCallComponent( + { + id: 'call_edit_dispose', + name: 'Edit', + args: { file_path: 'bar.ts' }, + streamingArguments: '{"file_path":"bar.ts","old_string":"a', + streamingStartedAtMs: 0, + }, + undefined, + darkColors, + ui as never, + ); + ui.requestRender.mockClear(); + componentToDispose.dispose(); + vi.advanceTimersByTime(1000); + expect(ui.requestRender).not.toHaveBeenCalled(); + }); + + it('expands the Write call preview when ctrl+o expansion is set', () => { + const lines: string[] = []; + for (let i = 1; i <= 30; i++) lines.push(`line${String(i)}`); + const component = new ToolCallComponent( + { + id: 'call_write_done', + name: 'Write', + args: { file_path: 'big.txt', content: lines.join('\n') }, + }, + { + tool_call_id: 'call_write_done', + output: 'Wrote big.txt', + is_error: false, + }, + darkColors, + ); + + const collapsed = strip(component.render(100).join('\n')); + expect(collapsed).toContain('line1'); + expect(collapsed).toContain('line10'); + expect(collapsed).not.toContain('line25'); + expect(collapsed).toContain('ctrl+o to expand'); + + component.setExpanded(true); + + const expanded = strip(component.render(100).join('\n')); + expect(expanded).toContain('line25'); + expect(expanded).toContain('line30'); + expect(expanded).not.toContain('ctrl+o to expand'); + }); + + it('renders unknown Write file extensions as plain text without stderr noise', () => { + const stderr = captureProcessWrite('stderr'); + try { + const component = new ToolCallComponent( + { + id: 'call_write_unknown_ext', + name: 'Write', + args: { file_path: 'demo.abcxyz', content: 'hello\nworld' }, + }, + { + tool_call_id: 'call_write_unknown_ext', + output: 'Wrote demo.abcxyz', + is_error: false, + }, + darkColors, + ); + + const collapsed = strip(component.render(100).join('\n')); + expect(collapsed).toContain('hello'); + + component.setExpanded(true); + const expanded = strip(component.render(100).join('\n')); + expect(expanded).toContain('world'); + expect(stderr.text()).not.toContain('Could not find the language'); + } finally { + stderr.restore(); + } + }); +}); diff --git a/apps/kimi-code/test/tui/components/messages/tool-renderers/chip.test.ts b/apps/kimi-code/test/tui/components/messages/tool-renderers/chip.test.ts new file mode 100644 index 000000000..99d253af7 --- /dev/null +++ b/apps/kimi-code/test/tui/components/messages/tool-renderers/chip.test.ts @@ -0,0 +1,124 @@ +import { describe, expect, it } from 'vitest'; + +import { + computeEditStats, + computeWriteStats, + pickChip, +} from '#/tui/components/messages/tool-renderers/chip'; +import type { ToolCallBlockData, ToolResultBlockData } from '#/tui/types'; + +function strip(text: string): string { + return text.replaceAll(/\[[0-9;]*m/g, ''); +} + +function call(name: string, args: Record<string, unknown> = {}): ToolCallBlockData { + return { id: 'tc', name, args }; +} + +function result(output: string, isError = false): ToolResultBlockData { + return { tool_call_id: 'tc', output, is_error: isError }; +} + +function chipFor(name: string, args: Record<string, unknown>, out: ToolResultBlockData): string { + const provider = pickChip(name); + return strip(provider?.(call(name, args), out) ?? ''); +} + +describe('chip registry', () => { + it('Bash has no chip (exit code is not surfaced)', () => { + expect(pickChip('Bash')).toBeUndefined(); + }); + + it('Edit chip shows +N -M from args diff', () => { + const c = chipFor( + 'Edit', + { path: 'foo.ts', old_string: 'a\nb\nc', new_string: 'a\nB\nc\nd' }, + result('Replaced 1 occurrence in foo.ts'), + ); + expect(c).toMatch(/\+\d+/); + expect(c).toMatch(/-\d+/); + }); + + it('Write chip shows N lines from content arg', () => { + expect(chipFor('Write', { path: 'a.txt', content: 'a\nb\nc\n' }, result('Wrote a.txt'))).toBe( + '3 lines', + ); + }); + + it('Read chip shows line count', () => { + expect(chipFor('Read', { path: 'a.ts' }, result('1\tfoo\n2\tbar\n3\tbaz'))).toBe('3 lines'); + }); + + it('Read chip handles single line as singular', () => { + expect(chipFor('Read', { path: 'a.ts' }, result('1\tfoo'))).toBe('1 line'); + }); + + it('Grep chip shows match count', () => { + expect(chipFor('Grep', { pattern: 'foo' }, result('a.ts\nb.ts\nc.ts'))).toBe('3 matches'); + }); + + it('Grep chip says "no matches" on empty result', () => { + expect(chipFor('Grep', { pattern: 'foo' }, result(''))).toBe('no matches'); + }); + + it('Glob chip shows file count', () => { + expect(chipFor('Glob', { pattern: '**/*.ts' }, result('a.ts\nb.ts'))).toBe('2 files'); + }); + + it('FetchURL chip shows size and is non-empty', () => { + const out = chipFor('FetchURL', { url: 'https://example.com' }, result('hello world')); + expect(out).toMatch(/\d+\s*B/); + }); + + it('WebSearch chip shows result count', () => { + expect(chipFor('WebSearch', { query: 'kimi' }, result('1. Alpha\n2. Beta\n3. Gamma'))).toBe( + '3 results', + ); + }); + + it('Think tool has no chip', () => { + expect(pickChip('Think')).toBeUndefined(); + }); + + it('Unknown tools have no chip', () => { + expect(pickChip('SomethingElse')).toBeUndefined(); + }); +}); + +describe('computeWriteStats', () => { + it('returns zero lines for empty content', () => { + expect(computeWriteStats({})).toEqual({ lines: 0 }); + expect(computeWriteStats({ content: '' })).toEqual({ lines: 0 }); + }); + + it('counts a single line with no trailing newline', () => { + expect(computeWriteStats({ content: 'hello' })).toEqual({ lines: 1 }); + }); + + it('ignores trailing newline so "a\\nb\\n" is 2 lines', () => { + expect(computeWriteStats({ content: 'a\nb\n' })).toEqual({ lines: 2 }); + expect(computeWriteStats({ content: 'a\nb' })).toEqual({ lines: 2 }); + }); +}); + +describe('computeEditStats', () => { + it('returns zero when both strings are empty', () => { + expect(computeEditStats({})).toEqual({ added: 0, removed: 0 }); + expect(computeEditStats({ old_string: '', new_string: '' })).toEqual({ + added: 0, + removed: 0, + }); + }); + + it('counts added and removed lines for a replacement', () => { + const stats = computeEditStats({ old_string: 'a\nb\nc', new_string: 'a\nB\nc\nd' }); + expect(stats.added).toBeGreaterThan(0); + expect(stats.removed).toBeGreaterThan(0); + }); + + it('counts only adds when old is empty', () => { + const stats = computeEditStats({ old_string: '', new_string: 'x\ny\nz' }); + expect(stats.added).toBe(3); + expect(stats.removed).toBe(0); + }); +}); diff --git a/apps/kimi-code/test/tui/components/messages/tool-renderers/media.test.ts b/apps/kimi-code/test/tui/components/messages/tool-renderers/media.test.ts new file mode 100644 index 000000000..ab4a53fd6 --- /dev/null +++ b/apps/kimi-code/test/tui/components/messages/tool-renderers/media.test.ts @@ -0,0 +1,150 @@ +import type { Component } from '@earendil-works/pi-tui'; +import { describe, expect, it } from 'vitest'; + +import { + parseReadMediaOutput, + readMediaChip, + readMediaSummary, +} from '#/tui/components/messages/tool-renderers/media'; +import { darkColors } from '#/tui/theme/colors'; +import type { ToolCallBlockData, ToolResultBlockData } from '#/tui/types'; + +function strip(text: string): string { + return text.replaceAll(/\[[0-9;]*m/g, ''); +} + +function joinRender(components: Component[], width = 100): string { + return components.flatMap((c) => c.render(width)).join('\n'); +} + +function call(name: string, args: Record<string, unknown> = {}): ToolCallBlockData { + return { id: 'tc', name, args }; +} + +function result(output: string, isError = false): ToolResultBlockData { + return { tool_call_id: 'tc', output, is_error: isError }; +} + +const ctx = { expanded: false, colors: darkColors }; +const expandedCtx = { expanded: true, colors: darkColors }; + +// 1x1 transparent png base64 (≈70 bytes once decoded) +const PNG_B64 = + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII='; +const PNG_DATA_URL = `data:image/png;base64,${PNG_B64}`; + +function imageOutput(path: string, b64 = PNG_B64, mime = 'image/png'): string { + return JSON.stringify([ + { type: 'text', text: `<image path="${path}">` }, + { type: 'image_url', imageUrl: { url: `data:${mime};base64,${b64}` } }, + { type: 'text', text: '</image>' }, + { type: 'text', text: `Loaded image file "${path}" (${mime}, 70 bytes, original size 1x1px).` }, + ]); +} + +function videoOutput(path: string, mime = 'video/mp4'): string { + return JSON.stringify([ + { type: 'text', text: `<video path="${path}">` }, + { type: 'video_url', videoUrl: { url: `data:${mime};base64,YWJj` } }, + { type: 'text', text: '</video>' }, + ]); +} + +describe('parseReadMediaOutput', () => { + it('extracts kind, path, mime type, and bytes from an image data URL', () => { + const m = parseReadMediaOutput(imageOutput('/tmp/a.png')); + expect(m).not.toBeNull(); + expect(m?.kind).toBe('image'); + expect(m?.path).toBe('/tmp/a.png'); + expect(m?.mimeType).toBe('image/png'); + expect(m?.bytes).toBeGreaterThan(0); + expect(m?.originalSize).toBe('1x1px'); + }); + + it('extracts video kind and mime', () => { + const m = parseReadMediaOutput(videoOutput('/tmp/a.mp4')); + expect(m?.kind).toBe('video'); + expect(m?.mimeType).toBe('video/mp4'); + }); + + it('captures non-data video URL when uploader was used', () => { + const out = JSON.stringify([ + { type: 'text', text: `<video path="/tmp/a.mp4">` }, + { type: 'video_url', videoUrl: { url: 'https://cdn.example/v/abc' } }, + { type: 'text', text: '</video>' }, + ]); + const m = parseReadMediaOutput(out); + expect(m?.kind).toBe('video'); + expect(m?.url).toBe('https://cdn.example/v/abc'); + expect(m?.bytes).toBeUndefined(); + }); + + it('returns null for non-JSON output', () => { + expect(parseReadMediaOutput('not json')).toBeNull(); + }); + + it('returns null when no media part is present', () => { + expect(parseReadMediaOutput(JSON.stringify([{ type: 'text', text: 'hi' }]))).toBeNull(); + }); +}); + +describe('readMediaChip', () => { + it('returns a compact summary for an image', () => { + const text = strip(readMediaChip(call('ReadMediaFile'), result(imageOutput('/tmp/a.png')))); + expect(text).toMatch(/image/); + expect(text).toContain('image/png'); + expect(text).toMatch(/B|KB|MB/); + }); + + it('returns empty string on error so the truncated body shows the error', () => { + expect(readMediaChip(call('ReadMediaFile'), result('boom', true))).toBe(''); + }); + + it('returns empty string when output is unparseable', () => { + expect(readMediaChip(call('ReadMediaFile'), result('garbage'))).toBe(''); + }); +}); + +describe('readMediaSummary renderer', () => { + it('renders an empty body when collapsed (chip carries the info)', () => { + const out = joinRender( + readMediaSummary(call('ReadMediaFile'), result(imageOutput('/tmp/a.png')), ctx), + ); + expect(out.trim()).toBe(''); + }); + + it('renders path + meta line when expanded — never the base64 blob', () => { + const out = strip( + joinRender( + readMediaSummary(call('ReadMediaFile'), result(imageOutput('/tmp/a.png')), expandedCtx), + ), + ); + expect(out).toContain('/tmp/a.png'); + expect(out).toContain('image/png'); + // Crucially: the base64 must never reach the screen. + expect(out).not.toContain(PNG_B64); + expect(out).not.toContain(PNG_DATA_URL); + }); + + it('falls back to truncated renderer for errors', () => { + const out = strip( + joinRender( + readMediaSummary( + call('ReadMediaFile', { path: '/tmp/x.png' }), + result('File not found', true), + ctx, + ), + ), + ); + expect(out).toContain('File not found'); + }); + + it('falls back to truncated renderer when the output is not the media envelope', () => { + const out = strip( + joinRender( + readMediaSummary(call('ReadMediaFile'), result('"some plain string output"'), ctx), + ), + ); + expect(out).toContain('some plain string output'); + }); +}); diff --git a/apps/kimi-code/test/tui/components/messages/tool-renderers/registry.test.ts b/apps/kimi-code/test/tui/components/messages/tool-renderers/registry.test.ts new file mode 100644 index 000000000..aebe87435 --- /dev/null +++ b/apps/kimi-code/test/tui/components/messages/tool-renderers/registry.test.ts @@ -0,0 +1,165 @@ +import type { Component } from '@earendil-works/pi-tui'; +import { describe, expect, it } from 'vitest'; + +import { pickResultRenderer } from '#/tui/components/messages/tool-renderers/registry'; +import { darkColors } from '#/tui/theme/colors'; +import type { ToolCallBlockData, ToolResultBlockData } from '#/tui/types'; + +function strip(text: string): string { + return text.replaceAll(/\[[0-9;]*m/g, ''); +} + +function joinRender(components: Component[], width = 100): string { + return components.flatMap((c) => c.render(width)).join('\n'); +} + +function call(name: string, args: Record<string, unknown> = {}): ToolCallBlockData { + return { id: 'tc', name, args }; +} + +function result(output: string, isError = false): ToolResultBlockData { + return { tool_call_id: 'tc', output, is_error: isError }; +} + +const ctx = { expanded: false, colors: darkColors }; +const expandedCtx = { expanded: true, colors: darkColors }; + +describe('tool-result registry', () => { + it('falls back to truncated renderer for unknown tools', () => { + const renderer = pickResultRenderer('SomethingUnknown'); + const out = strip(joinRender(renderer(call('SomethingUnknown'), result('a\nb\nc\nd\ne'), ctx))); + expect(out).toContain('a'); + expect(out).toContain('b'); + expect(out).toContain('c'); + expect(out).not.toContain('\nd'); + expect(out).toContain('... (2 more lines, ctrl+o to expand)'); + }); + + it('uses truncated renderer for Bash to preserve raw output UX', () => { + const renderer = pickResultRenderer('Bash'); + const out = strip(joinRender(renderer(call('Bash'), result('one\ntwo\nthree\nfour'), ctx))); + expect(out).toContain('one'); + expect(out).toContain('... (1 more lines, ctrl+o to expand)'); + }); + + it('Read renders no body when collapsed (header chip carries the count)', () => { + const renderer = pickResultRenderer('Read'); + const out = joinRender( + renderer(call('Read', { path: 'foo.ts' }), result('1\tfoo\n2\tbar'), ctx), + ); + expect(out.trim()).toBe(''); + }); + + it('Read expands to the raw file content when expanded', () => { + const renderer = pickResultRenderer('Read'); + const out = strip( + joinRender(renderer(call('Read', { path: 'foo.ts' }), result('1\tfoo\n2\tbar'), expandedCtx)), + ); + expect(out).toContain('foo'); + expect(out).toContain('bar'); + }); + + it('Grep glance lists path samples below the chip', () => { + const renderer = pickResultRenderer('Grep'); + const out = strip( + joinRender( + renderer( + call('Grep', { pattern: 'foo' }), + result('src/a.ts\nsrc/b.ts\nsrc/c.ts\nsrc/d.ts\nsrc/e.ts'), + ctx, + ), + ), + ); + expect(out).toContain('src/a.ts'); + expect(out).toContain('src/b.ts'); + expect(out).toContain('src/c.ts'); + expect(out).toContain('+2 more'); + expect(out).not.toContain('src/d.ts'); + }); + + it('Grep glance strips trailing :line:text in content mode', () => { + const renderer = pickResultRenderer('Grep'); + const out = strip( + joinRender( + renderer( + call('Grep', { pattern: 'foo' }), + result('src/a.ts:42: foo()\nsrc/b.ts:7:foo'), + ctx, + ), + ), + ); + expect(out).toContain('src/a.ts:42'); + expect(out).not.toContain('foo()'); + }); + + it('Grep with empty result renders nothing in collapsed state', () => { + const renderer = pickResultRenderer('Grep'); + const out = joinRender(renderer(call('Grep', { pattern: 'foo' }), result(''), ctx)); + expect(out.trim()).toBe(''); + }); + + it('Glob glance lists path samples', () => { + const renderer = pickResultRenderer('Glob'); + const out = strip( + joinRender( + renderer(call('Glob', { pattern: '**/*.ts' }), result('a.ts\nb.ts\nc.ts\nd.ts'), ctx), + ), + ); + expect(out).toContain('a.ts'); + expect(out).toContain('b.ts'); + expect(out).toContain('c.ts'); + expect(out).toContain('+1 more'); + }); + + it('FetchURL renders no body when collapsed', () => { + const renderer = pickResultRenderer('FetchURL'); + const out = joinRender( + renderer(call('FetchURL', { url: 'https://example.com/x' }), result('<body>...'), ctx), + ); + expect(out.trim()).toBe(''); + }); + + it('WebSearch renders no body when collapsed', () => { + const renderer = pickResultRenderer('WebSearch'); + const out = joinRender( + renderer(call('WebSearch', { query: 'kimi' }), result('1. Alpha\n2. Beta'), ctx), + ); + expect(out.trim()).toBe(''); + }); + + it('Edit renders no body when collapsed', () => { + const renderer = pickResultRenderer('Edit'); + const out = joinRender( + renderer( + call('Edit', { path: 'foo.ts', old_string: 'a', new_string: 'b' }), + result('Replaced 1 occurrence in foo.ts'), + ctx, + ), + ); + expect(out.trim()).toBe(''); + }); + + it('Write renders no body when collapsed', () => { + const renderer = pickResultRenderer('Write'); + const out = joinRender( + renderer(call('Write', { path: 'a.txt', content: 'a\nb\n' }), result('Wrote'), ctx), + ); + expect(out.trim()).toBe(''); + }); + + it('Think renders no body even with a thought arg', () => { + const renderer = pickResultRenderer('Think'); + const out = joinRender(renderer(call('Think', { thought: 'hello' }), result('Recorded.'), ctx)); + expect(out.trim()).toBe(''); + }); + + it('Errors always fall back to truncated renderer regardless of tool', () => { + const renderer = pickResultRenderer('Read'); + const out = strip( + joinRender( + renderer(call('Read', { path: 'foo.ts' }), result('ENOENT: foo.ts not found', true), ctx), + ), + ); + expect(out).toContain('ENOENT: foo.ts not found'); + }); +}); diff --git a/apps/kimi-code/test/tui/components/messages/usage-panel.test.ts b/apps/kimi-code/test/tui/components/messages/usage-panel.test.ts new file mode 100644 index 000000000..e7d6c2c79 --- /dev/null +++ b/apps/kimi-code/test/tui/components/messages/usage-panel.test.ts @@ -0,0 +1,67 @@ +import { visibleWidth } from '@earendil-works/pi-tui'; +import { describe, expect, it } from 'vitest'; + +import { buildUsageReportLines, UsagePanelComponent } from '#/tui/components/messages/usage-panel'; +import { darkColors } from '#/tui/theme/colors'; + +function strip(text: string): string { + return text.replaceAll(/\u001B\[[0-9;]*m/g, ''); +} + +describe('UsagePanelComponent', () => { + it('formats session, context, and managed usage sections', () => { + const lines = buildUsageReportLines({ + colors: darkColors, + sessionUsage: { + byModel: { + kimi: { + inputOther: 1000, + inputCacheRead: 500, + inputCacheCreation: 500, + output: 250, + }, + }, + } as never, + contextUsage: 0.25, + contextTokens: 2500, + maxContextTokens: 10000, + managedUsage: { + summary: { + label: 'daily', + used: 20, + limit: 100, + resetHint: 'resets tomorrow', + }, + limits: [], + }, + }).map(strip); + + expect(lines).toContain('Session usage'); + expect(lines).toContain(' kimi input 2.0k output 250 total 2.3k'); + expect(lines).toContain('Context window'); + expect(lines.join('\n')).toContain('25.0%'); + expect(lines).toContain('Plan usage'); + expect(lines.join('\n')).toContain('80% left'); + expect(lines.join('\n')).toContain('(resets tomorrow)'); + }); + + it('wraps preformatted usage lines in a bordered panel', () => { + const component = new UsagePanelComponent(['Session usage'], darkColors.primary); + const output = component.render(80).map(strip); + + expect(output[0]).toContain(' Usage '); + expect(output[1]).toContain('Session usage'); + }); + + it('truncates lines wider than the terminal so the panel never overflows', () => { + const longLine = 'error: ' + 'x'.repeat(200); + const component = new UsagePanelComponent([longLine], darkColors.primary); + const width = 60; + + const output = component.render(width); + + for (const line of output) { + expect(visibleWidth(line)).toBeLessThanOrEqual(width); + } + }); +}); diff --git a/apps/kimi-code/test/tui/components/messages/user-message.test.ts b/apps/kimi-code/test/tui/components/messages/user-message.test.ts new file mode 100644 index 000000000..ab1d5752b --- /dev/null +++ b/apps/kimi-code/test/tui/components/messages/user-message.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from 'vitest'; + +import { UserMessageComponent } from '#/tui/components/messages/user-message'; +import { darkColors } from '#/tui/theme/colors'; + +function stripAnsi(text: string): string { + return text.replaceAll(/\u001B\[[0-9;]*m/g, ''); +} + +describe('UserMessageComponent', () => { + it('renders video placeholders as plain text, not inline image escapes', () => { + const component = new UserMessageComponent( + 'please inspect [video #1 sample.mov]', + darkColors, + [], + ); + + const out = stripAnsi(component.render(80).join('\n')); + + expect(out).toContain('[video #1 sample.mov]'); + expect(out).not.toContain('\u001B_G'); + expect(out).not.toContain('\u001B]1337;File='); + }); +}); diff --git a/apps/kimi-code/test/tui/components/panels/footer-bg-agents.test.ts b/apps/kimi-code/test/tui/components/panels/footer-bg-agents.test.ts new file mode 100644 index 000000000..e66e7f11f --- /dev/null +++ b/apps/kimi-code/test/tui/components/panels/footer-bg-agents.test.ts @@ -0,0 +1,108 @@ +import { describe, expect, it } from 'vitest'; + +import { FooterComponent } from '#/tui/components/chrome/footer'; +import { darkColors } from '#/tui/theme/colors'; +import type { AppState } from '#/tui/types'; + +const ANSI_SGR = /\[[0-9;]*m/g; +function strip(text: string): string { + return text.replaceAll(ANSI_SGR, ''); +} + +function baseState(overrides: Partial<AppState> = {}): AppState { + return { + model: 'k2', + workDir: '/tmp/proj', + sessionId: 'sess_1', + yolo: false, + permissionMode: 'manual', + planMode: false, + thinking: false, + contextUsage: 0, + contextTokens: 0, + maxContextTokens: 200_000, + isStreaming: false, + isCompacting: false, + isReplaying: false, + streamingPhase: 'idle', + streamingStartTime: 0, + theme: 'dark', + version: 'test', + editorCommand: null, + notifications: { enabled: true, condition: 'unfocused' }, + availableModels: {}, + ...overrides, + } as AppState; +} + +describe('FooterComponent — background task / agent badges', () => { + it('omits both badges when counts are 0', () => { + const footer = new FooterComponent(baseState(), darkColors); + const [line1] = footer.render(120); + expect(line1).toBeDefined(); + expect(strip(line1!)).not.toMatch(/tasks? running/); + expect(strip(line1!)).not.toMatch(/agents? running/); + }); + + it('renders the task badge alone when only bash tasks are running', () => { + const footer = new FooterComponent(baseState(), darkColors); + footer.setBackgroundCounts({ bashTasks: 1, agentTasks: 0 }); + const out = strip(footer.render(120)[0]!); + expect(out).toMatch(/\[1 task running\]/); + expect(out).not.toMatch(/agents? running/); + }); + + it('renders the agent badge alone when only agent tasks are running', () => { + const footer = new FooterComponent(baseState(), darkColors); + footer.setBackgroundCounts({ bashTasks: 0, agentTasks: 1 }); + const out = strip(footer.render(120)[0]!); + expect(out).toMatch(/\[1 agent running\]/); + expect(out).not.toMatch(/tasks? running/); + }); + + it('renders both badges side by side when both are non-zero', () => { + const footer = new FooterComponent(baseState(), darkColors); + footer.setBackgroundCounts({ bashTasks: 2, agentTasks: 3 }); + const out = strip(footer.render(120)[0]!); + expect(out).toMatch(/\[2 tasks running\]/); + expect(out).toMatch(/\[3 agents running\]/); + // Task badge appears before agent badge in the line. + expect(out.indexOf('2 tasks')).toBeLessThan(out.indexOf('3 agents')); + }); + + it('pluralizes correctly across both badges', () => { + const footer = new FooterComponent(baseState(), darkColors); + footer.setBackgroundCounts({ bashTasks: 1, agentTasks: 1 }); + const out = strip(footer.render(120)[0]!); + expect(out).toMatch(/\[1 task running\]/); + expect(out).toMatch(/\[1 agent running\]/); + }); + + it('updates badges live via setBackgroundCounts', () => { + const footer = new FooterComponent(baseState(), darkColors); + footer.setBackgroundCounts({ bashTasks: 2, agentTasks: 1 }); + expect(strip(footer.render(120)[0]!)).toMatch(/\[2 tasks running\]/); + footer.setBackgroundCounts({ bashTasks: 0, agentTasks: 0 }); + const after = strip(footer.render(120)[0]!); + expect(after).not.toMatch(/tasks? running/); + expect(after).not.toMatch(/agents? running/); + }); + + it('clamps negative counts to 0', () => { + const footer = new FooterComponent(baseState(), darkColors); + footer.setBackgroundCounts({ bashTasks: -5, agentTasks: -2 }); + const out = strip(footer.render(120)[0]!); + expect(out).not.toMatch(/tasks? running/); + expect(out).not.toMatch(/agents? running/); + }); + + it('drops the badges when terminal is too narrow to fit them', () => { + const footer = new FooterComponent(baseState(), darkColors); + footer.setBackgroundCounts({ bashTasks: 4, agentTasks: 3 }); + // Extremely narrow width: footer primary content fills the line, so leftLine wins. + const [line1] = footer.render(20); + expect(line1).toBeDefined(); + expect(strip(line1!)).not.toMatch(/\[4 tasks running\]/); + expect(strip(line1!)).not.toMatch(/\[3 agents running\]/); + }); +}); diff --git a/apps/kimi-code/test/tui/components/panels/footer-context.test.ts b/apps/kimi-code/test/tui/components/panels/footer-context.test.ts new file mode 100644 index 000000000..4861b68cd --- /dev/null +++ b/apps/kimi-code/test/tui/components/panels/footer-context.test.ts @@ -0,0 +1,150 @@ +import { describe, it, expect } from 'vitest'; +import chalk from 'chalk'; + +import { FooterComponent, formatFooterGitBadge } from '#/tui/components/chrome/footer'; +import { darkColors } from '#/tui/theme/colors'; +import type { AppState } from '#/tui/types'; + +const ANSI_SGR = /\u001B\[[0-9;]*m/g; +function strip(text: string): string { + return text.replaceAll(ANSI_SGR, ''); +} + +function hexToSgr(hex: string): string { + const value = hex.replace(/^#/, ''); + const r = Number.parseInt(value.slice(0, 2), 16); + const g = Number.parseInt(value.slice(2, 4), 16); + const b = Number.parseInt(value.slice(4, 6), 16); + return `\u001B[38;2;${String(r)};${String(g)};${String(b)}m`; +} + +function baseState(overrides: Partial<AppState> = {}): AppState { + return { + model: 'k2', + workDir: '/tmp', + sessionId: 'sess_1', + yolo: false, + permissionMode: 'manual', + planMode: false, + thinking: false, + contextUsage: 0, + contextTokens: 0, + maxContextTokens: 0, + isStreaming: false, + isCompacting: false, + isReplaying: false, + streamingPhase: 'idle', + streamingStartTime: 0, + theme: 'dark', + version: 'test', + editorCommand: null, + notifications: { enabled: true, condition: 'unfocused' }, + availableModels: {}, + ...overrides, + } as AppState; +} + +describe('FooterComponent — context NaN resilience', () => { + it('NaN usage → renders 0.0% (never literal "NaN%")', () => { + const fc = new FooterComponent(baseState({ contextUsage: Number.NaN }), darkColors); + const out = strip(fc.render(120).join('')); + expect(out).not.toMatch(/NaN/); + expect(out).toMatch(/context: 0\.0%/); + }); + + it('undefined-ish (coerced) usage → renders 0.0%', () => { + const fc = new FooterComponent( + baseState({ contextUsage: undefined as unknown as number }), + darkColors, + ); + const out = strip(fc.render(120).join('')); + expect(out).not.toMatch(/NaN/); + expect(out).toMatch(/context: 0\.0%/); + }); + + it('clamps ratios above 1.0 → renders 100.0%', () => { + const fc = new FooterComponent(baseState({ contextUsage: 1.5 }), darkColors); + const out = strip(fc.render(120).join('')); + expect(out).toMatch(/context: 100\.0%/); + }); + + it('ratio 0.427 → renders 42.7%', () => { + const fc = new FooterComponent(baseState({ contextUsage: 0.427 }), darkColors); + const out = strip(fc.render(200).join('')); + expect(out).toMatch(/context: 42\.7%/); + }); + + it('tokens provided but max=0 → falls back to percent-only, no division-by-zero artefact', () => { + const fc = new FooterComponent( + baseState({ contextUsage: 0, contextTokens: 500, maxContextTokens: 0 }), + darkColors, + ); + const out = strip(fc.render(200).join('')); + expect(out).not.toMatch(/Infinity|NaN/); + expect(out).toMatch(/context: 0\.0%/); + // With maxTokens=0, token-count annotation is suppressed. + expect(out).not.toMatch(/\(500\//); + }); + + it('setState updates visible model and context values', () => { + const footer = new FooterComponent(baseState({ model: 'k2', contextUsage: 0 }), darkColors); + + footer.setState(baseState({ model: 'kimi-k2-5', contextUsage: 0.5 })); + + const out = strip(footer.render(200).join('')); + expect(out).toContain('kimi-k2-5'); + expect(out).not.toContain(' k2 '); + expect(out).toMatch(/context: 50\.0%/); + }); + + it('shows "thinking" label when thinking is enabled, hides it when disabled', () => { + const on = new FooterComponent(baseState({ model: 'k2', thinking: true }), darkColors); + const off = new FooterComponent(baseState({ model: 'k2', thinking: false }), darkColors); + + expect(strip(on.render(120)[0]!)).toContain('thinking'); + expect(strip(off.render(120)[0]!)).not.toContain('thinking'); + }); + + it('renders transient hints on the context line', () => { + const footer = new FooterComponent(baseState(), darkColors); + + footer.setTransientHint('Press Ctrl-C again to exit'); + + const [, line2] = footer.render(120); + expect(strip(line2 ?? '')).toContain('Press Ctrl-C again to exit'); + expect(strip(line2 ?? '')).toContain('context: 0.0%'); + }); + + it('highlights the pull request badge separately from git status text', () => { + const previousLevel = chalk.level; + chalk.level = 3; + try { + const out = formatFooterGitBadge( + { + branch: 'feature/footer', + dirty: false, + ahead: 0, + behind: 0, + diffAdded: 0, + diffDeleted: 0, + pullRequest: { + number: 6, + url: 'https://github.com/acme/repo/pull/6', + }, + }, + darkColors, + ); + + const primaryIndex = out.indexOf(hexToSgr(darkColors.primary)); + const statusIndex = out.indexOf(hexToSgr(darkColors.status)); + const badgeIndex = out.indexOf('[PR#6]'); + expect(statusIndex).toBeGreaterThanOrEqual(0); + expect(primaryIndex).toBeGreaterThanOrEqual(0); + expect(primaryIndex).toBeLessThan(badgeIndex); + expect(strip(out)).toContain('feature/footer '); + expect(strip(out)).toContain('[PR#6]'); + } finally { + chalk.level = previousLevel; + } + }); +}); diff --git a/apps/kimi-code/test/tui/components/panels/help-panel.test.ts b/apps/kimi-code/test/tui/components/panels/help-panel.test.ts new file mode 100644 index 000000000..8b3489899 --- /dev/null +++ b/apps/kimi-code/test/tui/components/panels/help-panel.test.ts @@ -0,0 +1,102 @@ +import { describe, it, expect, vi } from 'vitest'; + +import type { KimiSlashCommand } from '#/tui/commands/index'; +import { HelpPanelComponent } from '#/tui/components/dialogs/help-panel'; +import { darkColors } from '#/tui/theme/colors'; + +function cmd(name: string, description: string, aliases: string[] = []): KimiSlashCommand { + return { + name, + aliases, + description, + }; +} + +function strip(text: string): string { + return text.replaceAll(/\u001B\[[0-9;]*m/g, ''); +} + +describe('HelpPanelComponent', () => { + it('renders keyboard shortcuts + slash commands sections', () => { + const panel = new HelpPanelComponent({ + commands: [cmd('exit', 'Exit', ['quit', 'q'])], + colors: darkColors, + onClose: () => {}, + }); + const out = strip(panel.render(80).join('\n')); + expect(out).toMatch(/help/); + expect(out).toMatch(/Keyboard shortcuts/); + expect(out).toMatch(/Shift-Tab/); + expect(out).toMatch(/Ctrl-O/); + expect(out).toMatch(/Slash commands/); + expect(out).toMatch(/\/exit \(\/quit, \/q\)/); + expect(out).toMatch(/Exit/); + }); + + it('sorts slash commands by name', () => { + const panel = new HelpPanelComponent({ + commands: [cmd('zebra', 'Z'), cmd('alpha', 'A'), cmd('mango', 'M')], + colors: darkColors, + onClose: () => {}, + }); + const out = strip(panel.render(80).join('\n')); + const alphaIdx = out.indexOf('/alpha'); + const mangoIdx = out.indexOf('/mango'); + const zebraIdx = out.indexOf('/zebra'); + expect(alphaIdx).toBeGreaterThan(-1); + expect(alphaIdx).toBeLessThan(mangoIdx); + expect(mangoIdx).toBeLessThan(zebraIdx); + }); + + it('Escape fires onClose', () => { + const onClose = vi.fn(); + const panel = new HelpPanelComponent({ + commands: [], + colors: darkColors, + onClose, + }); + panel.handleInput('\u001B'); // Esc + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it('q / Enter also close the panel', () => { + const onClose = vi.fn(); + const panel = new HelpPanelComponent({ + commands: [], + colors: darkColors, + onClose, + }); + panel.handleInput('q'); + panel.handleInput('\r'); + expect(onClose).toHaveBeenCalledTimes(2); + }); + + it('clips to maxVisible with a "showing X-Y of Z" tail', () => { + const many = Array.from({ length: 30 }, (_, i) => cmd(`cmd${String(i)}`, `Desc ${String(i)}`)); + const panel = new HelpPanelComponent({ + commands: many, + colors: darkColors, + onClose: () => {}, + maxVisible: 6, + }); + const out = strip(panel.render(80).join('\n')); + expect(out).toMatch(/showing 1-6 of/); + }); + + it('arrow keys shift the scroll window', () => { + const many = Array.from({ length: 30 }, (_, i) => cmd(`cmd${String(i)}`, 'd')); + const panel = new HelpPanelComponent({ + commands: many, + colors: darkColors, + onClose: () => {}, + maxVisible: 6, + }); + panel.handleInput('\u001B[B'); // ↓ + panel.handleInput('\u001B[B'); // ↓ + const out = strip(panel.render(80).join('\n')); + expect(out).toMatch(/showing 3-8 of/); + panel.handleInput('\u001B[A'); // ↑ + const out2 = strip(panel.render(80).join('\n')); + expect(out2).toMatch(/showing 2-7 of/); + }); +}); diff --git a/apps/kimi-code/test/tui/components/panels/plan-box.test.ts b/apps/kimi-code/test/tui/components/panels/plan-box.test.ts new file mode 100644 index 000000000..04f1f84bb --- /dev/null +++ b/apps/kimi-code/test/tui/components/panels/plan-box.test.ts @@ -0,0 +1,106 @@ +import { describe, expect, it } from 'vitest'; + +import { PlanBoxComponent } from '#/tui/components/messages/plan-box'; +import { darkColors } from '#/tui/theme/colors'; +import { createMarkdownTheme } from '#/tui/theme/pi-tui-theme'; + +const ESC = String.fromCodePoint(0x1b); +const BEL = String.fromCodePoint(0x07); + +// Strip CSI styling and OSC 8 hyperlink sequences from rendered output so +// visible text can be matched directly. +function strip(text: string): string { + return text + .replaceAll(new RegExp(`${ESC}\\[[0-9;]*m`, 'g'), '') + .replaceAll(new RegExp(`${ESC}\\]8;;[^${BEL}]*${BEL}`, 'g'), ''); +} + +const theme = createMarkdownTheme(darkColors); + +describe('PlanBoxComponent', () => { + it('falls back to bare " plan " title when no path is provided', () => { + const box = new PlanBoxComponent('# Hello', theme, darkColors.success); + const out = strip(box.render(60).join('\n')); + const top = out.split('\n')[0]!; + expect(top).toContain('┌ plan '); + expect(top).not.toContain('plan:'); + }); + + it('renders " plan: <basename> " in the top border without the directory prefix', () => { + const box = new PlanBoxComponent( + '# Hello', + theme, + darkColors.success, + '/tmp/projects/foo/.kimi-code/plans/very-long-slug-name.md', + ); + const out = strip(box.render(80).join('\n')); + const top = out.split('\n')[0]!; + expect(top).toContain(' plan: very-long-slug-name.md '); + expect(top).not.toContain('/tmp/'); + expect(top).not.toContain('…/'); + }); + + it('wraps the basename in an OSC 8 hyperlink targeting file://', () => { + const box = new PlanBoxComponent('# Hello', theme, darkColors.success, '/tmp/plan.md'); + const top = box.render(60)[0]!; + expect(top).toContain(`${ESC}]8;;file:///tmp/plan.md${BEL}plan.md${ESC}]8;;${BEL}`); + // After stripping OSC + CSI, visible width must respect the requested render width. + expect(strip(top).length).toBeLessThanOrEqual(60); + }); + + it('skips the hyperlink for non-absolute paths but still shows the basename', () => { + const box = new PlanBoxComponent('# Hello', theme, darkColors.success, 'relative/plan.md'); + const top = box.render(60)[0]!; + expect(top).not.toContain(`${ESC}]8;`); + expect(strip(top)).toContain(' plan: plan.md '); + }); + + it('degrades to bare " plan " when even the basename does not fit', () => { + const box = new PlanBoxComponent('# Hello', theme, darkColors.success, '/tmp/plan.md'); + const out = strip(box.render(14).join('\n')); + const top = out.split('\n')[0]!; + expect(top).toContain(' plan '); + expect(top).not.toContain('plan:'); + }); + + it('renders all lines when content fits under maxContentLines', () => { + const plan = Array.from({ length: 5 }, (_, i) => `- step ${String(i + 1)}`).join('\n'); + const box = new PlanBoxComponent(plan, theme, darkColors.success, undefined, { + maxContentLines: 20, + }); + const out = strip(box.render(80).join('\n')); + expect(out).toContain('step 1'); + expect(out).toContain('step 5'); + expect(out).not.toContain('ctrl+e to expand'); + }); + + it('truncates content over maxContentLines with a footer inside the box', () => { + const plan = Array.from({ length: 30 }, (_, i) => `- step ${String(i + 1)}`).join('\n'); + const box = new PlanBoxComponent(plan, theme, darkColors.success, undefined, { + maxContentLines: 10, + }); + const rendered = box.render(80); + const out = strip(rendered.join('\n')); + expect(out).toContain('step 1'); + expect(out).toContain('step 9'); + expect(out).not.toContain('step 10'); + expect(out).toMatch(/\.\.\. \(\d+ more lines, ctrl\+e to expand\)/); + // Footer must live inside the bordered box, not after it. + const footerIdx = rendered.findIndex((line) => strip(line).includes('ctrl+e to expand')); + const bottomIdx = rendered.findIndex((line) => strip(line).includes('└')); + expect(footerIdx).toBeGreaterThan(-1); + expect(footerIdx).toBeLessThan(bottomIdx); + expect(strip(rendered[footerIdx]!)).toMatch(/^\s+│.*│\s*$/); + }); + + it('renders the full plan when expanded is true, ignoring maxContentLines', () => { + const plan = Array.from({ length: 30 }, (_, i) => `- step ${String(i + 1)}`).join('\n'); + const box = new PlanBoxComponent(plan, theme, darkColors.success, undefined, { + maxContentLines: 10, + expanded: true, + }); + const out = strip(box.render(80).join('\n')); + expect(out).toContain('step 30'); + expect(out).not.toContain('ctrl+e to expand'); + }); +}); diff --git a/apps/kimi-code/test/tui/components/panels/todo-panel.test.ts b/apps/kimi-code/test/tui/components/panels/todo-panel.test.ts new file mode 100644 index 000000000..373d2d0c4 --- /dev/null +++ b/apps/kimi-code/test/tui/components/panels/todo-panel.test.ts @@ -0,0 +1,58 @@ +import { describe, it, expect } from 'vitest'; + +import { TodoPanelComponent, type TodoItem } from '#/tui/components/chrome/todo-panel'; +import { darkColors } from '#/tui/theme/colors'; + +function strip(text: string): string { + return text.replaceAll(/\u001B\[[0-9;]*m/g, ''); +} + +describe('TodoPanelComponent', () => { + it('returns no lines when empty (so the layout slot collapses)', () => { + const panel = new TodoPanelComponent(darkColors); + expect(panel.render(80)).toEqual([]); + expect(panel.isEmpty()).toBe(true); + }); + + it('renders a Todo header + one row per entry', () => { + const panel = new TodoPanelComponent(darkColors); + panel.setTodos([ + { title: 'Investigate parser', status: 'done' }, + { title: 'Add tests', status: 'in_progress' }, + { title: 'Open PR', status: 'pending' }, + ]); + const lines = panel.render(80).map(strip); + const joined = lines.join('\n'); + expect(joined).toMatch(/Todo/); + expect(joined).toMatch(/✓ Investigate parser/); + expect(joined).toMatch(/● Add tests/); + expect(joined).toMatch(/○ Open PR/); + }); + + it('setTodos replaces the list (not appends)', () => { + const panel = new TodoPanelComponent(darkColors); + panel.setTodos([{ title: 'old', status: 'pending' }]); + panel.setTodos([{ title: 'new', status: 'in_progress' }]); + const out = strip(panel.render(80).join('\n')); + expect(out).toMatch(/● new/); + expect(out).not.toMatch(/old/); + }); + + it('clear() wipes the list and reverts to empty', () => { + const panel = new TodoPanelComponent(darkColors); + panel.setTodos([{ title: 'x', status: 'pending' }]); + panel.clear(); + expect(panel.isEmpty()).toBe(true); + expect(panel.render(80)).toEqual([]); + }); + + it('defensive copy: external mutation does not leak into the panel', () => { + const panel = new TodoPanelComponent(darkColors); + const source: TodoItem[] = [{ title: 'foo', status: 'pending' }]; + panel.setTodos(source); + source[0] = { title: 'hacked', status: 'done' }; + const out = strip(panel.render(80).join('\n')); + expect(out).toMatch(/○ foo/); + expect(out).not.toMatch(/hacked/); + }); +}); diff --git a/apps/kimi-code/test/tui/components/panes/activity-pane.test.ts b/apps/kimi-code/test/tui/components/panes/activity-pane.test.ts new file mode 100644 index 000000000..383c43693 --- /dev/null +++ b/apps/kimi-code/test/tui/components/panes/activity-pane.test.ts @@ -0,0 +1,29 @@ +import { Text } from '@earendil-works/pi-tui'; +import { describe, expect, it } from 'vitest'; + +import { ActivityPaneComponent } from '#/tui/components/panes/activity-pane'; + +describe('ActivityPaneComponent', () => { + it('renders waiting loader after a spacer', () => { + const component = new ActivityPaneComponent({ + mode: 'waiting', + spinner: new Text('loading', 0, 0) as never, + }); + + expect(component.render(80).map((line) => line.trimEnd())).toEqual(['', 'loading']); + }); + + it('renders composing spinner after a spacer', () => { + const component = new ActivityPaneComponent({ + mode: 'composing', + spinner: new Text('working', 0, 0) as never, + }); + + expect(component.render(80).map((line) => line.trimEnd())).toEqual(['', 'working']); + }); + + it('renders nothing for hidden and thinking modes', () => { + expect(new ActivityPaneComponent({ mode: 'hidden' }).render(80)).toEqual([]); + expect(new ActivityPaneComponent({ mode: 'thinking' }).render(80)).toEqual([]); + }); +}); diff --git a/apps/kimi-code/test/tui/components/panes/queue-pane.test.ts b/apps/kimi-code/test/tui/components/panes/queue-pane.test.ts new file mode 100644 index 000000000..fbb5e4979 --- /dev/null +++ b/apps/kimi-code/test/tui/components/panes/queue-pane.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from 'vitest'; + +import { QueuePaneComponent } from '#/tui/components/panes/queue-pane'; +import { darkColors } from '#/tui/theme/colors'; + +function stripAnsi(text: string): string { + return text.replaceAll(/\u001B\[[0-9;]*m/g, ''); +} + +describe('QueuePaneComponent', () => { + it('renders queued messages with the steer hint', () => { + const component = new QueuePaneComponent({ + colors: darkColors, + isCompacting: false, + isStreaming: true, + canSteerImmediately: true, + messages: [ + { text: 'first message' }, + { text: '/skill:review src/app.ts' }, + ], + }); + + const output = stripAnsi(component.render(120).join('\n')); + + expect(output).toContain('❯ first message'); + expect(output).toContain('❯ /skill:review src/app.ts'); + expect(output).toContain('ctrl-s to steer immediately'); + }); + + it('renders compaction hint when waiting for compaction', () => { + const component = new QueuePaneComponent({ + colors: darkColors, + isCompacting: true, + isStreaming: false, + canSteerImmediately: true, + messages: [{ text: 'after compact' }], + }); + + const output = stripAnsi(component.render(120).join('\n')); + + expect(output).toContain('will send after compaction'); + }); + + it('omits the steer hint when immediate steering is disabled', () => { + const component = new QueuePaneComponent({ + colors: darkColors, + isCompacting: false, + isStreaming: true, + canSteerImmediately: false, + messages: [{ text: 'after init' }], + }); + + const output = stripAnsi(component.render(120).join('\n')); + + expect(output).toContain('will send after current task'); + expect(output).not.toContain('ctrl-s to steer immediately'); + }); +}); diff --git a/apps/kimi-code/test/tui/config.test.ts b/apps/kimi-code/test/tui/config.test.ts new file mode 100644 index 000000000..a7ddf4509 --- /dev/null +++ b/apps/kimi-code/test/tui/config.test.ts @@ -0,0 +1,111 @@ +import { mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { + DEFAULT_TUI_CONFIG, + INVALID_TUI_CONFIG_MESSAGE, + loadTuiConfig, + parseTuiConfig, + saveTuiConfig, + TuiConfigParseError, +} from '#/tui/config'; + +let dir: string; +let filePath: string; + +beforeEach(() => { + dir = join(tmpdir(), `kimi-tui-config-${Date.now()}-${Math.random().toString(36).slice(2)}`); + mkdirSync(dir, { recursive: true }); + filePath = join(dir, 'tui.toml'); +}); + +afterEach(() => { + rmSync(dir, { recursive: true, force: true }); +}); + +describe('TUI config', () => { + it('creates the default config when the file does not exist', async () => { + const result = await loadTuiConfig(filePath); + + expect(result).toEqual(DEFAULT_TUI_CONFIG); + const text = readFileSync(filePath, 'utf-8'); + expect(text).toContain('Terminal UI preferences for kimi-code.'); + expect(text).toContain('theme = "auto"'); + expect(text).toContain('command = ""'); + expect(text).toContain('[notifications]'); + expect(text).toContain('enabled = true'); + expect(text).toContain('notification_condition = "unfocused"'); + }); + + it('parses valid TOML', () => { + const config = parseTuiConfig(` +theme = "light" + +[editor] +command = "code --wait" + +[notifications] +enabled = false +notification_condition = "always" +`); + + expect(config).toEqual({ + theme: 'light', + editorCommand: 'code --wait', + notifications: { enabled: false, condition: 'always' }, + }); + }); + + it('normalizes an empty editor command to auto-detect', () => { + const config = parseTuiConfig(` +[editor] +command = " " +`); + + expect(config).toEqual({ + theme: 'auto', + editorCommand: null, + notifications: { enabled: true, condition: 'unfocused' }, + }); + }); + + it('falls back to default notifications when the section is omitted', () => { + const config = parseTuiConfig(`theme = "dark"`); + + expect(config.notifications).toEqual({ enabled: true, condition: 'unfocused' }); + }); + + it('throws TuiConfigParseError with fallback when parsing fails, leaving the file untouched', async () => { + writeFileSync(filePath, '[[[', 'utf-8'); + + const error = await loadTuiConfig(filePath).then( + () => null, + (error: unknown) => error, + ); + + expect(error).toBeInstanceOf(TuiConfigParseError); + expect((error as TuiConfigParseError).message).toBe(INVALID_TUI_CONFIG_MESSAGE); + expect((error as TuiConfigParseError).fallback).toEqual(DEFAULT_TUI_CONFIG); + expect(readFileSync(filePath, 'utf-8')).toBe('[[['); + }); + + it('saves and reloads the normalized config', async () => { + await saveTuiConfig( + { + theme: 'light', + editorCommand: 'vim', + notifications: { enabled: false, condition: 'always' }, + }, + filePath, + ); + + expect(await loadTuiConfig(filePath)).toEqual({ + theme: 'light', + editorCommand: 'vim', + notifications: { enabled: false, condition: 'always' }, + }); + }); +}); diff --git a/apps/kimi-code/test/tui/create-tui-state.test.ts b/apps/kimi-code/test/tui/create-tui-state.test.ts new file mode 100644 index 000000000..4da743841 --- /dev/null +++ b/apps/kimi-code/test/tui/create-tui-state.test.ts @@ -0,0 +1,103 @@ + +import { describe, it, expect } from 'vitest'; + +import { createTUIState, type KimiTUIOptions } from '#/tui/kimi-tui'; +import type { AppState } from '#/tui/types'; + +function fakeInitialAppState(): AppState { + return { + model: 'test-model', + workDir: '/tmp/kimi-test', + sessionId: 'sess-1', + yolo: false, + permissionMode: 'manual', + planMode: false, + thinking: false, + contextUsage: 0, + contextTokens: 0, + maxContextTokens: 0, + isStreaming: false, + isCompacting: false, + isReplaying: false, + streamingPhase: 'idle', + streamingStartTime: 0, + theme: 'dark', + version: '0.0.0-test', + editorCommand: null, + notifications: { enabled: true, condition: 'unfocused' }, + availableModels: {}, + availableProviders: {}, + sessionTitle: null, + }; +} + +describe('createTUIState', () => { + it('initializes all fields with sensible defaults', () => { + const opts: KimiTUIOptions = { + initialAppState: fakeInitialAppState(), + startup: { + continueLast: false, + yolo: false, + plan: false, + }, + }; + const state = createTUIState(opts); + + // UI objects are created. + expect(state.ui).toBeDefined(); + expect(state.terminal).toBeDefined(); + expect(state.transcriptContainer).toBeDefined(); + expect(state.activityContainer).toBeDefined(); + expect(state.todoPanelContainer).toBeDefined(); + expect(state.queueContainer).toBeDefined(); + expect(state.editorContainer).toBeDefined(); + expect(state.editor).toBeDefined(); + expect(state.footer).toBeDefined(); + expect(state.todoPanel).toBeDefined(); + expect(state.theme.colors).toBeDefined(); + expect(state.theme.markdownTheme).toBeDefined(); + + // App state is cloned from initialAppState, not reused by reference. + expect(state.appState).not.toBe(opts.initialAppState); + expect(state.appState.model).toBe('test-model'); + expect(state.appState.sessionId).toBe('sess-1'); + expect(state.startupState).toBe('pending'); + expect(state.startupNotice).toBeUndefined(); + + // LivePane defaults. + expect(state.livePane.mode).toBe('idle'); + expect(state.livePane.pendingApproval).toBeNull(); + expect(state.livePane.pendingQuestion).toBeNull(); + + // Empty collections. + expect(state.transcriptEntries).toHaveLength(0); + expect(state.queuedMessages).toHaveLength(0); + expect(state.pendingToolComponents.size).toBe(0); + expect(state.activeToolCalls.size).toBe(0); + expect(state.streamingToolCallArguments.size).toBe(0); + expect(state.backgroundAgents.size).toBe(0); + expect(state.backgroundAgentMetadata.size).toBe(0); + expect(state.renderedSkillActivationIds.size).toBe(0); + + // Boolean, counter, and optional-field defaults. + expect(state.toolOutputExpanded).toBe(false); + expect(state.showingSessionPicker).toBe(false); + expect(state.showingHelpPanel).toBe(false); + expect(state.externalEditorRunning).toBe(false); + expect(state.loadingSessions).toBe(false); + expect(state.currentTurnId).toBeUndefined(); + expect(state.currentStep).toBe(0); + expect(state.assistantStreamActive).toBe(false); + expect(state.assistantDraft).toBe(''); + expect(state.thinkingDraft).toBe(''); + expect(state.lastHistoryContent).toBeUndefined(); + expect(state.lastActivityMode).toBeUndefined(); + expect(state.activitySpinner).toBeUndefined(); + expect(state.activitySpinnerStyle).toBeUndefined(); + expect(state.streamingComponent).toBeUndefined(); + expect(state.streamingTranscriptEntry).toBeUndefined(); + expect(state.activeCompactionBlock).toBeUndefined(); + expect(state.pendingAgentGroup).toBeNull(); + expect(state.pendingReadGroup).toBeNull(); + }); +}); diff --git a/apps/kimi-code/test/tui/input/image-attachment-store.test.ts b/apps/kimi-code/test/tui/input/image-attachment-store.test.ts new file mode 100644 index 000000000..cb3e88c59 --- /dev/null +++ b/apps/kimi-code/test/tui/input/image-attachment-store.test.ts @@ -0,0 +1,62 @@ +import { describe, it, expect } from 'vitest'; + +import { + ImageAttachmentStore, + formatPlaceholder, + formatVideoPlaceholder, +} from '#/tui/utils/image-attachment-store'; + +describe('ImageAttachmentStore', () => { + it('assigns monotonically increasing ids starting at 1', () => { + const s = new ImageAttachmentStore(); + const a = s.addImage(new Uint8Array([1]), 'image/png', 10, 20); + const b = s.addVideo('video/quicktime', '/tmp/sample.mov'); + expect(a.id).toBe(1); + expect(b.id).toBe(2); + }); + + it('builds the canonical placeholder string', () => { + expect(formatPlaceholder(1, 640, 480)).toBe('[image #1 (640×480)]'); + expect(formatPlaceholder(42, 3840, 2160)).toBe('[image #42 (3840×2160)]'); + }); + + it('builds video placeholders with sanitized labels', () => { + expect(formatVideoPlaceholder(1, 'sample.mov')).toBe('[video #1 sample.mov]'); + expect(formatVideoPlaceholder(2, 'bad[name]\u0000.mov')).toBe('[video #2 bad_name__.mov]'); + }); + + it('uses the video filename basename as the placeholder label', () => { + const s = new ImageAttachmentStore(); + const att = s.addVideo('video/mp4', '/tmp/clips/sample.mp4'); + expect(att.filename).toBe('sample.mp4'); + expect(att.sourcePath).toBe('/tmp/clips/sample.mp4'); + expect(att.placeholder).toBe('[video #1 sample.mp4]'); + }); + + it('get() returns stored attachment', () => { + const s = new ImageAttachmentStore(); + const bytes = new Uint8Array([9, 8, 7]); + const att = s.addImage(bytes, 'image/jpeg', 100, 200); + expect(s.get(att.id)).toBe(att); + expect(s.get(99)).toBeUndefined(); + }); + + it('keeps pasted image bytes in memory', () => { + const s = new ImageAttachmentStore(); + const bytes = new Uint8Array([9, 8, 7]); + const att = s.addImage(bytes, 'image/jpeg', 100, 200); + expect(att.bytes).toBe(bytes); + expect(att.mime).toBe('image/jpeg'); + }); + + it('clear() resets ids and empties storage', () => { + const s = new ImageAttachmentStore(); + s.addImage(new Uint8Array(), 'image/png', 10, 10); + s.addImage(new Uint8Array(), 'image/png', 10, 10); + expect(s.size()).toBe(2); + s.clear(); + expect(s.size()).toBe(0); + const next = s.addImage(new Uint8Array(), 'image/png', 10, 10); + expect(next.id).toBe(1); + }); +}); diff --git a/apps/kimi-code/test/tui/input/image-placeholder.test.ts b/apps/kimi-code/test/tui/input/image-placeholder.test.ts new file mode 100644 index 000000000..cdc74e913 --- /dev/null +++ b/apps/kimi-code/test/tui/input/image-placeholder.test.ts @@ -0,0 +1,104 @@ +import { describe, it, expect } from 'vitest'; + +import { ImageAttachmentStore } from '#/tui/utils/image-attachment-store'; +import { extractMediaAttachments } from '#/tui/utils/image-placeholder'; + +function storeWith( + bytes: Uint8Array, + width = 640, + height = 480, +): { store: ImageAttachmentStore; placeholder: string } { + const store = new ImageAttachmentStore(); + const att = store.addImage(bytes, 'image/png', width, height); + return { store, placeholder: att.placeholder }; +} + +describe('extractMediaAttachments', () => { + it('returns no parts and hasMedia=false for plain text', () => { + const store = new ImageAttachmentStore(); + const r = extractMediaAttachments('hello world', store); + expect(r.hasMedia).toBe(false); + expect(r.parts).toEqual([]); + expect(r.imageAttachmentIds).toEqual([]); + expect(r.videoAttachmentIds).toEqual([]); + }); + + it('extracts a single matching placeholder into an image content part', () => { + const { store, placeholder } = storeWith(new Uint8Array([0xaa, 0xbb])); + const r = extractMediaAttachments(`describe ${placeholder} please`, store); + expect(r.hasMedia).toBe(true); + expect(r.imageAttachmentIds).toEqual([1]); + expect(r.parts).toEqual([ + { type: 'text', text: 'describe ' }, + { type: 'image_url', imageUrl: { url: 'data:image/png;base64,qrs=' } }, + { type: 'text', text: ' please' }, + ]); + }); + + it('keeps matched-placeholder order with multiple images', () => { + const store = new ImageAttachmentStore(); + const a = store.addImage(new Uint8Array([1]), 'image/png', 10, 10); + const b = store.addImage(new Uint8Array([2]), 'image/png', 20, 20); + const text = `first ${a.placeholder} then ${b.placeholder} end`; + const r = extractMediaAttachments(text, store); + expect(r.imageAttachmentIds).toEqual([1, 2]); + expect(r.parts).toEqual([ + { type: 'text', text: 'first ' }, + { type: 'image_url', imageUrl: { url: 'data:image/png;base64,AQ==' } }, + { type: 'text', text: ' then ' }, + { type: 'image_url', imageUrl: { url: 'data:image/png;base64,Ag==' } }, + { type: 'text', text: ' end' }, + ]); + }); + + it('keeps matched-placeholder order with mixed image and video attachments', () => { + const store = new ImageAttachmentStore(); + const img = store.addImage(new Uint8Array([1]), 'image/png', 10, 10); + const vid = store.addVideo('video/quicktime', '/tmp/clip.mov'); + const text = `first ${img.placeholder} then ${vid.placeholder} end`; + const r = extractMediaAttachments(text, store); + expect(r.imageAttachmentIds).toEqual([1]); + expect(r.videoAttachmentIds).toEqual([2]); + expect(r.parts).toEqual([ + { type: 'text', text: 'first ' }, + { type: 'image_url', imageUrl: { url: 'data:image/png;base64,AQ==' } }, + { type: 'text', text: ' then <video path="/tmp/clip.mov"></video> end' }, + ]); + }); + + it('leaves unresolved (typed by hand) placeholders as literal text', () => { + const store = new ImageAttachmentStore(); + const r = extractMediaAttachments('try [image #999 (1×1)] and [video #42 clip.mov] now', store); + expect(r.hasMedia).toBe(false); + expect(r.parts).toEqual([]); + }); + + it('uses pasted image bytes in data URLs', () => { + const bytes = new Uint8Array([0x89, 0x50, 0x4e, 0x47]); + const { store, placeholder } = storeWith(bytes); + const r = extractMediaAttachments(placeholder, store); + expect(r.parts).toHaveLength(1); + expect(r.parts[0]).toEqual({ + type: 'image_url', + imageUrl: { url: 'data:image/png;base64,iVBORw==' }, + }); + }); + + it('escapes media paths in generated tags', () => { + const store = new ImageAttachmentStore(); + const att = store.addVideo('video/mp4', '/tmp/a&"<>.mp4', 'sample.mp4'); + const r = extractMediaAttachments(att.placeholder, store); + expect(r.parts).toEqual([ + { type: 'text', text: '<video path="/tmp/a&"<>.mp4"></video>' }, + ]); + }); + + it('expands video placeholders backed by local files to readMediaFile video tags', () => { + const store = new ImageAttachmentStore(); + const att = store.addVideo('video/mp4', '/tmp/sample.mp4'); + const r = extractMediaAttachments(att.placeholder, store); + expect(r.hasMedia).toBe(true); + expect(r.videoAttachmentIds).toEqual([1]); + expect(r.parts).toEqual([{ type: 'text', text: '<video path="/tmp/sample.mp4"></video>' }]); + }); +}); diff --git a/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts b/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts new file mode 100644 index 000000000..f1d9dc17d --- /dev/null +++ b/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts @@ -0,0 +1,1172 @@ +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { + deleteAllKittyImages, + resetCapabilitiesCache, + setCapabilities, +} from '@earendil-works/pi-tui'; +import type { ApprovalRequest, ApprovalResponse, Event } from '@moonshot-ai/kimi-code-sdk'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { ModelSelectorComponent } from '#/tui/components/dialogs/model-selector'; +import { KimiTUI, type KimiTUIStartupInput, type TUIState } from '#/tui/kimi-tui'; +import type { QueuedMessage } from '#/tui/types'; +import type { ImageAttachmentStore } from '#/tui/utils/image-attachment-store'; + +function stripSgr(text: string): string { + return text.replaceAll(/\u001B\[[0-9;]*m/g, ''); +} + +interface MessageDriver { + state: TUIState; + init(): Promise<boolean>; + handleUserInput(text: string): void; + handleEvent(event: Event, sendQueued: (item: QueuedMessage) => void): void; + persistInputHistory(text: string): Promise<void>; + startSessionEventSubscription(): void; + getCurrentSessionId(): string; +} + +interface FeedbackDriver extends MessageDriver { + handleFeedbackCommand(): Promise<void>; + promptFeedbackInput(): Promise<string | undefined>; +} + +function makeStartupInput(): KimiTUIStartupInput { + return { + cliOptions: { + session: undefined, + continue: false, + yolo: false, + plan: false, + model: undefined, + outputFormat: undefined, + prompt: undefined, + skillsDirs: [], + }, + tuiConfig: { + theme: 'dark', + editorCommand: null, + notifications: { enabled: true, condition: 'unfocused' }, + }, + version: '0.0.0-test', + workDir: '/tmp/proj-a', + resolvedTheme: 'dark', + }; +} + +function makeSession(overrides: Record<string, unknown> = {}) { + return { + id: 'ses-1', + model: 'k2', + summary: { title: null }, + prompt: vi.fn(async () => {}), + steer: vi.fn(async () => {}), + init: vi.fn(async () => {}), + cancel: vi.fn(async () => {}), + cancelCompaction: vi.fn(async () => {}), + getStatus: vi.fn(async () => ({ + model: 'k2', + thinkingLevel: 'off', + permission: 'manual', + planMode: false, + contextTokens: 0, + maxContextTokens: 100, + contextUsage: 0, + })), + setApprovalHandler: vi.fn(), + setQuestionHandler: vi.fn(), + setModel: vi.fn(async () => {}), + setThinking: vi.fn(async () => {}), + setPermission: vi.fn(async () => {}), + setPlanMode: vi.fn(async () => {}), + onEvent: vi.fn(() => vi.fn()), + listMcpServers: vi.fn(async () => []), + listSkills: vi.fn(async () => []), + getResumeState: vi.fn(() => ({ + sessionMetadata: {}, + agents: { + main: { + status: { + model: 'k2', + thinkingLevel: 'off', + permission: 'manual', + planMode: false, + contextTokens: 0, + maxContextTokens: 100, + contextUsage: 0, + }, + context: { history: [] }, + replay: [], + }, + }, + })), + close: vi.fn(async () => {}), + ...overrides, + }; +} + +function makeHarness(session = makeSession(), overrides: Record<string, unknown> = {}) { + return { + getConfig: vi.fn(async () => ({ + models: { + k2: { model: 'moonshot-v1', maxContextSize: 100 }, + }, + })), + createSession: vi.fn(async () => session), + resumeSession: vi.fn(async () => session), + forkSession: vi.fn(async () => session), + listSessions: vi.fn(async () => []), + close: vi.fn(async () => {}), + track: vi.fn(), + setTelemetryContext: vi.fn(), + interactiveAgentId: 'main', + auth: { + status: vi.fn(), + login: vi.fn(), + logout: vi.fn(), + getManagedUsage: vi.fn(), + submitFeedback: vi.fn(async () => ({ kind: 'ok' })), + }, + ...overrides, + }; +} + +async function makeDriver( + session = makeSession(), + harnessOverrides: Record<string, unknown> = {}, +): Promise<{ + driver: MessageDriver; + session: ReturnType<typeof makeSession>; + harness: ReturnType<typeof makeHarness>; +}> { + const harness = makeHarness(session, harnessOverrides); + const driver = new KimiTUI(harness as never, makeStartupInput()) as unknown as MessageDriver; + vi.spyOn(driver.state.ui, 'requestRender').mockImplementation(() => {}); + vi.spyOn(driver.state.terminal, 'setProgress').mockImplementation(() => {}); + driver.persistInputHistory = vi.fn(async () => {}); + await driver.init(); + return { driver, session, harness }; +} + +function renderTranscript(driver: MessageDriver): string { + return driver.state.transcriptContainer.render(120).join('\n'); +} + +function countOccurrences(haystack: string, needle: string): number { + return haystack.split(needle).length - 1; +} + +const tempDirs: string[] = []; +const originalKimiCodeHome = process.env['KIMI_CODE_HOME']; +const originalVisual = process.env['VISUAL']; +const originalEditor = process.env['EDITOR']; + +async function makeTempHome(): Promise<string> { + const dir = await mkdtemp(join(tmpdir(), 'kimi-code-tui-')); + tempDirs.push(dir); + return dir; +} + +afterEach(async () => { + resetCapabilitiesCache(); + for (const dir of tempDirs.splice(0)) { + await rm(dir, { recursive: true, force: true }); + } + if (originalKimiCodeHome === undefined) { + delete process.env['KIMI_CODE_HOME']; + } else { + process.env['KIMI_CODE_HOME'] = originalKimiCodeHome; + } + if (originalVisual === undefined) { + delete process.env['VISUAL']; + } else { + process.env['VISUAL'] = originalVisual; + } + if (originalEditor === undefined) { + delete process.env['EDITOR']; + } else { + process.env['EDITOR'] = originalEditor; + } +}); + +describe('KimiTUI message flow', () => { + it('tracks editor shortcut and paste hooks', async () => { + const { driver, harness } = await makeDriver(); + harness.track.mockClear(); + + driver.state.editor.handleInput('\n'); + driver.state.editor.handleInput('\u001F'); + delete process.env['VISUAL']; + delete process.env['EDITOR']; + driver.state.editor.onOpenExternalEditor?.(); + driver.state.editor.onToggleToolExpand?.(); + driver.state.editor.onTextPaste?.(); + + expect(harness.track).toHaveBeenCalledWith('shortcut_newline', undefined); + expect(harness.track).toHaveBeenCalledWith('undo', undefined); + expect(harness.track).toHaveBeenCalledWith('shortcut_editor', undefined); + expect(harness.track).toHaveBeenCalledWith('shortcut_expand', undefined); + expect(harness.track).toHaveBeenCalledWith('shortcut_paste', { kind: 'text' }); + }); + + it('tracks /clear as the clear alias for /new', async () => { + const { driver, harness } = await makeDriver(makeSession({ id: 'ses-1' })); + const nextSession = makeSession({ id: 'ses-2' }); + harness.createSession.mockResolvedValueOnce(nextSession); + harness.track.mockClear(); + + driver.handleUserInput('/clear'); + + await vi.waitFor(() => { + expect(driver.getCurrentSessionId()).toBe('ses-2'); + }); + expect(harness.track).toHaveBeenCalledWith('input_command', { command: 'new' }); + expect(harness.track).toHaveBeenCalledWith('clear', undefined); + }); + + it('tracks theme changes from slash commands', async () => { + process.env['KIMI_CODE_HOME'] = await makeTempHome(); + const { driver, harness } = await makeDriver(); + harness.track.mockClear(); + + driver.handleUserInput('/theme light'); + + await vi.waitFor(() => { + expect(driver.state.appState.theme).toBe('light'); + }); + expect(harness.track).toHaveBeenCalledWith('input_command', { command: 'theme' }); + expect(harness.track).toHaveBeenCalledWith('theme_switch', { theme: 'light' }); + }); + + it('tracks successful feedback submissions only after the request succeeds', async () => { + const { driver, harness } = await makeDriver( + makeSession(), + { + getConfig: vi.fn(async () => ({ + models: { + k2: { + model: 'moonshot-v1', + maxContextSize: 100, + provider: 'managed:kimi-code', + }, + }, + })), + }, + ); + const feedbackDriver = driver as unknown as FeedbackDriver; + feedbackDriver.promptFeedbackInput = vi.fn(async () => 'useful feedback'); + harness.auth.submitFeedback.mockResolvedValueOnce({ kind: 'ok' }); + harness.track.mockClear(); + + await feedbackDriver.handleFeedbackCommand(); + + expect(harness.auth.submitFeedback).toHaveBeenCalledWith( + expect.objectContaining({ + content: 'useful feedback', + sessionId: 'ses-1', + version: 'kimi-code-0.0.0-test', + model: 'k2', + }), + ); + expect(harness.track).toHaveBeenCalledWith('feedback_submitted', undefined); + }); + + it('does not track feedback when the dialog is cancelled', async () => { + const { driver, harness } = await makeDriver( + makeSession(), + { + getConfig: vi.fn(async () => ({ + models: { + k2: { + model: 'moonshot-v1', + maxContextSize: 100, + provider: 'managed:kimi-code', + }, + }, + })), + }, + ); + const feedbackDriver = driver as unknown as FeedbackDriver; + feedbackDriver.promptFeedbackInput = vi.fn(async () => undefined); + harness.track.mockClear(); + + await feedbackDriver.handleFeedbackCommand(); + + expect(harness.auth.submitFeedback).not.toHaveBeenCalled(); + expect(harness.track).not.toHaveBeenCalledWith('feedback_submitted', undefined); + }); + + it('tracks blocked slash commands as invalid without counting them as executed commands', async () => { + const { driver, harness } = await makeDriver(); + driver.state.appState.isStreaming = true; + harness.track.mockClear(); + + driver.handleUserInput('/new'); + await Promise.resolve(); + + expect(harness.track).toHaveBeenCalledWith('input_command_invalid', { + reason: 'blocked', + command: 'new', + }); + expect(harness.track).not.toHaveBeenCalledWith('input_command', { command: 'new' }); + }); + + it('tracks Shift-Tab mode switches through the editor handler', async () => { + const { driver, session, harness } = await makeDriver(); + harness.track.mockClear(); + + driver.state.editor.onShiftTab?.(); + + await vi.waitFor(() => { + expect(session.setPlanMode).toHaveBeenCalledWith(true); + }); + expect(harness.track).toHaveBeenCalledWith('shortcut_plan_toggle', { enabled: true }); + expect(harness.track).toHaveBeenCalledWith('shortcut_mode_switch', { to_mode: 'plan' }); + }); + + it('routes /yolo through session permission state without app-layer telemetry duplication', async () => { + const { driver, session, harness } = await makeDriver(); + harness.track.mockClear(); + + driver.handleUserInput('/yolo on'); + + await vi.waitFor(() => { + expect(session.setPermission).toHaveBeenCalledWith('yolo'); + }); + expect(driver.state.appState).toMatchObject({ + yolo: true, + permissionMode: 'yolo', + }); + expect(harness.track).toHaveBeenCalledWith('input_command', { command: 'yolo' }); + expect(harness.track).not.toHaveBeenCalledWith('yolo_toggle', expect.anything()); + }); + + it('hydrates MCP server status after subscribing to session events', async () => { + const session = makeSession({ + listMcpServers: vi.fn(async () => [ + { + name: 'local-tools', + transport: 'stdio', + status: 'connected', + toolCount: 2, + }, + { + name: 'remote-tools', + transport: 'http', + status: 'failed', + toolCount: 0, + error: 'connection refused', + }, + ]), + }); + const { driver } = await makeDriver(session); + + driver.startSessionEventSubscription(); + await Promise.resolve(); + + expect(session.onEvent).toHaveBeenCalledOnce(); + expect(session.listMcpServers).toHaveBeenCalledOnce(); + const subscribeOrder = session.onEvent.mock.invocationCallOrder[0]; + const snapshotOrder = session.listMcpServers.mock.invocationCallOrder[0]; + if (subscribeOrder === undefined || snapshotOrder === undefined) { + throw new Error('Expected MCP status sync to subscribe and fetch a snapshot.'); + } + expect(subscribeOrder).toBeLessThan(snapshotOrder); + const transcript = renderTranscript(driver); + expect(transcript).toContain('MCP server "local-tools" connected'); + expect(transcript).toContain('2 tools (stdio)'); + expect(transcript).toContain('MCP server "remote-tools" failed: connection refused'); + }); + + it('deduplicates identical MCP status updates while allowing reconnect transitions', async () => { + const eventListeners: Array<(event: Event) => void> = []; + const connectedServer = { + name: 'local-tools', + transport: 'stdio', + status: 'connected', + toolCount: 2, + }; + const session = makeSession({ + onEvent: vi.fn((listener: (event: Event) => void) => { + eventListeners.push(listener); + return vi.fn(); + }), + listMcpServers: vi.fn(async () => [connectedServer]), + }); + const { driver } = await makeDriver(session); + + driver.startSessionEventSubscription(); + await Promise.resolve(); + eventListeners[0]?.({ + type: 'mcp.server.status', + agentId: 'main', + sessionId: 'ses-1', + server: connectedServer, + } as Event); + + expect(countOccurrences(renderTranscript(driver), 'MCP server "local-tools" connected')).toBe( + 1, + ); + + eventListeners[0]?.({ + type: 'mcp.server.status', + agentId: 'main', + sessionId: 'ses-1', + server: { + ...connectedServer, + status: 'pending', + toolCount: 0, + }, + } as Event); + eventListeners[0]?.({ + type: 'mcp.server.status', + agentId: 'main', + sessionId: 'ses-1', + server: connectedServer, + } as Event); + + expect(countOccurrences(renderTranscript(driver), 'MCP server "local-tools" connected')).toBe( + 2, + ); + }); + + it('does not let a late MCP snapshot overwrite a live status event', async () => { + const eventListeners: Array<(event: Event) => void> = []; + let resolveSnapshot: ( + servers: Array<{ + name: string; + transport: 'stdio' | 'http'; + status: 'pending' | 'connected' | 'failed' | 'disabled'; + toolCount: number; + error?: string; + }>, + ) => void = () => {}; + const snapshot = new Promise((resolve) => { + resolveSnapshot = resolve; + }); + const session = makeSession({ + onEvent: vi.fn((listener: (event: Event) => void) => { + eventListeners.push(listener); + return vi.fn(); + }), + listMcpServers: vi.fn(() => snapshot), + }); + const { driver } = await makeDriver(session); + + driver.startSessionEventSubscription(); + eventListeners[0]?.({ + type: 'mcp.server.status', + agentId: 'main', + sessionId: 'ses-1', + server: { + name: 'local-tools', + transport: 'stdio', + status: 'connected', + toolCount: 2, + }, + } as Event); + resolveSnapshot([ + { + name: 'local-tools', + transport: 'stdio', + status: 'failed', + toolCount: 0, + error: 'stale failure', + }, + ]); + await Promise.resolve(); + + const transcript = renderTranscript(driver); + expect(transcript).toContain('MCP server "local-tools" connected'); + expect(transcript).not.toContain('stale failure'); + }); + + it('sends normal editor input to the active session and marks the turn as waiting', async () => { + const { driver, session } = await makeDriver(); + + driver.handleUserInput('hello'); + + expect(session.prompt).toHaveBeenCalledWith('hello'); + expect(driver.state.appState.isStreaming).toBe(true); + expect(driver.state.appState.streamingPhase).toBe('waiting'); + expect(driver.state.livePane.mode).toBe('waiting'); + expect(driver.state.transcriptEntries).toEqual([ + expect.objectContaining({ + kind: 'user', + content: 'hello', + }), + ]); + }); + + it('sends pasted image placeholders as image content parts', async () => { + const { driver, session } = await makeDriver(); + const imageStore = (driver as unknown as { imageStore: ImageAttachmentStore }).imageStore; + const attachment = imageStore.addImage(new Uint8Array([0xaa, 0xbb]), 'image/png', 1, 1); + + driver.handleUserInput(`describe ${attachment.placeholder}`); + + expect(session.prompt).toHaveBeenCalledWith([ + { type: 'text', text: 'describe ' }, + { type: 'image_url', imageUrl: { url: 'data:image/png;base64,qrs=' } }, + ]); + expect(driver.state.transcriptEntries).toEqual([ + expect.objectContaining({ + kind: 'user', + content: `describe ${attachment.placeholder}`, + imageAttachmentIds: [attachment.id], + }), + ]); + }); + + it('queues editor input instead of prompting while a turn is already streaming', async () => { + const { driver, session, harness } = await makeDriver(); + driver.state.appState.isStreaming = true; + harness.track.mockClear(); + + driver.handleUserInput('queued message'); + + expect(session.prompt).not.toHaveBeenCalled(); + expect(driver.state.queuedMessages).toEqual([{ text: 'queued message', agentId: 'main' }]); + expect(driver.state.queueContainer.children.length).toBeGreaterThan(0); + expect(harness.track).toHaveBeenCalledWith('input_queue', undefined); + }); + + it('cancels active streaming from Escape and Ctrl-C editor shortcuts', async () => { + const { driver, session } = await makeDriver(); + + driver.state.appState.isStreaming = true; + driver.state.editor.onEscape?.(); + + expect(session.cancel).toHaveBeenCalledTimes(1); + + session.cancel.mockClear(); + driver.state.appState.isStreaming = true; + driver.state.editor.onCtrlC?.(); + + expect(session.cancel).toHaveBeenCalledTimes(1); + }); + + it('dispatches the next queued message after the active turn ends', async () => { + vi.useFakeTimers(); + try { + const { driver } = await makeDriver(); + const sendQueued = vi.fn(); + driver.state.appState.isStreaming = true; + driver.state.appState.streamingStartTime = 1; + driver.state.currentTurnId = '1'; + driver.state.queuedMessages = [{ text: 'next' }]; + + driver.handleEvent( + { + type: 'turn.ended', + agentId: 'main', + sessionId: 'ses-1', + turnId: 1, + reason: 'completed', + } as Event, + sendQueued, + ); + await vi.runAllTimersAsync(); + + expect(sendQueued).toHaveBeenCalledWith({ text: 'next' }); + expect(driver.state.queuedMessages).toEqual([]); + expect(driver.state.appState.isStreaming).toBe(false); + } finally { + vi.useRealTimers(); + } + }); + + it('cancels manual compaction from the editor', async () => { + const { driver, session } = await makeDriver(); + driver.handleEvent( + { + type: 'compaction.started', + agentId: 'main', + sessionId: 'ses-1', + trigger: 'manual', + } as Event, + vi.fn(), + ); + + driver.state.editor.onEscape?.(); + + expect(session.cancelCompaction).toHaveBeenCalledTimes(1); + + session.cancelCompaction.mockClear(); + driver.state.appState.isCompacting = true; + driver.state.editor.onCtrlC?.(); + + expect(session.cancelCompaction).toHaveBeenCalledTimes(1); + }); + + it('dispatches the next queued message after compaction is cancelled', async () => { + vi.useFakeTimers(); + try { + const { driver } = await makeDriver(); + const sendQueued = vi.fn(); + driver.handleEvent( + { + type: 'compaction.started', + agentId: 'main', + sessionId: 'ses-1', + trigger: 'manual', + } as Event, + sendQueued, + ); + driver.state.queuedMessages = [{ text: 'next' }]; + + driver.handleEvent( + { + type: 'compaction.cancelled', + agentId: 'main', + sessionId: 'ses-1', + } as Event, + sendQueued, + ); + await vi.runAllTimersAsync(); + + expect(driver.state.appState.isCompacting).toBe(false); + expect(driver.state.appState.streamingPhase).toBe('idle'); + expect(driver.state.queuedMessages).toEqual([]); + expect(sendQueued).toHaveBeenCalledWith({ text: 'next' }); + expect(driver.state.transcriptContainer.render(120).map(stripSgr).join('\n')).toContain( + 'Compaction cancelled', + ); + } finally { + vi.useRealTimers(); + } + }); + + it('renders an error instead of prompting when no model is selected', async () => { + const { driver, session } = await makeDriver(); + driver.state.appState.model = ''; + + driver.handleUserInput('hello'); + + expect(session.prompt).not.toHaveBeenCalled(); + expect(driver.state.transcriptContainer.render(120).join('\n')).toContain('LLM not set'); + }); + + it('dispatches /init to the active session and clears busy state after completion', async () => { + let resolveInit: (() => void) | undefined; + const session = makeSession({ + init: vi.fn( + () => + new Promise<void>((resolve) => { + resolveInit = resolve; + }), + ), + }); + const { driver, harness } = await makeDriver(session); + harness.track.mockClear(); + + driver.handleUserInput('/init'); + + await vi.waitFor(() => { + expect(session.init).toHaveBeenCalledTimes(1); + }); + expect(session.prompt).not.toHaveBeenCalled(); + expect(driver.state.appState.isStreaming).toBe(true); + expect(driver.state.livePane.mode).toBe('waiting'); + + resolveInit?.(); + + await vi.waitFor(() => { + expect(driver.state.appState.isStreaming).toBe(false); + }); + expect(driver.state.livePane.mode).toBe('idle'); + expect(harness.track).toHaveBeenCalledWith('init_complete', undefined); + }); + + it('queues Ctrl-S input instead of steering while /init is running', async () => { + let resolveInit: (() => void) | undefined; + const session = makeSession({ + init: vi.fn( + () => + new Promise<void>((resolve) => { + resolveInit = resolve; + }), + ), + }); + const { driver } = await makeDriver(session); + + driver.handleUserInput('/init'); + await vi.waitFor(() => { + expect(session.init).toHaveBeenCalledTimes(1); + }); + + driver.state.editor.setText('apply after init'); + driver.state.editor.onCtrlS?.(); + + expect(session.steer).not.toHaveBeenCalled(); + expect(driver.state.queuedMessages).toEqual([{ text: 'apply after init', agentId: 'main' }]); + expect(stripSgr(driver.state.queueContainer.render(120).join('\n'))).not.toContain( + 'ctrl-s to steer immediately', + ); + + resolveInit?.(); + + await vi.waitFor(() => { + expect(session.prompt).toHaveBeenCalledWith('apply after init'); + }); + expect(driver.state.queuedMessages).toEqual([]); + }); + + it('cancels the active /init request through the session', async () => { + let resolveInit: (() => void) | undefined; + const session = makeSession({ + init: vi.fn( + () => + new Promise<void>((resolve) => { + resolveInit = resolve; + }), + ), + }); + const { driver } = await makeDriver(session); + + driver.handleUserInput('/init'); + await vi.waitFor(() => { + expect(session.init).toHaveBeenCalledTimes(1); + }); + + driver.state.editor.onEscape?.(); + + await vi.waitFor(() => { + expect(session.cancel).toHaveBeenCalledTimes(1); + }); + + resolveInit?.(); + }); + + it('does not run /init when no model is selected', async () => { + const { driver, session } = await makeDriver(); + driver.state.appState.model = ''; + + driver.handleUserInput('/init'); + + expect(session.init).not.toHaveBeenCalled(); + expect(driver.state.transcriptContainer.render(120).join('\n')).toContain('LLM not set'); + }); + + it('shows the login prompt for auth.login_required session errors', async () => { + const { driver } = await makeDriver(); + + driver.handleEvent( + { + type: 'error', + agentId: 'main', + sessionId: 'ses-1', + code: 'auth.login_required', + message: 'OAuth provider credentials were rejected.', + retryable: false, + } as Event, + vi.fn(), + ); + + const transcript = stripSgr(renderTranscript(driver)); + expect(transcript).toContain('OAuth login expired. Send /login to login.'); + expect(transcript).not.toContain('[auth.login_required]'); + expect(transcript).not.toContain('kimi export'); + }); + + it('appends the kimi export hint beneath session error messages', async () => { + const { driver } = await makeDriver(); + + driver.handleEvent( + { + type: 'error', + agentId: 'main', + sessionId: 'ses-1', + code: 'compaction.failed', + message: "APIStatusError: 400 the message at position 82 with role 'assistant' must not be empty", + retryable: false, + } as Event, + vi.fn(), + ); + + const transcript = stripSgr(driver.state.transcriptContainer.render(200).join('\n')); + expect(transcript).toContain('Error: [compaction.failed]'); + expect(transcript).toContain('If this persists, run `kimi export ses-1`'); + expect(transcript).toContain("Please don't share it publicly"); + }); + + it('skips the kimi export hint when no active session id is set', async () => { + const { driver } = await makeDriver(); + driver.state.appState.sessionId = ''; + + driver.handleEvent( + { + type: 'error', + agentId: 'main', + sessionId: '', + code: 'compaction.failed', + message: 'boom', + retryable: false, + } as Event, + vi.fn(), + ); + + const transcript = stripSgr(renderTranscript(driver)); + expect(transcript).toContain('Error: [compaction.failed]'); + expect(transcript).not.toContain('kimi export'); + }); + + it('shows ExitPlanMode plan only in the current-plan card during approval', async () => { + const planContent = '# No Duplicate Plan\n\n- Do the non-duplicated plan work'; + const session = makeSession({ + getPlan: vi.fn(async () => ({ + id: 'no-duplicate-plan', + content: planContent, + path: '/tmp/no-duplicate-plan.md', + })), + }); + const { driver } = await makeDriver(session); + + driver.handleEvent( + { + type: 'tool.call.started', + agentId: 'main', + sessionId: 'ses-1', + turnId: 1, + toolCallId: 'call_exit_plan', + name: 'ExitPlanMode', + args: {}, + } as Event, + vi.fn(), + ); + + await vi.waitFor(() => { + const transcript = stripSgr(renderTranscript(driver)); + expect(transcript).toContain('Current plan'); + expect(countOccurrences(transcript, 'non-duplicated plan work')).toBe(1); + }); + + const approvalHandler = vi.mocked(session.setApprovalHandler).mock.calls[0]?.[0] as + | ((request: ApprovalRequest) => Promise<ApprovalResponse>) + | undefined; + if (approvalHandler === undefined) throw new Error('expected approval handler'); + void approvalHandler({ + turnId: 1, + toolCallId: 'call_exit_plan', + toolName: 'ExitPlanMode', + action: 'Review plan', + display: { + kind: 'plan_review', + plan: planContent, + path: '/tmp/no-duplicate-plan.md', + }, + }); + + await vi.waitFor(() => { + const approval = stripSgr(driver.state.editorContainer.render(120).join('\n')); + expect(approval).toContain('Ready to build with this plan?'); + expect(approval).not.toContain('non-duplicated plan work'); + expect(approval).not.toContain('/tmp/no-duplicate-plan.md'); + }); + }); + + it('renders /status using the active session runtime status', async () => { + const session = makeSession({ + getStatus: vi.fn(async () => ({ + model: 'k2', + thinkingLevel: 'high', + permission: 'auto', + planMode: true, + contextTokens: 25, + maxContextTokens: 100, + contextUsage: 0.25, + })), + }); + const { driver } = await makeDriver(session); + const getStatus = vi.mocked(session.getStatus); + const previousStatusCalls = getStatus.mock.calls.length; + + driver.handleUserInput('/status'); + + await vi.waitFor(() => { + expect(getStatus).toHaveBeenCalledTimes(previousStatusCalls + 1); + const output = stripSgr(driver.state.transcriptContainer.render(120).join('\n')); + expect(output).toContain(' Status '); + expect(output).toContain('>_ Kimi Code'); + expect(output).toContain('Model'); + expect(output).toContain('thinking on'); + expect(output).toContain('Permissions auto'); + expect(output).toContain('Plan mode on'); + expect(output).toContain('Context window'); + expect(output).toContain('25.0%'); + }); + }); + + it('renders /mcp using a fresh MCP server snapshot', async () => { + const session = makeSession({ + listMcpServers: vi.fn(async () => [ + { + name: 'local-tools', + transport: 'stdio', + status: 'connected', + toolCount: 2, + }, + { + name: 'remote-tools', + transport: 'http', + status: 'failed', + toolCount: 0, + error: 'connection refused', + }, + { + name: 'linear', + transport: 'http', + status: 'needs-auth', + toolCount: 0, + }, + { + name: 'disabled-tools', + transport: 'stdio', + status: 'disabled', + toolCount: 0, + }, + ]), + }); + const { driver } = await makeDriver(session); + const listMcpServers = vi.mocked(session.listMcpServers); + const previousCalls = listMcpServers.mock.calls.length; + + driver.handleUserInput('/mcp'); + + await vi.waitFor(() => { + expect(listMcpServers).toHaveBeenCalledTimes(previousCalls + 1); + const output = stripSgr(driver.state.transcriptContainer.render(140).join('\n')); + expect(output).toContain(' MCP (4) '); + expect(output).toContain('Servers'); + expect(output).toContain('local-tools'); + expect(output).toContain('connected'); + expect(output).toContain('stdio'); + expect(output).toContain('2 tools'); + expect(output).toContain('remote-tools'); + expect(output).toContain('failed'); + expect(output).toContain('connection refused'); + expect(output).toContain('linear'); + expect(output).toContain('needs auth'); + expect(output).toContain('/mcp-config login linear'); + expect(output).toContain('disabled-tools'); + expect(output).toContain('disabled'); + expect(output).toContain('1 connected · 1 needs auth · 1 failed · 1 disabled · 2 tools available'); + }); + }); + + it('renders an empty /mcp state when no MCP servers are configured', async () => { + const session = makeSession({ + listMcpServers: vi.fn(async () => []), + }); + const { driver } = await makeDriver(session); + + driver.handleUserInput('/mcp'); + + await vi.waitFor(() => { + const output = stripSgr(driver.state.transcriptContainer.render(120).join('\n')); + expect(output).toContain('No MCP servers configured. Run /mcp-config to add one.'); + }); + }); + + it('renders /mcp list failures as command boundary errors', async () => { + const session = makeSession({ + listMcpServers: vi.fn(async () => { + throw new Error('rpc unavailable'); + }), + }); + const { driver } = await makeDriver(session); + + driver.handleUserInput('/mcp'); + + await vi.waitFor(() => { + const output = stripSgr(driver.state.transcriptContainer.render(120).join('\n')); + expect(output).toContain('Error: Failed to load MCP servers: rpc unavailable'); + }); + }); + + it('applies /model selection with inline thinking state', async () => { + const session = makeSession(); + const { driver } = await makeDriver(session, { + getConfig: vi.fn(async () => ({ + models: { + k2: { + provider: 'managed:kimi-code', + model: 'kimi-k2', + maxContextSize: 100, + displayName: 'Kimi K2', + capabilities: ['thinking'], + }, + turbo: { + provider: 'managed:kimi-code', + model: 'kimi-turbo', + maxContextSize: 100, + displayName: 'Kimi Turbo', + capabilities: ['thinking'], + }, + }, + })), + }); + + driver.handleUserInput('/model turbo'); + + const picker = driver.state.editorContainer.children[0]; + expect(picker).toBeInstanceOf(ModelSelectorComponent); + const pickerOutput = stripSgr((picker as ModelSelectorComponent).render(120).join('\n')); + expect(pickerOutput).toContain('Kimi K2 (Kimi Code) ← current'); + expect(pickerOutput).toContain('❯ Kimi Turbo (Kimi Code)'); + (picker as ModelSelectorComponent).handleInput('\u001B[D'); + (picker as ModelSelectorComponent).handleInput('\r'); + + await vi.waitFor(() => { + expect(session.setModel).toHaveBeenCalledWith('turbo'); + expect(session.setThinking).toHaveBeenCalledWith('on'); + }); + expect(driver.state.appState.model).toBe('turbo'); + expect(driver.state.appState.thinking).toBe(true); + }); + + it('deletes Kitty inline images when /new clears the transcript', async () => { + setCapabilities({ images: 'kitty', trueColor: true, hyperlinks: true }); + const { driver, harness } = await makeDriver(makeSession({ id: 'ses-1' })); + const nextSession = makeSession({ id: 'ses-2' }); + harness.createSession.mockResolvedValueOnce(nextSession); + const write = vi.spyOn(driver.state.terminal, 'write').mockImplementation(() => {}); + + driver.handleUserInput('/new'); + + await vi.waitFor(() => { + expect(harness.createSession).toHaveBeenCalledTimes(2); + expect(driver.getCurrentSessionId()).toBe('ses-2'); + }); + expect(write).toHaveBeenCalledWith(deleteAllKittyImages()); + }); + + it('forks the active session and switches to the returned session', async () => { + const originalTitle = process.title; + const source = makeSession({ + id: 'ses-source', + summary: { title: 'Source title' }, + }); + const forked = makeSession({ + id: 'ses-fork', + summary: { title: 'Fork: Source title' }, + }); + const forkSession = vi.fn(async () => forked); + const { driver, harness } = await makeDriver(source, { forkSession }); + + try { + driver.handleUserInput('/fork ignored args'); + + await vi.waitFor(() => { + expect(forkSession).toHaveBeenCalledWith({ + id: 'ses-source', + title: 'Fork: Source title', + }); + expect(driver.getCurrentSessionId()).toBe('ses-fork'); + }); + expect(process.title).toBe('Fork: Source title'); + expect(source.close).toHaveBeenCalledOnce(); + expect(forked.onEvent).toHaveBeenCalledOnce(); + expect(harness.resumeSession).not.toHaveBeenCalled(); + expect(driver.state.transcriptContainer.render(120).join('\n')).toContain( + 'Session forked (ses-fork).', + ); + } finally { + process.title = originalTitle; + } + }); + + it('keeps the current session when fork fails', async () => { + const forkSession = vi.fn(async () => { + throw new Error('fork unavailable'); + }); + const { driver } = await makeDriver(makeSession({ id: 'ses-source' }), { forkSession }); + + driver.handleUserInput('/fork'); + + await vi.waitFor(() => { + expect(forkSession).toHaveBeenCalledWith({ + id: 'ses-source', + title: 'Fork: ses-source', + }); + expect(driver.getCurrentSessionId()).toBe('ses-source'); + expect(driver.state.transcriptContainer.render(120).join('\n')).toContain( + 'Failed to fork session: fork unavailable', + ); + }); + }); + + it('renders newly streamed thinking expanded when ctrl+o toggle was already active', async () => { + const { driver } = await makeDriver(); + driver.state.toolOutputExpanded = true; + + const longThinking = ['t1', 't2', 't3', 't4', 't5', 't6', 't7'].join('\n'); + driver.handleEvent( + { + type: 'thinking.delta', + agentId: 'main', + sessionId: 'ses-1', + delta: longThinking, + } as Event, + vi.fn(), + ); + driver.handleEvent( + { + type: 'assistant.delta', + agentId: 'main', + sessionId: 'ses-1', + delta: 'answer', + } as Event, + vi.fn(), + ); + + const transcript = stripSgr(renderTranscript(driver)); + expect(transcript).toContain('t7'); + expect(transcript).not.toContain('ctrl+o to expand'); + }); + + it('renders hook results without XML tags', async () => { + const { driver } = await makeDriver(); + + driver.handleEvent( + { + type: 'hook.result', + agentId: 'main', + sessionId: 'ses-1', + turnId: 1, + hookEvent: 'UserPromptSubmit', + content: '{}', + } as Event, + vi.fn(), + ); + + const transcript = stripSgr(renderTranscript(driver)); + expect(transcript).toContain('UserPromptSubmit hook'); + expect(transcript).toContain('{}'); + expect(transcript).not.toContain('<hook_result'); + }); + + it('renders empty hook results as empty status text', async () => { + const { driver } = await makeDriver(); + + driver.handleEvent( + { + type: 'hook.result', + agentId: 'main', + sessionId: 'ses-1', + turnId: 1, + hookEvent: 'UserPromptSubmit', + content: '', + } as Event, + vi.fn(), + ); + + const transcript = stripSgr(renderTranscript(driver)); + expect(transcript).toContain('UserPromptSubmit hook'); + expect(transcript).toContain('(empty)'); + expect(transcript).not.toContain('<hook_result'); + }); +}); diff --git a/apps/kimi-code/test/tui/kimi-tui-startup.test.ts b/apps/kimi-code/test/tui/kimi-tui-startup.test.ts new file mode 100644 index 000000000..9d93ebfd5 --- /dev/null +++ b/apps/kimi-code/test/tui/kimi-tui-startup.test.ts @@ -0,0 +1,678 @@ +import { describe, expect, it, vi } from "vitest"; + +import type { MigrationPlan } from "@moonshot-ai/migration-legacy"; +import { log } from "@moonshot-ai/kimi-code-sdk"; + +import { KimiTUI, type KimiTUIStartupInput, type TUIState } from "#/tui/kimi-tui"; +import { + DISABLE_TERMINAL_THEME_REPORTING, + ENABLE_TERMINAL_THEME_REPORTING, + OSC11_QUERY, + QUERY_TERMINAL_THEME, + TERMINAL_THEME_LIGHT, +} from "#/tui/utils/terminal-theme"; + +interface StartupDriver { + state: TUIState; + init(): Promise<boolean>; + handleLoginCommand(): Promise<void>; + handleLogoutCommand(): Promise<void>; +} + +interface ThemeTrackingDriver extends StartupDriver { + refreshTerminalThemeTracking(): void; +} + +interface MigrateExitDriver extends StartupDriver { + start(): Promise<void>; + onExit?: (code?: number) => Promise<void>; + runMigrationScreen(plan: unknown): Promise<unknown>; + initMainTui(): Promise<boolean>; + terminalFocusTrackingDispose?: () => void; +} + +const MIGRATION_PLAN: MigrationPlan = { + sourceHome: "/x/.kimi", + hasConfig: false, + hasMcp: false, + hasUserHistory: false, + oauthCredentials: [], + workdirs: [], + detectedPlugins: [], + detectedMcpOauthServers: [], + totalSessions: 0, +}; + +function makeStartupInput( + cliOptions: Partial<KimiTUIStartupInput["cliOptions"]> = {}, + tuiConfig: Partial<KimiTUIStartupInput["tuiConfig"]> = {}, + resolvedTheme: KimiTUIStartupInput["resolvedTheme"] = "dark", +): KimiTUIStartupInput { + return { + cliOptions: { + session: undefined, + continue: false, + yolo: false, + plan: false, + model: undefined, + outputFormat: undefined, + prompt: undefined, + skillsDirs: [], + ...cliOptions, + }, + tuiConfig: { + theme: "dark", + editorCommand: null, + notifications: { enabled: true, condition: "unfocused" }, + ...tuiConfig, + }, + version: "0.0.0-test", + workDir: "/tmp/proj-a", + resolvedTheme, + }; +} + +function makeSession(overrides: Record<string, unknown> = {}) { + return { + id: "ses-1", + model: "k2", + summary: { title: "Session title" }, + getStatus: vi.fn(async () => ({ + model: "k2", + thinkingLevel: "off", + permission: "manual", + planMode: false, + contextTokens: 10, + maxContextTokens: 100, + contextUsage: 0.1, + })), + setApprovalHandler: vi.fn(), + setQuestionHandler: vi.fn(), + setModel: vi.fn(async () => {}), + setThinking: vi.fn(async () => {}), + setPermission: vi.fn(async () => {}), + setPlanMode: vi.fn(async () => {}), + onEvent: vi.fn(() => () => {}), + listSkills: vi.fn(async () => []), + close: vi.fn(async () => {}), + ...overrides, + }; +} + +function loginRequiredError(): Error & { readonly code: string } { + return Object.assign(new Error('OAuth provider "managed:kimi-code" requires login.'), { + code: "auth.login_required", + }); +} + +function makeHarness(session = makeSession(), overrides: Record<string, unknown> = {}) { + return { + getConfig: vi.fn(async () => ({ + models: { + k2: { model: "moonshot-v1", maxContextSize: 100 }, + }, + })), + createSession: vi.fn(async () => session), + resumeSession: vi.fn(async () => session), + listSessions: vi.fn(async () => []), + close: vi.fn(async () => {}), + track: vi.fn(), + setTelemetryContext: vi.fn(), + auth: { + status: vi.fn(async () => ({ providers: [] })), + login: vi.fn(async () => {}), + logout: vi.fn(), + getManagedUsage: vi.fn(), + }, + ...overrides, + }; +} + +function makeDriver(harness: ReturnType<typeof makeHarness>, input: KimiTUIStartupInput) { + const driver = new KimiTUI(harness as never, input) as unknown as StartupDriver; + vi.spyOn(driver.state.ui, "requestRender").mockImplementation(() => {}); + vi.spyOn(driver.state.terminal, "setProgress").mockImplementation(() => {}); + return driver; +} + +type InputListener = Parameters<TUIState["ui"]["addInputListener"]>[0]; +const DARK_OSC11_REPORT = "\u001B]11;rgb:2828/2c2c/3434\u0007"; +const LIGHT_OSC11_REPORT = "\u001B]11;rgb:fafa/fbfb/fcfc\u0007"; + +function captureInputListeners(driver: StartupDriver) { + const listeners: InputListener[] = []; + const removeInputListener = vi.fn<() => void>(); + const write = vi.spyOn(driver.state.terminal, "write").mockImplementation(() => {}); + const addInputListener = vi + .spyOn(driver.state.ui, "addInputListener") + .mockImplementation((listener: InputListener) => { + listeners.push(listener); + return removeInputListener; + }); + + return { listeners, removeInputListener, write, addInputListener }; +} + +describe("KimiTUI startup", () => { + it("creates a fresh session from startup flags and syncs runtime state", async () => { + const session = makeSession({ + getStatus: vi.fn(async () => ({ + model: "k2", + thinkingLevel: "off", + permission: "yolo", + planMode: true, + contextTokens: 25, + maxContextTokens: 200, + contextUsage: 0.125, + })), + }); + const harness = makeHarness(session); + const driver = makeDriver(harness, makeStartupInput({ yolo: true, plan: true })); + + await expect(driver.init()).resolves.toBe(false); + + expect(harness.createSession).toHaveBeenCalledWith({ + workDir: "/tmp/proj-a", + permission: "yolo", + planMode: true, + }); + expect(session.setApprovalHandler).toHaveBeenCalledOnce(); + expect(session.setQuestionHandler).toHaveBeenCalledOnce(); + expect(harness.setTelemetryContext).toHaveBeenCalledWith({ sessionId: null }); + expect(harness.setTelemetryContext).toHaveBeenLastCalledWith({ sessionId: "ses-1" }); + expect(driver.state.startupState).toBe("ready"); + expect(driver.state.appState).toMatchObject({ + sessionId: "ses-1", + model: "k2", + permissionMode: "yolo", + yolo: true, + planMode: true, + contextTokens: 25, + maxContextTokens: 200, + contextUsage: 0.125, + sessionTitle: "Session title", + }); + }); + + it("resumes the latest session for --continue and marks history for replay", async () => { + const session = makeSession({ id: "ses-latest" }); + const harness = makeHarness(session, { + listSessions: vi.fn(async () => [{ id: "ses-latest" }, { id: "ses-old" }]), + }); + const driver = makeDriver(harness, makeStartupInput({ continue: true })); + + await expect(driver.init()).resolves.toBe(true); + + expect(harness.resumeSession).toHaveBeenCalledWith({ id: "ses-latest" }); + expect(harness.createSession).not.toHaveBeenCalled(); + expect(driver.state.startupState).toBe("ready"); + expect(driver.state.appState.sessionId).toBe("ses-latest"); + }); + + it("passes the CLI model override when creating a fresh startup session", async () => { + const harness = makeHarness(); + const driver = makeDriver(harness, makeStartupInput({ model: "kimi-code/k2.5" })); + + await expect(driver.init()).resolves.toBe(false); + + expect(harness.createSession).toHaveBeenCalledWith({ + workDir: "/tmp/proj-a", + model: "kimi-code/k2.5", + permission: undefined, + planMode: undefined, + }); + }); + + it("applies the CLI model override when resuming a startup session", async () => { + let model = "k2"; + const session = makeSession({ + setModel: vi.fn(async (nextModel: string) => { + model = nextModel; + }), + getStatus: vi.fn(async () => ({ + model, + thinkingLevel: "off", + permission: "manual", + planMode: false, + contextTokens: 10, + maxContextTokens: 100, + contextUsage: 0.1, + })), + }); + const harness = makeHarness(session, { + listSessions: vi.fn(async () => [{ id: "ses-latest" }]), + }); + const driver = makeDriver( + harness, + makeStartupInput({ continue: true, model: "kimi-code/k2.5" }), + ); + + await expect(driver.init()).resolves.toBe(true); + + expect(session.setModel).toHaveBeenCalledWith("kimi-code/k2.5"); + expect(driver.state.appState.model).toBe("kimi-code/k2.5"); + }); + + it("enters picker startup for bare --session without creating a session", async () => { + const harness = makeHarness(); + const driver = makeDriver(harness, makeStartupInput({ session: "" })); + + await expect(driver.init()).resolves.toBe(false); + + expect(harness.createSession).not.toHaveBeenCalled(); + expect(harness.resumeSession).not.toHaveBeenCalled(); + expect(driver.state.startupState).toBe("picker"); + }); + + it("tracks terminal theme reports while auto theme is active", () => { + const harness = makeHarness(); + const driver = makeDriver( + harness, + makeStartupInput({}, { theme: "auto" }, "dark"), + ) as unknown as ThemeTrackingDriver; + const { listeners, write, addInputListener } = captureInputListeners(driver); + + driver.refreshTerminalThemeTracking(); + + expect(addInputListener).toHaveBeenCalledOnce(); + expect(write).toHaveBeenCalledWith(ENABLE_TERMINAL_THEME_REPORTING); + expect(write).toHaveBeenCalledWith(OSC11_QUERY); + expect(write).toHaveBeenCalledWith(QUERY_TERMINAL_THEME); + expect(listeners).toHaveLength(1); + + write.mockClear(); + expect(listeners[0]?.(TERMINAL_THEME_LIGHT)).toEqual({ consume: true }); + expect(write).toHaveBeenCalledWith(OSC11_QUERY); + expect(driver.state.appState.theme).toBe("auto"); + expect(driver.state.theme.resolvedTheme).toBe("dark"); + expect(driver.state.ui.requestRender).not.toHaveBeenCalled(); + + expect(listeners[0]?.(DARK_OSC11_REPORT)).toEqual({ consume: true }); + expect(driver.state.appState.theme).toBe("auto"); + expect(driver.state.theme.resolvedTheme).toBe("dark"); + expect(driver.state.ui.requestRender).not.toHaveBeenCalled(); + + expect(listeners[0]?.(LIGHT_OSC11_REPORT)).toEqual({ consume: true }); + expect(driver.state.appState.theme).toBe("auto"); + expect(driver.state.theme.resolvedTheme).toBe("light"); + expect(driver.state.ui.requestRender).toHaveBeenCalled(); + }); + + it("does not track terminal theme reports for explicit themes", () => { + const harness = makeHarness(); + const driver = makeDriver(harness, makeStartupInput()) as unknown as ThemeTrackingDriver; + const { write, addInputListener } = captureInputListeners(driver); + + driver.refreshTerminalThemeTracking(); + + expect(addInputListener).not.toHaveBeenCalled(); + expect(write).not.toHaveBeenCalled(); + }); + + it("disables terminal theme reports after leaving auto theme", () => { + const harness = makeHarness(); + const driver = makeDriver( + harness, + makeStartupInput({}, { theme: "auto" }, "dark"), + ) as unknown as ThemeTrackingDriver; + const { write, removeInputListener } = captureInputListeners(driver); + + driver.refreshTerminalThemeTracking(); + driver.state.appState.theme = "dark"; + driver.refreshTerminalThemeTracking(); + + expect(removeInputListener).toHaveBeenCalledOnce(); + expect(write).toHaveBeenCalledWith(DISABLE_TERMINAL_THEME_REPORTING); + }); + + it("starts TUI without a session when fresh startup needs OAuth login", async () => { + const harness = makeHarness(makeSession(), { + createSession: vi.fn(async () => { + throw loginRequiredError(); + }), + }); + const driver = makeDriver(harness, makeStartupInput()); + + await expect(driver.init()).resolves.toBe(false); + + expect(driver.state.startupState).toBe("ready"); + expect(driver.state.startupNotice).toContain("OAuth login expired"); + expect(driver.state.appState).toMatchObject({ + sessionId: "", + model: "", + thinking: false, + contextTokens: 0, + maxContextTokens: 0, + contextUsage: 0, + sessionTitle: null, + }); + }); + + it("preserves fresh startup yolo and plan intent after OAuth login", async () => { + const session = makeSession({ + getStatus: vi.fn(async () => ({ + model: "k2", + thinkingLevel: "off", + permission: "yolo", + planMode: true, + contextTokens: 10, + maxContextTokens: 100, + contextUsage: 0.1, + })), + }); + const createSession = vi + .fn() + .mockRejectedValueOnce(loginRequiredError()) + .mockResolvedValueOnce(session); + const harness = makeHarness(session, { + getConfig: vi.fn(async () => ({ + defaultModel: "k2", + defaultThinking: false, + models: { + k2: { model: "moonshot-v1", maxContextSize: 100 }, + }, + })), + createSession, + }); + const driver = makeDriver(harness, makeStartupInput({ yolo: true, plan: true })); + + await expect(driver.init()).resolves.toBe(false); + + expect(driver.state.appState).toMatchObject({ + sessionId: "", + model: "", + permissionMode: "yolo", + yolo: true, + planMode: true, + }); + + vi.spyOn(driver as any, 'promptPlatformSelection').mockResolvedValue('kimi-code'); + await driver.handleLoginCommand(); + + expect(createSession).toHaveBeenNthCalledWith(1, { + workDir: "/tmp/proj-a", + permission: "yolo", + planMode: true, + }); + expect(createSession).toHaveBeenNthCalledWith(2, { + workDir: "/tmp/proj-a", + model: "k2", + thinking: "off", + permission: "yolo", + planMode: true, + }); + expect(driver.state.appState).toMatchObject({ + sessionId: "ses-1", + model: "k2", + permissionMode: "yolo", + yolo: true, + planMode: true, + }); + }); + + it("does not force manual permission after OAuth login without --yolo", async () => { + const session = makeSession({ + getStatus: vi.fn(async () => ({ + model: "k2", + thinkingLevel: "off", + permission: "auto", + planMode: false, + contextTokens: 10, + maxContextTokens: 100, + contextUsage: 0.1, + })), + }); + const createSession = vi + .fn() + .mockRejectedValueOnce(loginRequiredError()) + .mockResolvedValueOnce(session); + const harness = makeHarness(session, { + getConfig: vi.fn(async () => ({ + defaultModel: "k2", + defaultThinking: false, + models: { + k2: { model: "moonshot-v1", maxContextSize: 100 }, + }, + })), + createSession, + }); + const driver = makeDriver(harness, makeStartupInput()); + + await expect(driver.init()).resolves.toBe(false); + vi.spyOn(driver as any, 'promptPlatformSelection').mockResolvedValue('kimi-code'); + await driver.handleLoginCommand(); + + expect(createSession).toHaveBeenNthCalledWith(2, { + workDir: "/tmp/proj-a", + model: "k2", + thinking: "off", + permission: undefined, + planMode: undefined, + }); + expect(driver.state.appState).toMatchObject({ + permissionMode: "auto", + yolo: false, + }); + }); + + it("syncs configured thinking after OAuth login refreshes an active session", async () => { + const session = makeSession(); + const harness = makeHarness(session, { + getConfig: vi.fn(async () => ({ + defaultModel: "k2", + defaultThinking: true, + models: { + k2: { model: "moonshot-v1", maxContextSize: 100 }, + }, + })), + }); + const driver = makeDriver(harness, makeStartupInput()); + + await expect(driver.init()).resolves.toBe(false); + expect(driver.state.appState.thinking).toBe(false); + + vi.spyOn(driver as any, 'promptPlatformSelection').mockResolvedValue('kimi-code'); + await driver.handleLoginCommand(); + + expect(session.setModel).toHaveBeenCalledWith("k2"); + expect(session.setThinking).toHaveBeenCalledWith("on"); + expect(driver.state.appState).toMatchObject({ + model: "k2", + thinking: true, + maxContextTokens: 100, + }); + expect(harness.track).toHaveBeenCalledWith("login", { + provider: "managed:kimi-code", + already_logged_in: false, + }); + }); + + it("tracks login with already_logged_in when a token already exists", async () => { + const session = makeSession(); + const harness = makeHarness(session, { + auth: { + status: vi.fn(async () => ({ + providers: [{ providerName: "managed:kimi-code", hasToken: true }], + })), + login: vi.fn(async () => {}), + logout: vi.fn(), + getManagedUsage: vi.fn(), + }, + }); + const driver = makeDriver(harness, makeStartupInput()); + + await expect(driver.init()).resolves.toBe(false); + harness.track.mockClear(); + + vi.spyOn(driver as any, 'promptPlatformSelection').mockResolvedValue('kimi-code'); + await driver.handleLoginCommand(); + + expect(harness.auth.login).toHaveBeenCalledWith( + "managed:kimi-code", + expect.objectContaining({ + signal: expect.any(AbortSignal), + onDeviceCode: expect.any(Function), + }), + ); + expect(harness.track).toHaveBeenCalledWith("login", { + provider: "managed:kimi-code", + already_logged_in: true, + }); + }); + + it("logs login failures with session context", async () => { + const warn = vi.spyOn(log, "warn").mockImplementation(() => {}); + const session = makeSession(); + const loginError = new Error("Failed to list Kimi Code models (HTTP 402)."); + const harness = makeHarness(session, { + auth: { + status: vi.fn(async () => ({ providers: [] })), + login: vi.fn(async () => { + throw loginError; + }), + logout: vi.fn(), + getManagedUsage: vi.fn(), + }, + }); + const driver = makeDriver(harness, makeStartupInput()); + + try { + await expect(driver.init()).resolves.toBe(false); + + vi.spyOn(driver as any, 'promptPlatformSelection').mockResolvedValue('kimi-code'); + await driver.handleLoginCommand(); + + expect(harness.auth.login).toHaveBeenCalledWith( + "managed:kimi-code", + expect.objectContaining({ + signal: expect.any(AbortSignal), + onDeviceCode: expect.any(Function), + }), + ); + expect(warn).toHaveBeenCalledWith( + "login failed", + expect.objectContaining({ + providerName: "managed:kimi-code", + alreadyLoggedIn: false, + sessionId: "ses-1", + error: expect.objectContaining({ + message: "Failed to list Kimi Code models (HTTP 402).", + }), + }), + ); + } finally { + warn.mockRestore(); + } + }); + + it("tracks logout after managed credentials and session state are cleared", async () => { + const session = makeSession(); + const harness = makeHarness(session); + const driver = makeDriver(harness, makeStartupInput()); + + await expect(driver.init()).resolves.toBe(false); + harness.track.mockClear(); + + await driver.handleLogoutCommand(); + + expect(harness.auth.logout).toHaveBeenCalledWith("managed:kimi-code"); + expect(session.close).toHaveBeenCalledOnce(); + expect(driver.state.appState).toMatchObject({ + sessionId: "", + model: "", + sessionTitle: null, + }); + expect(harness.track).toHaveBeenCalledWith("logout", { provider: "managed:kimi-code" }); + }); + + it("starts TUI without replaying when --continue needs OAuth login", async () => { + const harness = makeHarness(makeSession(), { + listSessions: vi.fn(async () => [{ id: "ses-latest" }]), + resumeSession: vi.fn(async () => { + throw loginRequiredError(); + }), + }); + const driver = makeDriver(harness, makeStartupInput({ continue: true })); + + await expect(driver.init()).resolves.toBe(false); + + expect(harness.resumeSession).toHaveBeenCalledWith({ id: "ses-latest" }); + expect(harness.createSession).not.toHaveBeenCalled(); + expect(driver.state.startupState).toBe("ready"); + expect(driver.state.appState.sessionId).toBe(""); + }); + + it("starts TUI without replaying when an explicit resume needs OAuth login", async () => { + const harness = makeHarness(makeSession(), { + listSessions: vi.fn(async () => [{ id: "ses-target" }]), + resumeSession: vi.fn(async () => { + throw loginRequiredError(); + }), + }); + const driver = makeDriver(harness, makeStartupInput({ session: "ses-target" })); + + await expect(driver.init()).resolves.toBe(false); + + expect(harness.resumeSession).toHaveBeenCalledWith({ id: "ses-target" }); + expect(driver.state.startupState).toBe("ready"); + expect(driver.state.appState.sessionId).toBe(""); + }); + + it("disposes terminal focus/theme tracking on the kimi migrate exit", async () => { + const harness = makeHarness(); + const driver = makeDriver(harness, { + ...makeStartupInput(), + migrationPlan: MIGRATION_PLAN, + migrateOnly: true, + }) as unknown as MigrateExitDriver; + // pi-tui start/stop and focus tracking touch the real TTY — stub the I/O. + vi.spyOn(driver.state.ui, "start").mockImplementation(() => {}); + vi.spyOn(driver.state.ui, "stop").mockImplementation(() => {}); + vi.spyOn(driver.state.terminal, "write").mockImplementation(() => {}); + // The migration screen would await user input; resolve it immediately. + vi.spyOn(driver, "runMigrationScreen").mockResolvedValue({ decision: "later" }); + const onExit = vi.fn(async () => {}); + driver.onExit = onExit; + + await driver.start(); + + // `kimi migrate` exits via process.exit; startEventLoop() installed focus + // tracking, so the exit path must dispose it — otherwise the terminal + // keeps emitting focus/OSC sequences after the command finishes. + expect(driver.terminalFocusTrackingDispose).toBeUndefined(); + expect(onExit).toHaveBeenCalledWith(0); + }); + + it("disposes terminal tracking when post-migration startup fails", async () => { + const harness = makeHarness(); + const driver = makeDriver(harness, { + ...makeStartupInput(), + migrationPlan: MIGRATION_PLAN, + migrateOnly: false, + }) as unknown as MigrateExitDriver; + vi.spyOn(driver.state.ui, "start").mockImplementation(() => {}); + vi.spyOn(driver.state.ui, "stop").mockImplementation(() => {}); + vi.spyOn(driver.state.terminal, "write").mockImplementation(() => {}); + // The migration screen resolves "later"; startup then continues into + // initMainTui(), which fails (e.g. a session-resume error). + vi.spyOn(driver, "runMigrationScreen").mockResolvedValue({ decision: "later" }); + vi.spyOn(driver, "initMainTui").mockRejectedValue(new Error("resume boom")); + + await expect(driver.start()).rejects.toThrow("resume boom"); + + // The focus tracking installed by startEventLoop() must be torn down + // before the error propagates — not left active after the process exits. + expect(driver.terminalFocusTrackingDispose).toBeUndefined(); + }); + + it("keeps non-login startup session errors fatal", async () => { + const harness = makeHarness(makeSession(), { + createSession: vi.fn(async () => { + throw new Error("provider config is invalid"); + }), + }); + const driver = makeDriver(harness, makeStartupInput()); + + await expect(driver.init()).rejects.toThrow("provider config is invalid"); + }); +}); diff --git a/apps/kimi-code/test/tui/media-url.test.ts b/apps/kimi-code/test/tui/media-url.test.ts new file mode 100644 index 000000000..79c73b9f9 --- /dev/null +++ b/apps/kimi-code/test/tui/media-url.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from 'vitest'; + +import { mediaUrlPartToText, summarizeDataUrl } from '#/tui/utils/media-url'; + +describe('mediaUrlPartToText', () => { + it('keeps non-data URLs as escaped XML-like references', () => { + expect(mediaUrlPartToText('image', 'file:///tmp/a&b".png')).toBe( + '<image url="file:///tmp/a&b".png">', + ); + }); + + it('summarizes base64 data URLs without returning the payload', () => { + expect(mediaUrlPartToText('image', 'data:image/png;base64,qrs=')).toBe( + '[image image/png, 2 B]', + ); + }); + + it('formats larger base64 payload sizes compactly', () => { + const oneKib = 'A'.repeat(1368); + expect(mediaUrlPartToText('video', `data:video/mp4;base64,${oneKib}`)).toBe( + '[video video/mp4, 1.0 KB]', + ); + }); +}); + +describe('summarizeDataUrl', () => { + it('returns undefined for regular URLs', () => { + expect(summarizeDataUrl('https://example.com/a.png')).toBeUndefined(); + }); + + it('parses MIME and decoded byte count for base64 data URLs', () => { + expect(summarizeDataUrl('data:image/png;base64,AQIDBA==')).toEqual({ + mime: 'image/png', + bytes: 4, + }); + }); +}); diff --git a/apps/kimi-code/test/tui/printable-key-guard.test.ts b/apps/kimi-code/test/tui/printable-key-guard.test.ts new file mode 100644 index 000000000..f7be25b1c --- /dev/null +++ b/apps/kimi-code/test/tui/printable-key-guard.test.ts @@ -0,0 +1,69 @@ +/** + * Guard test: scan every TUI component and reject `data === '<printable>'` + * bare-literal comparisons. When the terminal enables the Kitty keyboard + * protocol (e.g. the VSCode integrated terminal), printable keys arrive as + * CSI-u sequences, so a bare comparison silently disables the shortcut. + * See `apps/kimi-code/src/tui/utils/printable-key.ts`. + * + * Every printable-character comparison must first go through + * `printableChar(data)`. Control characters (codepoint < 32) should use + * `matchesKey` with `Key.*` or stay as escape literals (`'\t'`, ...); + * those are exempted by the guard's regex. + */ + +import { readdirSync, readFileSync, statSync } from 'node:fs'; +import { join } from 'node:path'; + +import { describe, expect, it } from 'vitest'; + +const COMPONENTS_ROOT = join(__dirname, '..', '..', 'src', 'tui', 'components'); + +function walk(dir: string): string[] { + const out: string[] = []; + for (const entry of readdirSync(dir)) { + const p = join(dir, entry); + const s = statSync(p); + if (s.isDirectory()) out.push(...walk(p)); + else if (entry.endsWith('.ts') || entry.endsWith('.tsx')) out.push(p); + } + return out; +} + +// Single-character visible-ASCII (codepoint 32-126) bare-literal +// comparisons, e.g. `data === 'q'`, `data === ' '`. The regex deliberately +// permits escape sequences (`data === '\t'`, control-char literals), +// multi-character sequences (`data === '[A'`), and comparisons on +// variables other than `data` (the decoded value is usually `k` or +// `printable`). +const BARE_PRINTABLE = /\bdata\s*===\s*'([\u0020-\u007E])'/g; + +describe('TUI handleInput — printable-key guard', () => { + it('forbids bare-literal printable comparisons on `data` (use printableChar)', () => { + const offenders: { file: string; line: number; snippet: string }[] = []; + for (const file of walk(COMPONENTS_ROOT)) { + const content = readFileSync(file, 'utf8'); + const lines = content.split('\n'); + for (let i = 0; i < lines.length; i++) { + const line = lines[i] ?? ''; + if (line.trimStart().startsWith('//')) continue; + BARE_PRINTABLE.lastIndex = 0; + let m: RegExpExecArray | null; + while ((m = BARE_PRINTABLE.exec(line)) !== null) { + offenders.push({ + file: file.slice(file.indexOf('src/')), + line: i + 1, + snippet: line.trim(), + }); + } + } + } + expect( + offenders, + `Found bare-literal \`data === '...'\` comparisons. ` + + `In VSCode/Kitty terminals these never match because keys arrive as ` + + `CSI-u sequences. Use \`printableChar(data)\` from ` + + `\`@/tui/utils/printable-key\` and compare the decoded value instead.\n` + + offenders.map((o) => ` ${o.file}:${String(o.line)} ${o.snippet}`).join('\n'), + ).toEqual([]); + }); +}); diff --git a/apps/kimi-code/test/tui/replay-ops.test.ts b/apps/kimi-code/test/tui/replay-ops.test.ts new file mode 100644 index 000000000..2a366e6cc --- /dev/null +++ b/apps/kimi-code/test/tui/replay-ops.test.ts @@ -0,0 +1,566 @@ +import type { + AgentReplayRecord, + BackgroundTaskInfo, + ContentPart, + PromptOrigin, + Role, + ToolCall, +} from '@moonshot-ai/kimi-code-sdk'; +import { describe, expect, it } from 'vitest'; + +import { projectReplayRecords } from '#/tui/actions/replay-ops'; + +interface ReplayMessageExtra { + readonly toolCalls?: readonly ToolCall[]; + readonly toolCallId?: string; + readonly origin?: PromptOrigin; + readonly isError?: boolean; +} + +function message( + role: Role, + content: readonly ContentPart[], + extra: ReplayMessageExtra = {}, +): AgentReplayRecord { + return { + type: 'message', + message: { + role, + content: [...content], + toolCalls: [...(extra.toolCalls ?? [])], + toolCallId: extra.toolCallId, + origin: extra.origin, + isError: extra.isError, + }, + }; +} + +function backgroundTask( + taskId: string, + description: string, + status: BackgroundTaskInfo['status'] = 'running', +): BackgroundTaskInfo { + return { + taskId, + command: `[agent] ${description}`, + description, + status, + pid: 0, + exitCode: status === 'completed' ? 0 : null, + startedAt: 1, + endedAt: status === 'running' || status === 'awaiting_approval' ? null : 2, + }; +} + +describe('projectReplayRecords', () => { + it('projects only the most recent ten visible user turns from agent replay', () => { + const projected = projectReplayRecords( + Array.from({ length: 12 }, (_, index) => [ + message('user', [{ type: 'text', text: `prompt ${index}` }]), + message('assistant', [{ type: 'text', text: `answer ${index}` }]), + ]).flat(), + ); + + expect( + projected.entries.filter((entry) => entry.kind === 'user').map((entry) => entry.content), + ).toEqual([ + 'prompt 2', + 'prompt 3', + 'prompt 4', + 'prompt 5', + 'prompt 6', + 'prompt 7', + 'prompt 8', + 'prompt 9', + 'prompt 10', + 'prompt 11', + ]); + expect( + projected.entries.filter((entry) => entry.kind === 'assistant').map((entry) => entry.content), + ).toEqual([ + 'answer 2', + 'answer 3', + 'answer 4', + 'answer 5', + 'answer 6', + 'answer 7', + 'answer 8', + 'answer 9', + 'answer 10', + 'answer 11', + ]); + }); + + it('does not count model-triggered skill activations as user turns', () => { + const records: AgentReplayRecord[] = Array.from({ length: 9 }, (_, index) => [ + message('user', [{ type: 'text', text: `prompt ${index}` }]), + message('assistant', [{ type: 'text', text: `answer ${index}` }]), + ]).flat(); + for (const index of [0, 1, 2, 3]) { + records.push( + message('user', [{ type: 'text', text: `Skill body ${index}` }], { + origin: { + kind: 'skill_activation', + activationId: `act-${index}`, + skillName: 'review', + trigger: 'model-tool', + }, + }), + ); + } + + const projected = projectReplayRecords(records); + + expect( + projected.entries.filter((entry) => entry.kind === 'user').map((entry) => entry.content), + ).toEqual([ + 'prompt 0', + 'prompt 1', + 'prompt 2', + 'prompt 3', + 'prompt 4', + 'prompt 5', + 'prompt 6', + 'prompt 7', + 'prompt 8', + ]); + expect(projected.entries.filter((entry) => entry.kind === 'skill_activation')).toHaveLength(4); + }); + + it('projects UserPromptSubmit hook results as assistant transcript entries', () => { + const hookResult = + '<hook_result hook_event="UserPromptSubmit">\nhook response 1\n</hook_result>\n<hook_result hook_event="UserPromptSubmit">\nhook response 2\n</hook_result>'; + const projected = projectReplayRecords([ + message('user', [{ type: 'text', text: 'prompt' }]), + message('user', [{ type: 'text', text: hookResult }], { + origin: { kind: 'hook_result', event: 'UserPromptSubmit' }, + }), + message('assistant', [{ type: 'text', text: 'model response' }]), + ]); + + expect(projected.entries.map((e) => [e.kind, e.content])).toEqual([ + ['user', 'prompt'], + [ + 'assistant', + '*UserPromptSubmit hook*\n\nhook response 1\n\n*UserPromptSubmit hook*\n\nhook response 2', + ], + ['assistant', 'model response'], + ]); + }); + + it('projects blocking UserPromptSubmit hook results from replayed assistant entries', () => { + const hookResult = + '<hook_result hook_event="UserPromptSubmit">\nblocked reason\n</hook_result>'; + const projected = projectReplayRecords([ + message('user', [{ type: 'text', text: 'blocked prompt' }]), + message('assistant', [{ type: 'text', text: hookResult }], { + origin: { kind: 'hook_result', event: 'UserPromptSubmit', blocked: true }, + }), + ]); + + expect(projected.entries.map((e) => [e.kind, e.content])).toEqual([ + ['user', 'blocked prompt'], + ['assistant', '*UserPromptSubmit hook blocked*\n\nblocked reason'], + ]); + }); + + it('does not infer blocked UserPromptSubmit hook results from assistant role alone', () => { + const hookResult = + '<hook_result hook_event="UserPromptSubmit">\nlegacy hook response\n</hook_result>'; + const projected = projectReplayRecords([ + message('user', [{ type: 'text', text: 'prompt' }]), + message('assistant', [{ type: 'text', text: hookResult }], { + origin: { kind: 'hook_result', event: 'UserPromptSubmit' }, + }), + ]); + + expect(projected.entries.map((e) => [e.kind, e.content])).toEqual([ + ['user', 'prompt'], + ['assistant', '*UserPromptSubmit hook*\n\nlegacy hook response'], + ]); + }); + + it('preserves literal hook result XML from normal assistant replies', () => { + const hookResult = + '<hook_result hook_event="UserPromptSubmit">\nhook response\n</hook_result>'; + const projected = projectReplayRecords([ + message('user', [{ type: 'text', text: 'show me the hook XML' }]), + message('assistant', [{ type: 'text', text: hookResult }]), + ]); + + expect(projected.entries.map((e) => [e.kind, e.content])).toEqual([ + ['user', 'show me the hook XML'], + ['assistant', hookResult], + ]); + }); + + it('projects user messages plus thinking and assistant content', () => { + const projected = projectReplayRecords([ + message('user', [{ type: 'text', text: 'hello' }]), + message('assistant', [ + { type: 'think', think: 'thinking...' }, + { type: 'text', text: 'answer' }, + ]), + ]); + + expect(projected.entries.map((e) => [e.kind, e.content])).toEqual([ + ['user', 'hello'], + ['thinking', 'thinking...'], + ['assistant', 'answer'], + ]); + }); + + it('projects skill activation origin metadata without exposing the full prompt', () => { + const projected = projectReplayRecords([ + message( + 'user', + [{ type: 'text', text: 'Review the requested file.\n\nUser request:\nsrc/app.ts' }], + { + origin: { + kind: 'skill_activation', + activationId: 'act-1', + skillName: 'review', + skillArgs: 'src/app.ts', + trigger: 'user-slash', + }, + }, + ), + ]); + + expect(projected.entries).toHaveLength(1); + expect(projected.entries[0]).toEqual( + expect.objectContaining({ + kind: 'skill_activation', + content: 'Activated skill: review', + skillActivationId: 'act-1', + skillName: 'review', + skillArgs: 'src/app.ts', + }), + ); + expect(JSON.stringify(projected.entries)).not.toContain('Review the requested file'); + }); + + it('deduplicates replayed skill activation cards by activation id', () => { + const record = message('user', [{ type: 'text', text: 'Skill body' }], { + origin: { + kind: 'skill_activation', + activationId: 'act-1', + skillName: 'review', + trigger: 'user-slash', + }, + }); + + const projected = projectReplayRecords([record, record]); + + expect(projected.entries).toHaveLength(1); + expect(projected.entries[0]).toEqual( + expect.objectContaining({ + kind: 'skill_activation', + skillActivationId: 'act-1', + skillName: 'review', + }), + ); + }); + + it('projects background task notifications as status rows', () => { + const notificationXml = [ + '<notification id="task:agent-bg123:completed" category="task" type="task.completed" source_kind="background_task" source_id="agent-bg123">', + 'Title: Background agent completed', + 'Severity: info', + 'Optimize summary completed.', + '<task-notification>', + 'Subagent detailed output should stay out of the transcript row.', + '</task-notification>', + '</notification>', + ].join('\n'); + const projected = projectReplayRecords([ + message('assistant', [], { + toolCalls: [ + { + type: 'function', + id: 'call_agent', + function: { + name: 'Agent', + arguments: JSON.stringify({ + description: 'Optimize summary', + subagent_type: 'coder', + run_in_background: true, + }), + }, + }, + ], + }), + message('tool', [ + { + type: 'text', + text: [ + 'task_id: agent-bg123', + 'status: running', + 'agent_id: agent-child123', + 'actual_subagent_type: coder', + 'automatic_notification: true', + '', + 'description: Optimize summary', + ].join('\n'), + }, + ], { + toolCallId: 'call_agent', + }), + message('user', [{ type: 'text', text: notificationXml }], { + origin: { + kind: 'background_task', + taskId: 'agent-bg123', + status: 'completed', + notificationId: 'task:agent-bg123:completed', + }, + }), + ], [backgroundTask('agent-bg123', 'Optimize summary', 'completed')]); + + expect(projected.entries.map((entry) => [entry.kind, entry.content])).toEqual([ + ['tool_call', ''], + ['status', 'agent completed in background'], + ]); + expect(projected.entries[1]?.backgroundAgentStatus).toMatchObject({ + phase: 'completed', + headline: 'agent completed in background', + detail: 'Optimize summary', + }); + expect(JSON.stringify(projected.entries)).not.toContain('<notification'); + expect(JSON.stringify(projected.entries)).not.toContain('Subagent detailed output'); + }); + + it('uses background notification origin over XML attributes', () => { + const projected = projectReplayRecords([ + message('user', [ + { + type: 'text', + text: [ + '<notification id="task:wrong:completed" category="task" type="task.completed" source_kind="background_task" source_id="wrong">', + 'Title: Background agent completed', + 'Severity: info', + 'Optimize ch03 lost.', + '</notification>', + ].join('\n'), + }, + ], { + origin: { + kind: 'background_task', + taskId: 'agent-real', + status: 'lost', + notificationId: 'task:agent-real:lost', + }, + }), + ], [backgroundTask('agent-real', 'Real task description', 'lost')]); + + expect(projected.entries).toHaveLength(1); + expect(projected.entries[0]).toEqual( + expect.objectContaining({ + kind: 'status', + content: 'agent lost in background', + backgroundAgentStatus: expect.objectContaining({ + phase: 'failed', + detail: 'Real task description', + }), + }), + ); + }); + + it('renders multimodal user parts as stable placeholders', () => { + const projected = projectReplayRecords([ + message('user', [ + { type: 'text', text: 'look ' }, + { type: 'image_url', imageUrl: { url: 'file:///tmp/a.png' } }, + { type: 'video_url', videoUrl: { url: 'file:///tmp/a.mov' } }, + ]), + ]); + + expect(projected.entries[0]?.content).toBe( + 'look <image url="file:///tmp/a.png"><video url="file:///tmp/a.mov">', + ); + }); + + it('summarizes data URLs in resumed multimodal user parts', () => { + const projected = projectReplayRecords([ + message('user', [ + { type: 'text', text: 'look ' }, + { type: 'image_url', imageUrl: { url: 'data:image/png;base64,qrs=' } }, + { type: 'video_url', videoUrl: { url: 'data:video/mp4;base64,AQIDBA==' } }, + ]), + ]); + + expect(projected.entries[0]?.content).toBe('look [image image/png, 2 B][video video/mp4, 4 B]'); + expect(projected.entries[0]?.content).not.toContain('qrs='); + expect(projected.entries[0]?.content).not.toContain('AQIDBA=='); + }); + + it('pairs tool results with their tool call entry', () => { + const projected = projectReplayRecords([ + message('assistant', [], { + toolCalls: [ + { + type: 'function', + id: 'tc_1', + function: { + name: 'Bash', + arguments: '{"command":"pwd"}', + }, + }, + ], + }), + message('tool', [{ type: 'text', text: 'done' }], { + toolCallId: 'tc_1', + }), + ]); + + expect(projected.entries).toHaveLength(1); + expect(projected.entries[0]?.toolCallData).toMatchObject({ + id: 'tc_1', + name: 'Bash', + result: { tool_call_id: 'tc_1', output: 'done' }, + }); + }); + + it('preserves failed tool result state', () => { + const projected = projectReplayRecords([ + message('assistant', [], { + toolCalls: [ + { + type: 'function', + id: 'tc_1', + function: { + name: 'Bash', + arguments: '{"command":"false"}', + }, + }, + ], + }), + message('tool', [{ type: 'text', text: 'failed' }], { + toolCallId: 'tc_1', + isError: true, + }), + ]); + + expect(projected.entries[0]?.toolCallData?.result).toMatchObject({ + tool_call_id: 'tc_1', + output: 'failed', + is_error: true, + }); + }); + + it('projects resumed assistant text, tool call, and tool result records in order', () => { + const projected = projectReplayRecords([ + message('user', [{ type: 'text', text: 'try call a tool' }]), + message( + 'assistant', + [ + { type: 'think', think: 'I should call Bash.' }, + { type: 'text', text: 'Calling Bash now.' }, + ], + { + toolCalls: [ + { + type: 'function', + id: 'call_resume_bash', + function: { + name: 'Bash', + arguments: '{"command":"echo ok"}', + }, + }, + ], + }, + ), + message('tool', [{ type: 'text', text: 'ok' }], { + toolCallId: 'call_resume_bash', + }), + ]); + + expect(projected.entries.map((entry) => [entry.kind, entry.content])).toEqual([ + ['user', 'try call a tool'], + ['thinking', 'I should call Bash.'], + ['assistant', 'Calling Bash now.'], + ['tool_call', ''], + ]); + expect(projected.entries[3]?.toolCallData).toMatchObject({ + id: 'call_resume_bash', + name: 'Bash', + args: { command: 'echo ok' }, + result: { tool_call_id: 'call_resume_bash', output: 'ok' }, + }); + }); + + it('keeps media-bearing tool results as a JSON envelope', () => { + const mediaContent: ContentPart[] = [ + { type: 'text', text: '<image path="/tmp/a.png">' }, + { type: 'image_url', imageUrl: { url: 'data:image/png;base64,QUJD' } }, + { type: 'text', text: '</image>' }, + ]; + const projected = projectReplayRecords([ + message('assistant', [], { + toolCalls: [ + { + type: 'function', + id: 'tc_media', + function: { + name: 'ReadMediaFile', + arguments: '{"path":"/tmp/a.png"}', + }, + }, + ], + }), + message('tool', mediaContent, { + toolCallId: 'tc_media', + }), + ]); + + const output = projected.entries[0]?.toolCallData?.result?.output ?? ''; + expect(JSON.parse(output)).toEqual(mediaContent); + }); + + it('projects plan, permission, and approval replay records as notices', () => { + const projected = projectReplayRecords([ + { type: 'plan_updated', enabled: true }, + { type: 'permission_updated', mode: 'auto' }, + { type: 'permission_updated', mode: 'yolo' }, + { type: 'permission_updated', mode: 'manual' }, + { + type: 'approval_result', + record: { + turnId: 0, + toolCallId: 'call_bash', + action: 'run command', + toolName: 'Bash', + result: { + decision: 'approved', + scope: 'session', + selectedLabel: 'Approve for this session', + }, + }, + }, + { type: 'plan_updated', enabled: false }, + ]); + + expect(projected.entries.map((e) => [e.kind, e.renderMode, e.content])).toEqual([ + ['status', 'notice', 'Plan mode: ON'], + ['status', 'notice', 'Permission mode: auto'], + ['status', 'notice', 'YOLO mode: ON'], + ['status', 'notice', 'YOLO mode: OFF'], + ['status', 'notice', 'Approved for session: run command'], + ['status', 'notice', 'Plan mode: OFF'], + ]); + expect(projected.entries[2]?.detail).toBe( + 'All actions will be approved automatically. Use with caution.', + ); + }); + + it('ignores config replay records and system injections', () => { + const projected = projectReplayRecords([ + { type: 'config_updated', config: { thinkingLevel: 'off' } }, + message('user', [{ type: 'text', text: 'ignore by origin' }], { + origin: { kind: 'injection', variant: 'plan_mode' }, + }), + message('user', [{ type: 'text', text: 'visible' }]), + ]); + + expect(projected.entries.map((e) => [e.kind, e.content])).toEqual([['user', 'visible']]); + }); +}); diff --git a/apps/kimi-code/test/tui/reverse-rpc/approval-adapter.test.ts b/apps/kimi-code/test/tui/reverse-rpc/approval-adapter.test.ts new file mode 100644 index 000000000..72c2b6141 --- /dev/null +++ b/apps/kimi-code/test/tui/reverse-rpc/approval-adapter.test.ts @@ -0,0 +1,162 @@ +import { describe, expect, it } from 'vitest'; + +import { adaptApprovalRequest, adaptPanelResponse } from '#/tui/reverse-rpc/approval/adapter'; + +describe('approval adapter', () => { + it('adapts generic command displays into shell blocks with approval choices', () => { + const adapted = adaptApprovalRequest( + { + toolCallId: 'tc-1', + toolName: 'EnterPlanMode', + action: 'run', + display: { + kind: 'generic', + summary: 'run', + detail: { + command: 'sudo rm -rf /tmp/cache', + cwd: '/tmp', + }, + }, + }, + ); + + expect(adapted).toMatchObject({ + id: 'tc-1', + tool_call_id: 'tc-1', + tool_name: 'EnterPlanMode', + display: [ + { + type: 'shell', + language: 'bash', + command: 'sudo rm -rf /tmp/cache', + cwd: '/tmp', + danger: 'recursive delete', + }, + ], + }); + expect(adapted.choices.map((choice) => choice.label)).toEqual([ + 'Approve once', + 'Approve for this session', + 'Reject', + 'Reject with feedback', + ]); + }); + + it('emits only a diff block for Edit — no separate file_op title row', () => { + const adapted = adaptApprovalRequest( + { + toolCallId: 'tc-edit', + toolName: 'Edit', + action: 'edit', + display: { + kind: 'generic', + summary: 'edit', + detail: { + file_path: 'src/foo.ts', + old_string: 'a\nb\nc', + new_string: 'a\nB\nc', + }, + }, + }, + ); + + expect(adapted.display).toEqual([ + { type: 'diff', path: 'src/foo.ts', old_text: 'a\nb\nc', new_text: 'a\nB\nc' }, + ]); + }); + + it('emits a file_content block for Write so the new file previews as code, not diff', () => { + const adapted = adaptApprovalRequest( + { + toolCallId: 'tc-write', + toolName: 'Write', + action: 'write', + display: { + kind: 'generic', + summary: 'write', + detail: { + file_path: 'src/new.ts', + content: 'export const x = 1;\nexport const y = 2;', + }, + }, + }, + ); + + expect(adapted.display).toEqual([ + { + type: 'file_content', + path: 'src/new.ts', + content: 'export const x = 1;\nexport const y = 2;', + }, + ]); + }); + + it('omits plan review content from the approval panel while keeping Python-style choices', () => { + const adapted = adaptApprovalRequest({ + toolCallId: 'tc-plan', + toolName: 'ExitPlanMode', + action: 'Review plan', + display: { + kind: 'plan_review', + plan: '# Plan\n\n- Inspect\n- Change\n- Verify', + path: '/tmp/kimi-plan.md', + }, + }); + + expect(adapted.display).toEqual([]); + expect(adapted.choices).toEqual([ + { label: 'Approve', response: 'approved', selected_label: 'Approve' }, + { label: 'Reject', response: 'rejected', selected_label: 'Reject' }, + { + label: 'Revise', + response: 'rejected', + selected_label: 'Revise', + requires_feedback: true, + }, + ]); + }); + + it('renders multi-option plan review choices ahead of reject controls', () => { + const adapted = adaptApprovalRequest({ + toolCallId: 'tc-plan-options', + toolName: 'ExitPlanMode', + action: 'Review plan and choose an option', + display: { + kind: 'plan_review', + plan: '# Plan', + path: '/tmp/kimi-plan.md', + options: [ + { label: 'Approach A', description: 'Small refactor' }, + { label: 'Approach B', description: 'Full refactor' }, + ], + }, + }); + + expect(adapted.choices).toEqual([ + { label: 'Approach A', response: 'approved', selected_label: 'Approach A' }, + { label: 'Approach B', response: 'approved', selected_label: 'Approach B' }, + { label: 'Reject', response: 'rejected', selected_label: 'Reject' }, + { + label: 'Revise', + response: 'rejected', + selected_label: 'Revise', + requires_feedback: true, + }, + ]); + }); + + it('maps approved-for-session responses into core approval payloads', () => { + expect( + adaptPanelResponse({ + response: 'approved_for_session', + feedback: 'looks good', + selected_label: 'Approve for this session', + }), + ).toEqual({ + decision: 'approved', + scope: 'session', + feedback: 'looks good', + selectedLabel: 'Approve for this session', + }); + }); +}); diff --git a/apps/kimi-code/test/tui/reverse-rpc/approval.test.ts b/apps/kimi-code/test/tui/reverse-rpc/approval.test.ts new file mode 100644 index 000000000..c2e29c6c2 --- /dev/null +++ b/apps/kimi-code/test/tui/reverse-rpc/approval.test.ts @@ -0,0 +1,142 @@ +import type { ApprovalRequest } from '@moonshot-ai/kimi-code-sdk'; +import { describe, expect, it, vi } from 'vitest'; + +import { ApprovalController } from '#/tui/reverse-rpc/approval/controller'; +import { createApprovalRequestHandler } from '#/tui/reverse-rpc/approval/handler'; + +function approvalEvent(overrides: Partial<ApprovalRequest> = {}): ApprovalRequest { + return { + toolCallId: 'tc-1', + toolName: 'Bash', + action: 'run command', + display: { + kind: 'generic', + summary: 'run command', + detail: { + command: 'rm -rf /tmp/cache', + cwd: '/tmp', + }, + }, + ...overrides, + }; +} + +describe('approval reverse-rpc', () => { + it('auto-approves queued requests with the same action when the current is approved for session', async () => { + const controller = new ApprovalController(); + const panel = (id: string, action: string) => ({ + id, + tool_call_id: id, + tool_name: 'Bash', + action, + description: '', + display: [], + choices: [], + }); + + const first = controller.show(panel('tc-1', 'run command: ls')); + const second = controller.show(panel('tc-2', 'run command: ls')); + const third = controller.show(panel('tc-3', 'edit src/x.ts')); + const fourth = controller.show(panel('tc-4', 'run command: ls')); + + controller.respond({ decision: 'approved', scope: 'session', feedback: 'ok' }); + + await expect(first).resolves.toEqual({ + decision: 'approved', + scope: 'session', + feedback: 'ok', + }); + // Queued same-action requests inherit a session-scoped approval without + // surfacing another panel. The user's feedback is not carried over — + // it described the first request only. + await expect(second).resolves.toEqual({ decision: 'approved', scope: 'session' }); + await expect(fourth).resolves.toEqual({ decision: 'approved', scope: 'session' }); + // A different-action request still waits for an explicit decision. + expect(controller.hasPending()).toBe(true); + + controller.respond({ decision: 'rejected' }); + await expect(third).resolves.toEqual({ decision: 'rejected' }); + }); + + it('does not auto-approve queued requests when only approved-once is chosen', async () => { + const controller = new ApprovalController(); + const panel = (id: string) => ({ + id, + tool_call_id: id, + tool_name: 'Bash', + action: 'run command: ls', + description: '', + display: [], + choices: [], + }); + + const first = controller.show(panel('tc-1')); + const second = controller.show(panel('tc-2')); + + controller.respond({ decision: 'approved' }); + + await expect(first).resolves.toEqual({ decision: 'approved' }); + // The second same-action request must NOT be auto-resolved — approve-once + // is a one-shot decision, not a session rule. + expect(controller.hasPending()).toBe(true); + controller.respond({ decision: 'approved' }); + await expect(second).resolves.toEqual({ decision: 'approved' }); + }); + + it('ApprovalController cancels pending requests with a cancelled response', async () => { + const controller = new ApprovalController(); + const pending = controller.show({ + id: 'req-1', + tool_call_id: 'tc-1', + tool_name: 'Bash', + action: 'run', + description: '', + display: [], + choices: [], + }); + + controller.cancelAll('closed'); + + await expect(pending).resolves.toEqual({ + decision: 'cancelled', + feedback: 'closed', + }); + }); + + it('adapts approval payloads through the handler and falls back on failure', async () => { + const controller = new ApprovalController(); + const show = vi.spyOn(controller, 'show').mockResolvedValue({ + decision: 'approved', + scope: 'session', + feedback: 'looks good', + }); + const handler = createApprovalRequestHandler(controller); + + await expect(handler(approvalEvent())).resolves.toEqual({ + decision: 'approved', + scope: 'session', + feedback: 'looks good', + }); + expect(show).toHaveBeenCalledWith( + expect.objectContaining({ + id: 'tc-1', + tool_call_id: 'tc-1', + tool_name: 'Bash', + display: [ + expect.objectContaining({ + type: 'shell', + command: 'rm -rf /tmp/cache', + cwd: '/tmp', + danger: 'recursive delete', + }), + ], + }), + ); + + show.mockRejectedValueOnce(new Error('boom')); + await expect(handler(approvalEvent())).resolves.toEqual({ + decision: 'cancelled', + feedback: 'approval handler failed', + }); + }); +}); diff --git a/apps/kimi-code/test/tui/reverse-rpc/base-controller.test.ts b/apps/kimi-code/test/tui/reverse-rpc/base-controller.test.ts new file mode 100644 index 000000000..c755c4dd9 --- /dev/null +++ b/apps/kimi-code/test/tui/reverse-rpc/base-controller.test.ts @@ -0,0 +1,124 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { ReverseRpcController } from '#/tui/reverse-rpc/base-controller'; + +class TestController extends ReverseRpcController<string, string> { + protected createCancelResponse(reason: string): string { + return `cancel:${reason}`; + } +} + +describe('ReverseRpcController', () => { + it('shows a payload, resolves the pending promise on respond, and hides the panel', async () => { + const controller = new TestController(); + const showPanel = vi.fn(); + const hidePanel = vi.fn(); + controller.setUIHooks({ showPanel, hidePanel }); + + const pending = controller.show('payload'); + expect(controller.hasPending()).toBe(true); + expect(showPanel).toHaveBeenCalledWith('payload'); + + controller.respond('approved'); + + await expect(pending).resolves.toBe('approved'); + expect(controller.hasPending()).toBe(false); + expect(hidePanel).toHaveBeenCalledOnce(); + }); + + it('queues concurrent show() requests and presents them one at a time', async () => { + const controller = new TestController(); + const showPanel = vi.fn(); + const hidePanel = vi.fn(); + controller.setUIHooks({ showPanel, hidePanel }); + + const first = controller.show('first'); + const second = controller.show('second'); + const third = controller.show('third'); + + // Only the first is presented; the rest stay queued. + expect(showPanel).toHaveBeenCalledTimes(1); + expect(showPanel).toHaveBeenLastCalledWith('first'); + expect(controller.hasPending()).toBe(true); + + controller.respond('answer-first'); + await expect(first).resolves.toBe('answer-first'); + // Advancing to the next queued request reuses the same panel without + // hiding it in between. + expect(hidePanel).not.toHaveBeenCalled(); + expect(showPanel).toHaveBeenCalledTimes(2); + expect(showPanel).toHaveBeenLastCalledWith('second'); + + controller.respond('answer-second'); + await expect(second).resolves.toBe('answer-second'); + expect(showPanel).toHaveBeenCalledTimes(3); + expect(showPanel).toHaveBeenLastCalledWith('third'); + + controller.respond('answer-third'); + await expect(third).resolves.toBe('answer-third'); + expect(controller.hasPending()).toBe(false); + expect(hidePanel).toHaveBeenCalledTimes(1); + }); + + it('auto-resolves matching queued requests via the autoResolveFor hook', async () => { + class AutoController extends ReverseRpcController< + { action: string; id: string }, + string + > { + protected createCancelResponse(reason: string): string { + return `cancel:${reason}`; + } + protected override autoResolveFor( + resolved: { action: string; id: string }, + response: string, + queued: { action: string; id: string }, + ): string | undefined { + if (response === 'approve_all_same' && resolved.action === queued.action) { + return `auto:${queued.id}`; + } + return undefined; + } + } + const controller = new AutoController(); + const showPanel = vi.fn(); + const hidePanel = vi.fn(); + controller.setUIHooks({ showPanel, hidePanel }); + + const first = controller.show({ action: 'run', id: 'a' }); + const second = controller.show({ action: 'run', id: 'b' }); + const third = controller.show({ action: 'edit', id: 'c' }); + const fourth = controller.show({ action: 'run', id: 'd' }); + + controller.respond('approve_all_same'); + + await expect(first).resolves.toBe('approve_all_same'); + await expect(second).resolves.toBe('auto:b'); + await expect(fourth).resolves.toBe('auto:d'); + // The non-matching request advances to the panel and stays pending. + expect(showPanel).toHaveBeenLastCalledWith({ action: 'edit', id: 'c' }); + expect(controller.hasPending()).toBe(true); + + controller.respond('approve_all_same'); + await expect(third).resolves.toBe('approve_all_same'); + expect(controller.hasPending()).toBe(false); + expect(hidePanel).toHaveBeenCalledTimes(1); + }); + + it('cancelAll cancels the current request and every queued request', async () => { + const controller = new TestController(); + const hidePanel = vi.fn(); + controller.setUIHooks({ showPanel: vi.fn(), hidePanel }); + + const first = controller.show('first'); + const second = controller.show('second'); + const third = controller.show('third'); + + controller.cancelAll('shutdown'); + + await expect(first).resolves.toBe('cancel:shutdown'); + await expect(second).resolves.toBe('cancel:shutdown'); + await expect(third).resolves.toBe('cancel:shutdown'); + expect(controller.hasPending()).toBe(false); + expect(hidePanel).toHaveBeenCalledTimes(1); + }); +}); diff --git a/apps/kimi-code/test/tui/reverse-rpc/index.test.ts b/apps/kimi-code/test/tui/reverse-rpc/index.test.ts new file mode 100644 index 000000000..790fd00dc --- /dev/null +++ b/apps/kimi-code/test/tui/reverse-rpc/index.test.ts @@ -0,0 +1,214 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { ApprovalController } from '#/tui/reverse-rpc/approval/controller'; +import { registerReverseRPCHandlers } from '#/tui/reverse-rpc/index'; +import { QuestionController } from '#/tui/reverse-rpc/question/controller'; + +describe('registerReverseRPCHandlers', () => { + it('wires controller UI hooks without registering wire request handlers', async () => { + const approvalController = new ApprovalController(); + const questionController = new QuestionController(); + const uiHooks = { + showApprovalPanel: vi.fn(), + hideApprovalPanel: vi.fn(), + showQuestionDialog: vi.fn(), + hideQuestionDialog: vi.fn(), + }; + + registerReverseRPCHandlers(approvalController, questionController, uiHooks); + + const approvalPending = approvalController.show({ + id: 'req-1', + tool_call_id: 'tc-1', + tool_name: 'Bash', + action: 'run', + description: '', + display: [], + choices: [], + }); + expect(uiHooks.showApprovalPanel).toHaveBeenCalledWith( + expect.objectContaining({ id: 'req-1' }), + ); + approvalController.cancelAll('bye'); + await expect(approvalPending).resolves.toEqual({ + decision: 'cancelled', + feedback: 'bye', + }); + expect(uiHooks.hideApprovalPanel).toHaveBeenCalledOnce(); + + const questionPending = questionController.show({ + id: 'q-1', + tool_call_id: 'tc-1', + questions: [], + }); + expect(uiHooks.showQuestionDialog).toHaveBeenCalledWith(expect.objectContaining({ id: 'q-1' })); + questionController.cancelAll('bye'); + await expect(questionPending).resolves.toEqual({ answers: [] }); + expect(uiHooks.hideQuestionDialog).toHaveBeenCalledOnce(); + }); + + it('queues question dialogs behind active approval panels', async () => { + const approvalController = new ApprovalController(); + const questionController = new QuestionController(); + const uiHooks = { + showApprovalPanel: vi.fn(), + hideApprovalPanel: vi.fn(), + showQuestionDialog: vi.fn(), + hideQuestionDialog: vi.fn(), + }; + + registerReverseRPCHandlers(approvalController, questionController, uiHooks); + + const approvalPending = approvalController.show({ + id: 'approval-1', + tool_call_id: 'tc-1', + tool_name: 'Bash', + action: 'run', + description: '', + display: [], + choices: [], + }); + const questionPending = questionController.show({ + id: 'question-1', + tool_call_id: 'tq-1', + questions: [], + }); + + expect(uiHooks.showApprovalPanel).toHaveBeenCalledWith( + expect.objectContaining({ id: 'approval-1' }), + ); + expect(uiHooks.showQuestionDialog).not.toHaveBeenCalled(); + + approvalController.respond({ decision: 'approved' }); + await expect(approvalPending).resolves.toEqual({ decision: 'approved' }); + expect(uiHooks.hideApprovalPanel).toHaveBeenCalledOnce(); + expect(uiHooks.showQuestionDialog).toHaveBeenCalledWith( + expect.objectContaining({ id: 'question-1' }), + ); + + questionController.respond({ answers: ['answer'] }); + await expect(questionPending).resolves.toEqual({ answers: ['answer'] }); + expect(uiHooks.hideQuestionDialog).toHaveBeenCalledOnce(); + }); + + it('queues approval panels behind active question dialogs', async () => { + const approvalController = new ApprovalController(); + const questionController = new QuestionController(); + const uiHooks = { + showApprovalPanel: vi.fn(), + hideApprovalPanel: vi.fn(), + showQuestionDialog: vi.fn(), + hideQuestionDialog: vi.fn(), + }; + + registerReverseRPCHandlers(approvalController, questionController, uiHooks); + + const questionPending = questionController.show({ + id: 'question-1', + tool_call_id: 'tq-1', + questions: [], + }); + const approvalPending = approvalController.show({ + id: 'approval-1', + tool_call_id: 'tc-1', + tool_name: 'Bash', + action: 'run', + description: '', + display: [], + choices: [], + }); + + expect(uiHooks.showQuestionDialog).toHaveBeenCalledWith( + expect.objectContaining({ id: 'question-1' }), + ); + expect(uiHooks.showApprovalPanel).not.toHaveBeenCalled(); + + questionController.respond({ answers: ['answer'] }); + await expect(questionPending).resolves.toEqual({ answers: ['answer'] }); + expect(uiHooks.hideQuestionDialog).toHaveBeenCalledOnce(); + expect(uiHooks.showApprovalPanel).toHaveBeenCalledWith( + expect.objectContaining({ id: 'approval-1' }), + ); + + approvalController.respond({ decision: 'approved' }); + await expect(approvalPending).resolves.toEqual({ decision: 'approved' }); + expect(uiHooks.hideApprovalPanel).toHaveBeenCalledOnce(); + }); + + it('removes queued modals when their controller is cancelled', async () => { + const approvalController = new ApprovalController(); + const questionController = new QuestionController(); + const uiHooks = { + showApprovalPanel: vi.fn(), + hideApprovalPanel: vi.fn(), + showQuestionDialog: vi.fn(), + hideQuestionDialog: vi.fn(), + }; + + registerReverseRPCHandlers(approvalController, questionController, uiHooks); + + const approvalPending = approvalController.show({ + id: 'approval-1', + tool_call_id: 'tc-1', + tool_name: 'Bash', + action: 'run', + description: '', + display: [], + choices: [], + }); + const questionPending = questionController.show({ + id: 'question-1', + tool_call_id: 'tq-1', + questions: [], + }); + + questionController.cancelAll('closed'); + await expect(questionPending).resolves.toEqual({ answers: [] }); + expect(uiHooks.hideQuestionDialog).not.toHaveBeenCalled(); + + approvalController.respond({ decision: 'approved' }); + await expect(approvalPending).resolves.toEqual({ decision: 'approved' }); + expect(uiHooks.showQuestionDialog).not.toHaveBeenCalled(); + }); + + it('clears active and queued modals without showing queued entries', async () => { + const approvalController = new ApprovalController(); + const questionController = new QuestionController(); + const uiHooks = { + showApprovalPanel: vi.fn(), + hideApprovalPanel: vi.fn(), + showQuestionDialog: vi.fn(), + hideQuestionDialog: vi.fn(), + }; + + const disposers = registerReverseRPCHandlers(approvalController, questionController, uiHooks); + + const approvalPending = approvalController.show({ + id: 'approval-1', + tool_call_id: 'tc-1', + tool_name: 'Bash', + action: 'run', + description: '', + display: [], + choices: [], + }); + const questionPending = questionController.show({ + id: 'question-1', + tool_call_id: 'tq-1', + questions: [], + }); + + for (const dispose of disposers) dispose(); + expect(uiHooks.hideApprovalPanel).toHaveBeenCalledOnce(); + expect(uiHooks.showQuestionDialog).not.toHaveBeenCalled(); + + approvalController.cancelAll('closed'); + questionController.cancelAll('closed'); + await expect(approvalPending).resolves.toEqual({ + decision: 'cancelled', + feedback: 'closed', + }); + await expect(questionPending).resolves.toEqual({ answers: [] }); + expect(uiHooks.hideQuestionDialog).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/kimi-code/test/tui/reverse-rpc/question.test.ts b/apps/kimi-code/test/tui/reverse-rpc/question.test.ts new file mode 100644 index 000000000..41de65fff --- /dev/null +++ b/apps/kimi-code/test/tui/reverse-rpc/question.test.ts @@ -0,0 +1,134 @@ +import type { QuestionRequest } from '@moonshot-ai/kimi-code-sdk'; +import { describe, expect, it, vi } from 'vitest'; + +import { QuestionController } from '#/tui/reverse-rpc/question/controller'; +import { createQuestionAskHandler } from '#/tui/reverse-rpc/question/handler'; + +function questionEvent(overrides: Partial<QuestionRequest> = {}): QuestionRequest { + return { + toolCallId: 'q-1', + questions: [ + { + question: 'Q1?', + options: [{ label: 'Alpha' }], + }, + ], + ...overrides, + }; +} + +describe('question reverse-rpc', () => { + it('QuestionController cancels pending requests with an empty answer list', async () => { + const controller = new QuestionController(); + const pending = controller.show({ + id: 'req-1', + tool_call_id: 'tc-1', + questions: [], + }); + + controller.cancelAll('closed'); + + await expect(pending).resolves.toEqual({ answers: [] }); + }); + + it('normalizes question payloads and returns the selected answer', async () => { + const controller = new QuestionController(); + const show = vi + .spyOn(controller, 'show') + .mockResolvedValue({ answers: ['Alpha'], method: 'number_key' }); + const handler = createQuestionAskHandler(controller); + const event = questionEvent({ + questions: [ + { + question: 'Q1?', + header: 'Pick', + body: 'Choose one', + multiSelect: true, + otherLabel: 'Other', + otherDescription: 'Type a custom answer', + options: [{ label: 'Alpha', description: 'First option' }], + }, + ], + }); + + await expect(handler(event)).resolves.toEqual({ + answers: { 'Q1?': 'Alpha' }, + method: 'number_key', + }); + expect(show).toHaveBeenCalledWith({ + id: 'q-1', + tool_call_id: 'q-1', + questions: [ + { + question: 'Q1?', + header: 'Pick', + body: 'Choose one', + multi_select: true, + other_label: 'Other', + other_description: 'Type a custom answer', + options: [{ label: 'Alpha', description: 'First option' }], + }, + ], + }); + + show.mockResolvedValueOnce({ answers: [''] }); + await expect(handler(questionEvent())).resolves.toBeNull(); + + show.mockRejectedValueOnce(new Error('boom')); + await expect(handler(questionEvent())).resolves.toBeNull(); + }); + + it('maps multiple question answers by question text', async () => { + const controller = new QuestionController(); + const show = vi + .spyOn(controller, 'show') + .mockResolvedValue({ answers: ['Alpha', 'SQLite'], method: 'enter' }); + const handler = createQuestionAskHandler(controller); + const event = questionEvent({ + toolCallId: 'call_question', + questions: [ + { + question: 'Q1?', + options: [{ label: 'Alpha' }], + }, + { + question: 'Storage?', + header: 'Store', + options: [{ label: 'SQLite' }], + }, + ], + }); + + await expect(handler(event)).resolves.toEqual({ + answers: { + 'Q1?': 'Alpha', + 'Storage?': 'SQLite', + }, + method: 'enter', + }); + expect(show).toHaveBeenCalledWith({ + id: 'call_question', + tool_call_id: 'call_question', + questions: [ + { + question: 'Q1?', + header: undefined, + body: undefined, + multi_select: false, + other_label: undefined, + other_description: undefined, + options: [{ label: 'Alpha', description: undefined }], + }, + { + question: 'Storage?', + header: 'Store', + body: undefined, + multi_select: false, + other_label: undefined, + other_description: undefined, + options: [{ label: 'SQLite', description: undefined }], + }, + ], + }); + }); +}); diff --git a/apps/kimi-code/test/tui/task-output-viewer.test.ts b/apps/kimi-code/test/tui/task-output-viewer.test.ts new file mode 100644 index 000000000..2cb998b42 --- /dev/null +++ b/apps/kimi-code/test/tui/task-output-viewer.test.ts @@ -0,0 +1,260 @@ +import type { Terminal } from '@earendil-works/pi-tui'; +import type { BackgroundTaskInfo } from '@moonshot-ai/kimi-code-sdk'; +import { describe, expect, it, vi } from 'vitest'; + +import { TaskOutputViewer } from '@/tui/components/dialogs/task-output-viewer'; +import { darkColors } from '@/tui/theme/colors'; + +const ANSI_SGR = /\[[0-9;]*m/g; +function strip(text: string): string { + return text.replaceAll(ANSI_SGR, ''); +} + +function fakeTerminal(rows: number, columns = 120): Terminal { + return { + start: () => {}, + stop: () => {}, + drainInput: () => Promise.resolve(), + write: () => {}, + get columns() { + return columns; + }, + get rows() { + return rows; + }, + get kittyProtocolActive() { + return false; + }, + moveBy: () => {}, + hideCursor: () => {}, + showCursor: () => {}, + clearLine: () => {}, + clearFromCursor: () => {}, + clearScreen: () => {}, + setTitle: () => {}, + setProgress: () => {}, + }; +} + +function info(overrides: Partial<BackgroundTaskInfo> = {}): BackgroundTaskInfo { + return { + taskId: 'bash-aaaaaaaa', + command: 'npm run dev', + description: 'dev server', + status: 'running', + pid: 1234, + exitCode: null, + startedAt: Date.now() - 60_000, + endedAt: null, + ...overrides, + }; +} + +function makeViewer(opts: { + output: string; + taskInfo?: BackgroundTaskInfo; + rows?: number; + columns?: number; + onClose?: () => void; +}): TaskOutputViewer { + return new TaskOutputViewer( + { + taskId: opts.taskInfo?.taskId ?? 'bash-aaaaaaaa', + info: opts.taskInfo ?? info(), + output: opts.output, + colors: darkColors, + onClose: opts.onClose ?? (() => {}), + }, + fakeTerminal(opts.rows ?? 30, opts.columns ?? 120), + ); +} + +describe('TaskOutputViewer — rendering', () => { + it('fills exactly terminal.rows lines', () => { + const lines = makeViewer({ output: 'hello\nworld', rows: 24 }).render(120); + expect(lines.length).toBe(24); + }); + + it('shows the task header (id + status + description)', () => { + const out = strip( + makeViewer({ + output: '', + taskInfo: info({ taskId: 'bash-zzzzzzzz', status: 'running', description: 'demo svc' }), + }) + .render(120) + .join('\n'), + ); + expect(out).toContain('Task output'); + expect(out).toContain('bash-zzzzzzzz'); + expect(out).toContain('running'); + expect(out).toContain('demo svc'); + }); + + it('shows the empty-state body when output is empty', () => { + const out = strip(makeViewer({ output: '' }).render(120).join('\n')); + expect(out).toContain('[no output captured]'); + }); + + it('shows position indicator in the footer', () => { + const lines = ['line 1', 'line 2', 'line 3'].join('\n'); + const out = strip(makeViewer({ output: lines, rows: 20 }).render(120).join('\n')); + expect(out).toMatch(/1-3 \/ 3/); + expect(out).toContain('100%'); + }); + + it('renders all visible body lines when output fits the body height', () => { + const lines = ['alpha', 'bravo', 'charlie', 'delta', 'echo'].join('\n'); + const out = strip(makeViewer({ output: lines, rows: 20 }).render(120).join('\n')); + expect(out).toContain('alpha'); + expect(out).toContain('bravo'); + expect(out).toContain('charlie'); + expect(out).toContain('delta'); + expect(out).toContain('echo'); + }); +}); + +describe('TaskOutputViewer — scrolling', () => { + function bigOutput(n: number): string { + return Array.from({ length: n }, (_, i) => `line-${String(i + 1).padStart(3, '0')}`).join('\n'); + } + + it('renders the top of the buffer initially', () => { + const viewer = makeViewer({ output: bigOutput(100), rows: 20 }); + const out = strip(viewer.render(120).join('\n')); + expect(out).toContain('line-001'); + expect(out).not.toContain('line-100'); + }); + + it('down arrow scrolls forward by one line', () => { + const viewer = makeViewer({ output: bigOutput(50), rows: 12 }); + viewer.handleInput(''); + const out = strip(viewer.render(120).join('\n')); + expect(out).toContain('line-002'); + expect(out).not.toContain('line-001'); + }); + + it('PageDown scrolls a page', () => { + const viewer = makeViewer({ output: bigOutput(50), rows: 12 }); + // PageDown via prompt-toolkit-style sequence "[6~" + viewer.handleInput('[6~'); + const out = strip(viewer.render(120).join('\n')); + // body has 12 - 2 (header/footer) - 2 (top/bot border) = 8 viewable rows. + // PageDown shifts by (body - 1) = 7 lines. + expect(out).toContain('line-008'); + expect(out).not.toContain('line-001'); + }); + + it('G jumps to the bottom', () => { + const viewer = makeViewer({ output: bigOutput(100), rows: 14 }); + viewer.handleInput('G'); + const out = strip(viewer.render(120).join('\n')); + expect(out).toContain('line-100'); + // Footer reads 100% at the end. + expect(out).toContain('100%'); + }); + + it('g jumps back to the top', () => { + const viewer = makeViewer({ output: bigOutput(100), rows: 14 }); + viewer.handleInput('G'); + viewer.handleInput('g'); + const out = strip(viewer.render(120).join('\n')); + expect(out).toContain('line-001'); + }); + + it('scrolling clamps at the start and end', () => { + const viewer = makeViewer({ output: bigOutput(5), rows: 20 }); + // Scrolling down on a buffer smaller than the body should not advance. + viewer.handleInput(''); + viewer.handleInput(''); + const out = strip(viewer.render(120).join('\n')); + expect(out).toContain('line-001'); + expect(out).toContain('line-005'); + }); +}); + +describe('TaskOutputViewer — input', () => { + it('Esc invokes onClose', () => { + const onClose = vi.fn(); + makeViewer({ output: 'x', onClose }).handleInput(''); + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it('q invokes onClose', () => { + const onClose = vi.fn(); + makeViewer({ output: 'x', onClose }).handleInput('q'); + expect(onClose).toHaveBeenCalledTimes(1); + }); + + // Under the Kitty keyboard protocol (e.g. VSCode integrated terminal), + // ordinary printable keys arrive as CSI-u sequences. + it('Kitty-encoded q invokes onClose', () => { + const onClose = vi.fn(); + makeViewer({ output: 'x', onClose }).handleInput('\u001B[113u'); + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it('Kitty-encoded G scrolls to bottom', () => { + const viewer = makeViewer({ + output: Array.from({ length: 200 }, (_, i) => `line-${String(i)}`).join('\n'), + rows: 10, + }); + viewer.handleInput('\u001B[71u'); // G (uppercase) + const rendered = strip(viewer.render(120).join('\n')); + expect(rendered).toContain('line-199'); + }); +}); + +describe('TaskOutputViewer — live tail via setProps', () => { + function makeOutput(n: number): string { + return Array.from({ length: n }, (_, i) => `line-${String(i + 1).padStart(3, '0')}`).join('\n'); + } + + it('follows new lines when the viewer is already parked at the bottom', () => { + // 5 lines, body has ~26 viewable rows → user is at bottom by default. + const viewer = makeViewer({ output: makeOutput(5), rows: 30 }); + viewer.handleInput('G'); + // Append more lines. + viewer.setProps({ + taskId: 'bash-aaaaaaaa', + info: info(), + output: makeOutput(50), + colors: darkColors, + onClose: () => {}, + }); + const out = strip(viewer.render(120).join('\n')); + expect(out).toContain('line-050'); + }); + + it('preserves scroll position when the user has scrolled up', () => { + const viewer = makeViewer({ output: makeOutput(100), rows: 14 }); + // Body has 14 - 4 = 10 viewable rows; max scroll = 90. Stay at top (0). + expect(strip(viewer.render(120).join('\n'))).toContain('line-001'); + // Output grows. Since user is at scroll=0 (not bottom), keep them at top. + viewer.setProps({ + taskId: 'bash-aaaaaaaa', + info: info(), + output: makeOutput(200), + colors: darkColors, + onClose: () => {}, + }); + const out = strip(viewer.render(120).join('\n')); + expect(out).toContain('line-001'); + expect(out).not.toContain('line-200'); + }); + + it('skips re-split when output is unchanged', () => { + const same = makeOutput(20); + const viewer = makeViewer({ output: same, rows: 30 }); + viewer.handleInput('G'); + const before = strip(viewer.render(120).join('\n')); + viewer.setProps({ + taskId: 'bash-aaaaaaaa', + info: info(), + output: same, + colors: darkColors, + onClose: () => {}, + }); + const after = strip(viewer.render(120).join('\n')); + expect(after).toBe(before); + }); +}); diff --git a/apps/kimi-code/test/tui/tasks-browser.test.ts b/apps/kimi-code/test/tui/tasks-browser.test.ts new file mode 100644 index 000000000..fd973c8fa --- /dev/null +++ b/apps/kimi-code/test/tui/tasks-browser.test.ts @@ -0,0 +1,434 @@ +import type { Terminal } from '@earendil-works/pi-tui'; +import type { BackgroundTaskInfo, BackgroundTaskStatus } from '@moonshot-ai/kimi-code-sdk'; +import { describe, expect, it, vi } from 'vitest'; + +import { + TasksBrowserApp, + type TasksBrowserProps, + type TasksFilter, +} from '@/tui/components/dialogs/tasks-browser'; +import { darkColors } from '@/tui/theme/colors'; + +const ANSI_SGR = /\[[0-9;]*m/g; +function strip(text: string): string { + return text.replaceAll(ANSI_SGR, ''); +} + +/** Minimal Terminal stub — only `rows` is read by the component. */ +function fakeTerminal(rows: number, columns = 120): Terminal { + return { + start: () => {}, + stop: () => {}, + drainInput: () => Promise.resolve(), + write: () => {}, + get columns() { + return columns; + }, + get rows() { + return rows; + }, + get kittyProtocolActive() { + return false; + }, + moveBy: () => {}, + hideCursor: () => {}, + showCursor: () => {}, + clearLine: () => {}, + clearFromCursor: () => {}, + clearScreen: () => {}, + setTitle: () => {}, + setProgress: () => {}, + }; +} + +function task(overrides: Partial<BackgroundTaskInfo> = {}): BackgroundTaskInfo { + return { + taskId: 'bash-abcd1234', + command: 'npm run dev', + description: 'dev server', + status: 'running', + pid: 1234, + exitCode: null, + startedAt: Date.now() - 60_000, + endedAt: null, + ...overrides, + }; +} + +function makeProps(overrides: Partial<TasksBrowserProps> = {}): TasksBrowserProps { + return { + tasks: [], + filter: 'all', + selectedTaskId: undefined, + tailOutput: undefined, + tailLoading: false, + flashMessage: undefined, + colors: darkColors, + onSelect: vi.fn(), + onToggleFilter: vi.fn(), + onRefresh: vi.fn(), + onCancel: vi.fn(), + onStopConfirmed: vi.fn(), + onOpenOutput: vi.fn(), + onStopIgnored: vi.fn(), + ...overrides, + } as TasksBrowserProps; +} + +function makeApp( + props: Partial<TasksBrowserProps> = {}, + rows = 30, + columns = 120, +): TasksBrowserApp { + return new TasksBrowserApp(makeProps(props), fakeTerminal(rows, columns)); +} + +describe('TasksBrowserApp — full-screen rendering', () => { + it('fills exactly terminal.rows lines (height takeover)', () => { + const rows = 30; + const lines = makeApp({}, rows).render(120); + expect(lines.length).toBe(rows); + }); + + it('reacts to terminal height changes', () => { + const props = makeProps({ + tasks: [task({ taskId: 'bash-aaaaaaaa', status: 'running' })], + selectedTaskId: 'bash-aaaaaaaa', + }); + // Two terminals with different heights — verify render adapts. + const small = new TasksBrowserApp(props, fakeTerminal(15, 120)).render(120); + const big = new TasksBrowserApp(props, fakeTerminal(40, 120)).render(120); + expect(small.length).toBe(15); + expect(big.length).toBe(40); + }); + + it('shows the header row with TASK BROWSER title and counts', () => { + const props: Partial<TasksBrowserProps> = { + tasks: [ + task({ taskId: 'bash-aaaaaaaa', status: 'running' }), + task({ taskId: 'agent-bbbbbbbb', status: 'completed' }), + ], + }; + const out = strip(makeApp(props).render(120).join('\n')); + expect(out).toContain('TASK BROWSER'); + expect(out).toContain('filter=ALL'); + expect(out).toContain('1 running'); + expect(out).toContain('1 completed'); + expect(out).toContain('2 total'); + }); + + it('renders three framed panes: Tasks / Detail / Preview Output', () => { + const out = strip( + makeApp({ + tasks: [task({ taskId: 'bash-aaaaaaaa', status: 'running' })], + selectedTaskId: 'bash-aaaaaaaa', + }) + .render(120) + .join('\n'), + ); + expect(out).toContain('Tasks [all]'); + expect(out).toContain('Detail'); + expect(out).toContain('Preview Output'); + }); + + it('shows the selected task details in the Detail pane', () => { + const out = strip( + makeApp({ + tasks: [ + task({ + taskId: 'bash-aaaaaaaa', + status: 'running', + description: 'long running task', + pid: 9999, + }), + ], + selectedTaskId: 'bash-aaaaaaaa', + }) + .render(120) + .join('\n'), + ); + expect(out).toContain('Task ID:'); + expect(out).toContain('bash-aaaaaaaa'); + expect(out).toContain('long running task'); + }); + + it('renders tail output in the Preview Output pane', () => { + const out = strip( + makeApp({ + tasks: [task({ taskId: 'bash-aaaaaaaa' })], + selectedTaskId: 'bash-aaaaaaaa', + tailOutput: 'ready in 432ms\nlistening on :3000', + }) + .render(120) + .join('\n'), + ); + expect(out).toContain('ready in 432ms'); + expect(out).toContain('listening on :3000'); + }); + + it('shows a loading state when tail is loading', () => { + const out = strip( + makeApp({ + tasks: [task({ taskId: 'bash-aaaaaaaa' })], + selectedTaskId: 'bash-aaaaaaaa', + tailLoading: true, + }) + .render(120) + .join('\n'), + ); + expect(out).toContain('[loading'); + }); + + it('shows empty-state copy in the Tasks pane when no tasks', () => { + const out = strip(makeApp().render(120).join('\n')); + expect(out).toContain('No background tasks'); + }); + + it('filters out terminal tasks when filter=active', () => { + const tasks = [ + task({ taskId: 'bash-aaaaaaaa', status: 'running' }), + task({ taskId: 'bash-bbbbbbbb', status: 'completed' }), + ]; + const out = strip(makeApp({ tasks, filter: 'active' }).render(120).join('\n')); + expect(out).toContain('bash-aaaaaaaa'); + expect(out).not.toContain('bash-bbbbbbbb'); + }); + + it('renders without throwing for every BackgroundTaskStatus', () => { + const statuses: BackgroundTaskStatus[] = [ + 'running', + 'awaiting_approval', + 'completed', + 'failed', + 'killed', + 'lost', + ]; + for (const status of statuses) { + const props = makeProps({ + tasks: [task({ taskId: 'bash-aaaaaaaa', status })], + selectedTaskId: 'bash-aaaaaaaa', + }); + expect(() => new TasksBrowserApp(props, fakeTerminal(30)).render(120)).not.toThrow(); + } + }); + + it('falls back to a single line when the terminal is too small', () => { + const out = strip(makeApp({}, 5, 30).render(30).join('\n')); + expect(out).toContain('too small'); + }); +}); + +describe('TasksBrowserApp — input handling', () => { + it('Esc invokes onCancel', () => { + const onCancel = vi.fn(); + const app = makeApp({ onCancel }); + app.handleInput(''); + expect(onCancel).toHaveBeenCalledTimes(1); + }); + + it('q invokes onCancel', () => { + const onCancel = vi.fn(); + makeApp({ onCancel }).handleInput('q'); + expect(onCancel).toHaveBeenCalledTimes(1); + }); + + it('Tab invokes onToggleFilter', () => { + const onToggleFilter = vi.fn(); + makeApp({ onToggleFilter }).handleInput('\t'); + expect(onToggleFilter).toHaveBeenCalledTimes(1); + }); + + it('R invokes onRefresh', () => { + const onRefresh = vi.fn(); + makeApp({ onRefresh }).handleInput('r'); + expect(onRefresh).toHaveBeenCalledTimes(1); + }); + + it('arrow keys move selection and invoke onSelect', () => { + const onSelect = vi.fn(); + const tasks = [ + task({ taskId: 'bash-aaaaaaaa', status: 'running', startedAt: 1 }), + task({ taskId: 'bash-bbbbbbbb', status: 'running', startedAt: 2 }), + task({ taskId: 'bash-cccccccc', status: 'running', startedAt: 3 }), + ]; + const app = makeApp({ tasks, selectedTaskId: 'bash-aaaaaaaa', onSelect }); + app.handleInput(''); // ↓ + expect(onSelect).toHaveBeenLastCalledWith('bash-bbbbbbbb'); + app.handleInput('j'); + expect(onSelect).toHaveBeenLastCalledWith('bash-cccccccc'); + app.handleInput(''); // ↑ + expect(onSelect).toHaveBeenLastCalledWith('bash-bbbbbbbb'); + }); + + it('Enter and O both invoke onOpenOutput', () => { + const onOpenOutput = vi.fn(); + const app = makeApp({ + tasks: [task({ taskId: 'bash-aaaaaaaa' })], + selectedTaskId: 'bash-aaaaaaaa', + onOpenOutput, + }); + app.handleInput('o'); + app.handleInput('\r'); + expect(onOpenOutput).toHaveBeenCalledTimes(2); + expect(onOpenOutput).toHaveBeenCalledWith('bash-aaaaaaaa'); + }); +}); + +// When a terminal (e.g. the VSCode integrated terminal) enables the Kitty +// keyboard protocol disambiguate flag, ordinary printable keys arrive as +// CSI-u sequences: `r` → "\x1b[114u", `q` → "\x1b[113u". These tests pin +// down that the tasks panel's literal-character shortcuts still fire +// under Kitty mode. +describe('TasksBrowserApp — Kitty CSI-u printable input', () => { + const kitty = (ch: string): string => `\u001B[${String(ch.codePointAt(0) ?? 0)}u`; + + it('Kitty-encoded q invokes onCancel', () => { + const onCancel = vi.fn(); + makeApp({ onCancel }).handleInput(kitty('q')); + expect(onCancel).toHaveBeenCalledTimes(1); + }); + + it('Kitty-encoded r invokes onRefresh', () => { + const onRefresh = vi.fn(); + makeApp({ onRefresh }).handleInput(kitty('r')); + expect(onRefresh).toHaveBeenCalledTimes(1); + }); + + it('Kitty-encoded j moves selection down', () => { + const onSelect = vi.fn(); + const tasks = [ + task({ taskId: 'bash-aaaaaaaa', status: 'running', startedAt: 1 }), + task({ taskId: 'bash-bbbbbbbb', status: 'running', startedAt: 2 }), + ]; + const app = makeApp({ tasks, selectedTaskId: 'bash-aaaaaaaa', onSelect }); + app.handleInput(kitty('j')); + expect(onSelect).toHaveBeenLastCalledWith('bash-bbbbbbbb'); + }); + + it('Kitty-encoded o invokes onOpenOutput', () => { + const onOpenOutput = vi.fn(); + const app = makeApp({ + tasks: [task({ taskId: 'bash-aaaaaaaa' })], + selectedTaskId: 'bash-aaaaaaaa', + onOpenOutput, + }); + app.handleInput(kitty('o')); + expect(onOpenOutput).toHaveBeenCalledWith('bash-aaaaaaaa'); + }); + + it('Kitty-encoded s → y confirms a stop', () => { + const onStopConfirmed = vi.fn(); + const app = makeApp({ + tasks: [task({ taskId: 'bash-aaaaaaaa', status: 'running' })], + selectedTaskId: 'bash-aaaaaaaa', + onStopConfirmed, + }); + app.handleInput(kitty('s')); + app.handleInput(kitty('y')); + expect(onStopConfirmed).toHaveBeenCalledWith('bash-aaaaaaaa'); + }); +}); + +describe('TasksBrowserApp — stop confirmation', () => { + it('S → y confirms a stop and invokes onStopConfirmed', () => { + const onStopConfirmed = vi.fn(); + const app = makeApp({ + tasks: [task({ taskId: 'bash-aaaaaaaa', status: 'running' })], + selectedTaskId: 'bash-aaaaaaaa', + onStopConfirmed, + }); + app.handleInput('s'); + const after = strip(app.render(120).join('\n')); + expect(after).toContain('Stop bash-aaaaaaaa?'); + app.handleInput('y'); + expect(onStopConfirmed).toHaveBeenCalledWith('bash-aaaaaaaa'); + expect(strip(app.render(120).join('\n'))).not.toContain('Stop bash-aaaaaaaa?'); + }); + + it('S → n cancels without firing onStopConfirmed', () => { + const onStopConfirmed = vi.fn(); + const app = makeApp({ + tasks: [task({ taskId: 'bash-aaaaaaaa', status: 'running' })], + selectedTaskId: 'bash-aaaaaaaa', + onStopConfirmed, + }); + app.handleInput('s'); + app.handleInput('n'); + expect(onStopConfirmed).not.toHaveBeenCalled(); + expect(strip(app.render(120).join('\n'))).not.toContain('Stop bash-aaaaaaaa?'); + }); + + it('S → Esc cancels the confirm without closing the panel', () => { + const onStopConfirmed = vi.fn(); + const onCancel = vi.fn(); + const app = makeApp({ + tasks: [task({ taskId: 'bash-aaaaaaaa', status: 'running' })], + selectedTaskId: 'bash-aaaaaaaa', + onStopConfirmed, + onCancel, + }); + app.handleInput('s'); + app.handleInput(''); + expect(onStopConfirmed).not.toHaveBeenCalled(); + expect(onCancel).not.toHaveBeenCalled(); + }); + + it('S on a terminal task invokes onStopIgnored and stays out of confirm mode', () => { + const onStopConfirmed = vi.fn(); + const onStopIgnored = vi.fn(); + const app = makeApp({ + tasks: [task({ taskId: 'bash-aaaaaaaa', status: 'completed', exitCode: 0 })], + selectedTaskId: 'bash-aaaaaaaa', + onStopConfirmed, + onStopIgnored, + }); + app.handleInput('s'); + expect(onStopIgnored).toHaveBeenCalledWith('bash-aaaaaaaa', 'terminal'); + expect(onStopConfirmed).not.toHaveBeenCalled(); + expect(strip(app.render(120).join('\n'))).not.toContain('Stop bash-aaaaaaaa?'); + }); + + it('navigation during confirm mode is locked out', () => { + const onSelect = vi.fn(); + const onStopConfirmed = vi.fn(); + const tasks = [ + task({ taskId: 'bash-aaaaaaaa', status: 'running', startedAt: 1 }), + task({ taskId: 'bash-bbbbbbbb', status: 'running', startedAt: 2 }), + ]; + const app = makeApp({ tasks, selectedTaskId: 'bash-aaaaaaaa', onSelect, onStopConfirmed }); + app.handleInput('s'); + onSelect.mockClear(); + app.handleInput(''); // ↓ arrow should be swallowed + expect(onSelect).not.toHaveBeenCalled(); + expect(strip(app.render(120).join('\n'))).not.toContain('Stop bash-aaaaaaaa?'); + }); +}); + +describe('TasksBrowserApp — setProps', () => { + it('keeps selection across prop updates when the task still exists', () => { + const tasks = [ + task({ taskId: 'bash-aaaaaaaa', status: 'running' }), + task({ taskId: 'bash-bbbbbbbb', status: 'running' }), + ]; + const app = makeApp({ tasks, selectedTaskId: 'bash-bbbbbbbb' }); + app.setProps({ + ...makeProps({ + tasks: [...tasks, task({ taskId: 'bash-cccccccc', status: 'completed' })], + selectedTaskId: 'bash-bbbbbbbb', + }), + }); + const out = strip(app.render(120).join('\n')); + expect(out).toContain('bash-bbbbbbbb'); + }); + + it('switches the filter via setProps without throwing', () => { + const tasks = [task({ status: 'completed' })]; + const filters: TasksFilter[] = ['all', 'active', 'all']; + const app = makeApp({ tasks }); + for (const filter of filters) { + expect(() => { + app.setProps(makeProps({ tasks, filter })); + }).not.toThrow(); + } + }); +}); diff --git a/apps/kimi-code/test/tui/terminal-focus.test.ts b/apps/kimi-code/test/tui/terminal-focus.test.ts new file mode 100644 index 000000000..71246b785 --- /dev/null +++ b/apps/kimi-code/test/tui/terminal-focus.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it, vi } from 'vitest'; + +import type { TUIState } from '#/tui/kimi-tui'; +import { + DISABLE_TERMINAL_FOCUS_REPORTING, + ENABLE_TERMINAL_FOCUS_REPORTING, + TERMINAL_FOCUS_IN, + TERMINAL_FOCUS_OUT, + handleTerminalFocusInput, + installTerminalFocusTracking, +} from '#/tui/utils/terminal-focus'; + +describe('terminal focus tracking', () => { + it('updates focus state from terminal focus reporting sequences', () => { + const state = { focused: true }; + + expect(handleTerminalFocusInput(state, TERMINAL_FOCUS_OUT)).toEqual({ consume: true }); + expect(state.focused).toBe(false); + + expect(handleTerminalFocusInput(state, TERMINAL_FOCUS_IN)).toEqual({ consume: true }); + expect(state.focused).toBe(true); + + expect(handleTerminalFocusInput(state, 'x')).toBeUndefined(); + }); + + it('enables focus reporting and removes the listener on dispose', () => { + const listeners: Array<(data: string) => { consume: true } | undefined> = []; + const removeInputListener = vi.fn(); + const state = { + terminalState: { + focused: false, + }, + terminal: { + write: vi.fn(), + }, + ui: { + addInputListener: vi.fn((listener) => { + listeners.push(listener); + return removeInputListener; + }), + }, + } as unknown as TUIState; + + const dispose = installTerminalFocusTracking(state); + + expect(state.terminalState.focused).toBe(true); + expect(state.terminal.write).toHaveBeenCalledWith(ENABLE_TERMINAL_FOCUS_REPORTING); + expect(listeners).toHaveLength(1); + + listeners[0]?.(TERMINAL_FOCUS_OUT); + expect(state.terminalState.focused).toBe(false); + + dispose(); + + expect(removeInputListener).toHaveBeenCalledOnce(); + expect(state.terminal.write).toHaveBeenCalledWith(DISABLE_TERMINAL_FOCUS_REPORTING); + expect(state.terminalState.focused).toBe(true); + }); +}); diff --git a/apps/kimi-code/test/tui/terminal-notification.test.ts b/apps/kimi-code/test/tui/terminal-notification.test.ts new file mode 100644 index 000000000..2d5d904ee --- /dev/null +++ b/apps/kimi-code/test/tui/terminal-notification.test.ts @@ -0,0 +1,227 @@ +import { describe, expect, it, vi } from 'vitest'; + +import type { TUIState } from '#/tui/kimi-tui'; +import { + buildTerminalNotificationSequences, + emitTerminalNotification, + formatNotification, + isInsideTmux, + notifyTerminalOnce, + supportsOsc9Notification, +} from '#/tui/utils/terminal-notification'; + +function makeNotificationState(args: { + readonly enabled?: boolean; + readonly condition?: 'unfocused' | 'always'; + readonly focused?: boolean; + readonly supportsOsc9?: boolean; + readonly insideTmux?: boolean; +} = {}): TUIState { + return { + appState: { + notifications: { + enabled: args.enabled ?? true, + condition: args.condition ?? 'unfocused', + }, + }, + terminalState: { + notificationKeys: new Set<string>(), + focused: args.focused ?? false, + supportsOsc9: args.supportsOsc9 ?? true, + insideTmux: args.insideTmux ?? false, + }, + terminal: { + write: vi.fn(), + }, + } as unknown as TUIState; +} + +describe('terminal notification helpers', () => { + it('emits OSC 9 only when the terminal supports it', () => { + const terminal = { write: vi.fn() }; + + emitTerminalNotification( + terminal, + { title: 'Kimi Code', body: 'Approval\nrequired' }, + { supportsOsc9: true, insideTmux: false }, + ); + + expect(terminal.write).toHaveBeenCalledTimes(1); + expect(terminal.write).toHaveBeenCalledWith(']9;Kimi Code: Approval required'); + }); + + it('falls back to a bare BEL when the terminal does not support OSC 9', () => { + const terminal = { write: vi.fn() }; + + emitTerminalNotification( + terminal, + { title: 'Kimi Code', body: 'Approval required' }, + { supportsOsc9: false, insideTmux: false }, + ); + + expect(terminal.write).toHaveBeenCalledTimes(1); + expect(terminal.write).toHaveBeenCalledWith(''); + }); + + it('wraps OSC 9 in a tmux DCS passthrough when running inside tmux', () => { + const terminal = { write: vi.fn() }; + + emitTerminalNotification( + terminal, + { title: 'Kimi Code', body: 'Approval required' }, + { supportsOsc9: true, insideTmux: true }, + ); + + expect(terminal.write).toHaveBeenCalledTimes(1); + expect(terminal.write).toHaveBeenCalledWith('Ptmux;]9;Kimi Code: Approval required\\'); + }); + + it('skips the tmux wrap when falling back to BEL', () => { + const terminal = { write: vi.fn() }; + + emitTerminalNotification( + terminal, + { title: 'Kimi Code', body: 'Approval required' }, + { supportsOsc9: false, insideTmux: true }, + ); + + expect(terminal.write).toHaveBeenCalledWith(''); + }); + + it('emits nothing when the formatted message is empty', () => { + const terminal = { write: vi.fn() }; + + emitTerminalNotification( + terminal, + { title: '', body: '' }, + { supportsOsc9: true, insideTmux: false }, + ); + + expect(terminal.write).not.toHaveBeenCalled(); + }); + + it('deduplicates notifications by key on TUI state', () => { + const state = makeNotificationState(); + + notifyTerminalOnce(state, 'approval:req-1', { title: 'Approval required' }); + notifyTerminalOnce(state, 'approval:req-1', { title: 'Approval required' }); + notifyTerminalOnce(state, 'approval:req-2', { title: 'Approval required' }); + + expect(state.terminal.write).toHaveBeenCalledTimes(2); + }); + + it('suppresses notifications while the terminal is focused', () => { + const state = makeNotificationState({ focused: true }); + + notifyTerminalOnce(state, 'approval:req-1', { title: 'Approval required' }); + state.terminalState.focused = false; + notifyTerminalOnce(state, 'approval:req-1', { title: 'Approval required' }); + notifyTerminalOnce(state, 'approval:req-2', { title: 'Approval required' }); + + expect(state.terminal.write).toHaveBeenCalledTimes(1); + expect(state.terminalState.notificationKeys.has('approval:req-1')).toBe(true); + }); + + it('skips emission entirely when notifications.enabled is false', () => { + const state = makeNotificationState({ enabled: false }); + + notifyTerminalOnce(state, 'approval:req-1', { title: 'Approval required' }); + + expect(state.terminal.write).not.toHaveBeenCalled(); + expect(state.terminalState.notificationKeys.has('approval:req-1')).toBe(false); + }); + + it('emits even while focused when condition is "always"', () => { + const state = makeNotificationState({ condition: 'always', focused: true }); + + notifyTerminalOnce(state, 'approval:req-1', { title: 'Approval required' }); + + expect(state.terminal.write).toHaveBeenCalledTimes(1); + expect(state.terminalState.notificationKeys.has('approval:req-1')).toBe(true); + }); + + it('uses the tmux-wrapped sequence when state.insideTmux is true', () => { + const state = makeNotificationState({ insideTmux: true }); + + notifyTerminalOnce(state, 'approval:req-1', { title: 'Approval required' }); + + expect(state.terminal.write).toHaveBeenCalledTimes(1); + expect(state.terminal.write).toHaveBeenCalledWith('Ptmux;]9;Approval required\\'); + }); + + it('falls back to BEL on a TUI state that did not detect OSC 9 support', () => { + const state = makeNotificationState({ supportsOsc9: false }); + + notifyTerminalOnce(state, 'approval:req-1', { title: 'Approval required' }); + + expect(state.terminal.write).toHaveBeenCalledTimes(1); + expect(state.terminal.write).toHaveBeenCalledWith(''); + }); + + it('falls back to body when the title is empty', () => { + expect(formatNotification({ title: '', body: 'Question?' })).toBe('Question?'); + }); + + it('returns OSC 9 / BEL based on capability flag', () => { + expect( + buildTerminalNotificationSequences( + { title: 'A', body: 'B' }, + { supportsOsc9: true, insideTmux: false }, + ), + ).toEqual([']9;A: B']); + expect( + buildTerminalNotificationSequences( + { title: 'A', body: 'B' }, + { supportsOsc9: false, insideTmux: false }, + ), + ).toEqual(['']); + }); + + it('doubles ESC bytes inside the tmux DCS payload', () => { + const sequences = buildTerminalNotificationSequences( + { title: 'A', body: 'B' }, + { supportsOsc9: true, insideTmux: true }, + ); + + expect(sequences).toHaveLength(1); + const wrapped = sequences[0]!; + expect(wrapped.startsWith('Ptmux;')).toBe(true); + expect(wrapped.endsWith('\\')).toBe(true); + expect(wrapped).toContain(']9;A: B'); + }); +}); + +describe('supportsOsc9Notification', () => { + it('detects iTerm2 / WezTerm / Ghostty / Warp via TERM_PROGRAM', () => { + expect(supportsOsc9Notification({ TERM_PROGRAM: 'iTerm.app' })).toBe(true); + expect(supportsOsc9Notification({ TERM_PROGRAM: 'WezTerm' })).toBe(true); + expect(supportsOsc9Notification({ TERM_PROGRAM: 'ghostty' })).toBe(true); + expect(supportsOsc9Notification({ TERM_PROGRAM: 'WarpTerminal' })).toBe(true); + }); + + it('detects Kitty / Ghostty via TERM', () => { + expect(supportsOsc9Notification({ TERM: 'xterm-kitty' })).toBe(true); + expect(supportsOsc9Notification({ TERM: 'xterm-ghostty' })).toBe(true); + }); + + it('returns false for terminals known not to support OSC 9', () => { + expect(supportsOsc9Notification({ TERM_PROGRAM: 'Apple_Terminal' })).toBe(false); + expect(supportsOsc9Notification({ TERM_PROGRAM: 'vscode' })).toBe(false); + expect(supportsOsc9Notification({ TERM_PROGRAM: 'tabby' })).toBe(false); + expect(supportsOsc9Notification({ WT_SESSION: 'abc-123' })).toBe(false); + expect(supportsOsc9Notification({ ConEmuANSI: 'ON' })).toBe(false); + expect(supportsOsc9Notification({ TERM: 'xterm-256color' })).toBe(false); + expect(supportsOsc9Notification({})).toBe(false); + }); +}); + +describe('isInsideTmux', () => { + it('detects tmux via the TMUX env var', () => { + expect(isInsideTmux({ TMUX: '/private/tmp/tmux-501/default,1234,0' })).toBe(true); + }); + + it('returns false when TMUX is empty or unset', () => { + expect(isInsideTmux({ TMUX: '' })).toBe(false); + expect(isInsideTmux({})).toBe(false); + }); +}); diff --git a/apps/kimi-code/test/tui/terminal-theme.test.ts b/apps/kimi-code/test/tui/terminal-theme.test.ts new file mode 100644 index 000000000..68a1818b2 --- /dev/null +++ b/apps/kimi-code/test/tui/terminal-theme.test.ts @@ -0,0 +1,160 @@ +import { describe, expect, it, vi } from "vitest"; + +import type { TUIState } from "#/tui/kimi-tui"; +import { + DISABLE_TERMINAL_THEME_REPORTING, + ENABLE_TERMINAL_THEME_REPORTING, + OSC11_QUERY, + QUERY_TERMINAL_THEME, + TERMINAL_THEME_DARK, + TERMINAL_THEME_LIGHT, + createTerminalThemeInputState, + hasTerminalThemeReport, + handleTerminalThemeInput, + installTerminalThemeTracking, +} from "#/tui/utils/terminal-theme"; + +type InputListener = Parameters<TUIState["ui"]["addInputListener"]>[0]; +const DARK_OSC11_REPORT = "\u001B]11;rgb:2828/2c2c/3434\u0007"; +const LIGHT_OSC11_REPORT = "\u001B]11;rgb:fafa/fbfb/fcfc\u0007"; + +describe("terminal theme tracking", () => { + it("recognizes terminal theme reports as change notifications", () => { + expect(hasTerminalThemeReport(TERMINAL_THEME_DARK)).toBe(true); + expect(hasTerminalThemeReport(TERMINAL_THEME_LIGHT)).toBe(true); + expect(hasTerminalThemeReport(`x${TERMINAL_THEME_LIGHT}y`)).toBe(true); + expect(hasTerminalThemeReport("x")).toBe(false); + }); + + it("consumes theme reports by querying the terminal background", () => { + const terminal = { write: vi.fn() }; + const onTheme = vi.fn(); + + expect(handleTerminalThemeInput(TERMINAL_THEME_DARK, terminal, onTheme)).toEqual({ + consume: true, + }); + expect(handleTerminalThemeInput(TERMINAL_THEME_LIGHT, terminal, onTheme)).toEqual({ + consume: true, + }); + + expect(terminal.write).toHaveBeenCalledTimes(2); + expect(terminal.write).toHaveBeenCalledWith(OSC11_QUERY); + expect(onTheme).not.toHaveBeenCalled(); + }); + + it("strips terminal theme reports without dropping coalesced input", () => { + const terminal = { write: vi.fn() }; + const onTheme = vi.fn(); + + expect(handleTerminalThemeInput(`a${TERMINAL_THEME_LIGHT}b`, terminal, onTheme)).toEqual({ + data: "ab", + }); + + expect(terminal.write).toHaveBeenCalledWith(OSC11_QUERY); + expect(onTheme).not.toHaveBeenCalled(); + }); + + it("consumes OSC 11 background reports and forwards resolved themes", () => { + const terminal = { write: vi.fn() }; + const onTheme = vi.fn(); + + expect(handleTerminalThemeInput(DARK_OSC11_REPORT, terminal, onTheme)).toEqual({ + consume: true, + }); + expect(onTheme).toHaveBeenLastCalledWith("dark"); + + expect(handleTerminalThemeInput(LIGHT_OSC11_REPORT, terminal, onTheme)).toEqual({ + consume: true, + }); + expect(onTheme).toHaveBeenLastCalledWith("light"); + + expect(handleTerminalThemeInput("x", terminal, onTheme)).toBeUndefined(); + expect(handleTerminalThemeInput("]", terminal, onTheme)).toBeUndefined(); + expect(onTheme).toHaveBeenCalledTimes(2); + expect(terminal.write).not.toHaveBeenCalled(); + }); + + it("strips OSC 11 background reports without dropping coalesced input", () => { + const terminal = { write: vi.fn() }; + const onTheme = vi.fn(); + + expect(handleTerminalThemeInput(`a${DARK_OSC11_REPORT}b`, terminal, onTheme)).toEqual({ + data: "ab", + }); + + expect(onTheme).toHaveBeenCalledWith("dark"); + expect(terminal.write).not.toHaveBeenCalled(); + }); + + it("accumulates fragmented OSC 11 background reports", () => { + const terminal = { write: vi.fn() }; + const onTheme = vi.fn(); + const inputState = createTerminalThemeInputState(); + + expect( + handleTerminalThemeInput("\u001B]11;rgb:2828/2c2c/3", terminal, onTheme, inputState), + ).toEqual({ consume: true }); + expect(onTheme).not.toHaveBeenCalled(); + + expect(handleTerminalThemeInput("434\u0007", terminal, onTheme, inputState)).toEqual({ + consume: true, + }); + expect(onTheme).toHaveBeenCalledWith("dark"); + expect(inputState.osc11Buffer).toBe(""); + }); + + it("forwards input that follows a fragmented OSC 11 background report", () => { + const terminal = { write: vi.fn() }; + const onTheme = vi.fn(); + const inputState = createTerminalThemeInputState(); + + expect( + handleTerminalThemeInput("\u001B]11;rgb:2828/2c2c/3", terminal, onTheme, inputState), + ).toEqual({ consume: true }); + + expect(handleTerminalThemeInput("434\u0007x", terminal, onTheme, inputState)).toEqual({ + data: "x", + }); + expect(onTheme).toHaveBeenCalledWith("dark"); + expect(inputState.osc11Buffer).toBe(""); + }); + + it("enables reporting, queries current theme, and disables on dispose", () => { + const listeners: InputListener[] = []; + const removeInputListener = vi.fn(); + const onTheme = vi.fn(); + const state = { + terminal: { + write: vi.fn(), + }, + ui: { + addInputListener: vi.fn((listener: InputListener) => { + listeners.push(listener); + return removeInputListener; + }), + }, + } as unknown as Pick<TUIState, "terminal" | "ui">; + + const dispose = installTerminalThemeTracking(state, onTheme); + + expect(state.terminal.write).toHaveBeenCalledWith(ENABLE_TERMINAL_THEME_REPORTING); + expect(state.terminal.write).toHaveBeenCalledWith(OSC11_QUERY); + expect(state.terminal.write).toHaveBeenCalledWith(QUERY_TERMINAL_THEME); + expect(listeners).toHaveLength(1); + + expect(listeners[0]?.(TERMINAL_THEME_LIGHT)).toEqual({ consume: true }); + expect(state.terminal.write).toHaveBeenCalledWith(OSC11_QUERY); + expect(onTheme).not.toHaveBeenCalled(); + + expect(listeners[0]?.("\u001B]11;rgb:2828/2c2c/3")).toEqual({ consume: true }); + expect(onTheme).not.toHaveBeenCalled(); + + expect(listeners[0]?.("434\u0007")).toEqual({ consume: true }); + expect(onTheme).toHaveBeenCalledWith("dark"); + + dispose(); + + expect(removeInputListener).toHaveBeenCalledOnce(); + expect(state.terminal.write).toHaveBeenCalledWith(DISABLE_TERMINAL_THEME_REPORTING); + }); +}); diff --git a/apps/kimi-code/test/tui/utils/mcp-oauth.test.ts b/apps/kimi-code/test/tui/utils/mcp-oauth.test.ts new file mode 100644 index 000000000..1e1808508 --- /dev/null +++ b/apps/kimi-code/test/tui/utils/mcp-oauth.test.ts @@ -0,0 +1,106 @@ +import { + MCP_OAUTH_AUTHORIZATION_URL_TOOL_UPDATE, + type ToolUpdate, +} from '@moonshot-ai/kimi-code-sdk'; +import { describe, expect, it, vi } from 'vitest'; + +import { + McpOAuthAuthorizationUrlOpener, + type OpenUrl, + parseMcpOAuthAuthorizationUrlUpdate, +} from '#/tui/utils/mcp-oauth'; + +describe('parseMcpOAuthAuthorizationUrlUpdate', () => { + it('extracts MCP OAuth authorization URLs from structured tool updates', () => { + const update: ToolUpdate = { + kind: 'custom', + customKind: MCP_OAUTH_AUTHORIZATION_URL_TOOL_UPDATE, + customData: { + serverName: 'linear', + authorizationUrl: 'https://linear.example/oauth?state=abc', + }, + }; + + expect(parseMcpOAuthAuthorizationUrlUpdate(update)).toEqual({ + serverName: 'linear', + authorizationUrl: 'https://linear.example/oauth?state=abc', + }); + }); + + it('ignores unrelated or malformed updates', () => { + const unrelated: ToolUpdate = { + kind: 'status', + text: 'https://linear.example/oauth?state=abc', + }; + const malformed: ToolUpdate = { + kind: 'custom', + customKind: MCP_OAUTH_AUTHORIZATION_URL_TOOL_UPDATE, + customData: { + serverName: 'linear', + authorizationUrl: 'file:///tmp/callback', + }, + }; + + expect(parseMcpOAuthAuthorizationUrlUpdate(unrelated)).toBeUndefined(); + expect(parseMcpOAuthAuthorizationUrlUpdate(malformed)).toBeUndefined(); + }); +}); + +describe('McpOAuthAuthorizationUrlOpener', () => { + it('opens authorization URLs from structured tool progress updates', () => { + const openUrl = vi.fn<OpenUrl>(); + const opener = new McpOAuthAuthorizationUrlOpener(openUrl); + + opener.handleToolProgress({ + toolCallId: 'tool-1', + update: authorizationUrlUpdate('https://linear.example/oauth?state=abc'), + }); + + expect(openUrl).toHaveBeenCalledTimes(1); + expect(openUrl).toHaveBeenCalledWith('https://linear.example/oauth?state=abc'); + }); + + it('opens each authorization URL once per tool call', () => { + const openUrl = vi.fn<OpenUrl>(); + const opener = new McpOAuthAuthorizationUrlOpener(openUrl); + const update = authorizationUrlUpdate('https://linear.example/oauth?state=abc'); + + opener.handleToolProgress({ toolCallId: 'tool-1', update }); + opener.handleToolProgress({ toolCallId: 'tool-1', update }); + opener.handleToolProgress({ toolCallId: 'tool-2', update }); + + expect(openUrl).toHaveBeenCalledTimes(2); + expect(openUrl).toHaveBeenNthCalledWith(1, 'https://linear.example/oauth?state=abc'); + expect(openUrl).toHaveBeenNthCalledWith(2, 'https://linear.example/oauth?state=abc'); + }); + + it('ignores progress updates that do not contain an MCP OAuth authorization URL', () => { + const openUrl = vi.fn<OpenUrl>(); + const opener = new McpOAuthAuthorizationUrlOpener(openUrl); + + opener.handleToolProgress({ + toolCallId: 'tool-1', + update: { + kind: 'status', + text: 'https://linear.example/oauth?state=abc', + }, + }); + opener.handleToolProgress({ + toolCallId: 'tool-1', + update: authorizationUrlUpdate('file:///tmp/callback'), + }); + + expect(openUrl).not.toHaveBeenCalled(); + }); +}); + +function authorizationUrlUpdate(authorizationUrl: string): ToolUpdate { + return { + kind: 'custom', + customKind: MCP_OAUTH_AUTHORIZATION_URL_TOOL_UPDATE, + customData: { + serverName: 'linear', + authorizationUrl, + }, + }; +} diff --git a/apps/kimi-code/test/utils/clipboard/clipboard-image.test.ts b/apps/kimi-code/test/utils/clipboard/clipboard-image.test.ts new file mode 100644 index 000000000..7e802db6a --- /dev/null +++ b/apps/kimi-code/test/utils/clipboard/clipboard-image.test.ts @@ -0,0 +1,161 @@ +import { mkdtempSync, rmSync, truncateSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; +import { pathToFileURL } from 'node:url'; + +import { describe, expect, it, vi } from 'vitest'; + +import { ClipboardMediaError, readClipboardMedia } from '#/utils/clipboard/clipboard-image'; +import type { ClipboardModule } from '#/utils/clipboard/clipboard-native'; + +function png(width: number, height: number): Uint8Array { + const bytes = new Uint8Array(24); + bytes.set([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a], 0); + bytes.set([0x00, 0x00, 0x00, 0x0d], 8); + bytes.set([0x49, 0x48, 0x44, 0x52], 12); + bytes[16] = (width >>> 24) & 0xff; + bytes[17] = (width >>> 16) & 0xff; + bytes[18] = (width >>> 8) & 0xff; + bytes[19] = width & 0xff; + bytes[20] = (height >>> 24) & 0xff; + bytes[21] = (height >>> 16) & 0xff; + bytes[22] = (height >>> 8) & 0xff; + bytes[23] = height & 0xff; + return bytes; +} + +function fakeClipboard(overrides: Partial<ClipboardModule>): ClipboardModule { + return { + hasImage: vi.fn(() => false), + getImageBinary: vi.fn(async () => []), + ...overrides, + }; +} + +function noMacOsPaths(): { stdout: Buffer; ok: boolean } { + return { stdout: Buffer.alloc(0), ok: false }; +} + +describe('readClipboardMedia', () => { + it('reads a copied image file from its real path instead of the Finder preview icon', async () => { + const dir = mkdtempSync(join(tmpdir(), 'kimi-code-clip-')); + try { + const imagePath = join(dir, 'photo.png'); + const imageBytes = png(12, 34); + writeFileSync(imagePath, imageBytes); + const getImageBinary = vi.fn(async () => Array.from(png(1, 1))); + const clip = fakeClipboard({ + availableFormats: vi.fn(() => ['public.file-url', 'public.png']), + hasImage: vi.fn(() => true), + getImageBinary, + }); + const runCommand = vi.fn(() => ({ stdout: Buffer.from(`${imagePath}\n`), ok: true })); + + const media = await readClipboardMedia({ platform: 'darwin', clipboard: clip, runCommand }); + + expect(media).toEqual({ + kind: 'image', + bytes: imageBytes, + mimeType: 'image/png', + }); + expect(getImageBinary).not.toHaveBeenCalled(); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it('prefers a video file URL over an available image preview', async () => { + const dir = mkdtempSync(join(tmpdir(), 'kimi-code-clip-')); + try { + const videoPath = join(dir, 'sample.mov'); + writeFileSync(videoPath, new Uint8Array([0, 1, 2])); + const getImageBinary = vi.fn(async () => [0x89, 0x50, 0x4e, 0x47]); + const clip = fakeClipboard({ + availableFormats: vi.fn(() => ['public.file-url', 'public.png']), + hasText: vi.fn(() => true), + getText: vi.fn(async () => pathToFileURL(videoPath).toString()), + hasImage: vi.fn(() => true), + getImageBinary, + }); + + const media = await readClipboardMedia({ + platform: 'darwin', + clipboard: clip, + runCommand: noMacOsPaths, + }); + + expect(media?.kind).toBe('video'); + expect(media).toMatchObject({ + mimeType: 'video/quicktime', + filename: 'sample.mov', + sourcePath: videoPath, + }); + expect(getImageBinary).not.toHaveBeenCalled(); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it('falls back to native image bytes when no video file is present', async () => { + const clip = fakeClipboard({ + availableFormats: vi.fn(() => ['public.png']), + hasText: vi.fn(() => false), + hasImage: vi.fn(() => true), + getImageBinary: vi.fn(async () => [0x89, 0x50, 0x4e, 0x47]), + }); + + const media = await readClipboardMedia({ + platform: 'darwin', + clipboard: clip, + runCommand: noMacOsPaths, + }); + + expect(media).toEqual({ + kind: 'image', + bytes: new Uint8Array([0x89, 0x50, 0x4e, 0x47]), + mimeType: 'image/png', + }); + }); + + it('does not consume file-like clipboard image previews when no real file path is readable', async () => { + const getImageBinary = vi.fn(async () => Array.from(png(1, 1))); + const clip = fakeClipboard({ + availableFormats: vi.fn(() => ['public.file-url', 'public.png']), + hasImage: vi.fn(() => true), + getImageBinary, + }); + + const media = await readClipboardMedia({ + platform: 'darwin', + clipboard: clip, + runCommand: noMacOsPaths, + }); + + expect(media).toBeNull(); + expect(getImageBinary).not.toHaveBeenCalled(); + }); + + it('rejects pasted videos larger than 100 MB', async () => { + const dir = mkdtempSync(join(tmpdir(), 'kimi-code-clip-')); + try { + const videoPath = resolve(dir, 'too-big.mp4'); + writeFileSync(videoPath, new Uint8Array([0])); + truncateSync(videoPath, 101 * 1024 * 1024); + const clip = fakeClipboard({ + availableFormats: vi.fn(() => ['public.file-url']), + hasText: vi.fn(() => true), + getText: vi.fn(async () => pathToFileURL(videoPath).toString()), + }); + + await expect( + readClipboardMedia({ + platform: 'darwin', + clipboard: clip, + runCommand: noMacOsPaths, + }), + ).rejects.toThrow(ClipboardMediaError); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); +}); diff --git a/apps/kimi-code/test/utils/git/git-ls-files.test.ts b/apps/kimi-code/test/utils/git/git-ls-files.test.ts new file mode 100644 index 000000000..d7b193a5c --- /dev/null +++ b/apps/kimi-code/test/utils/git/git-ls-files.test.ts @@ -0,0 +1,113 @@ +import { execFileSync } from 'node:child_process'; +import { mkdtempSync, rmSync, writeFileSync, mkdirSync, utimesSync, statSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; + +import { createGitLsFilesCache } from '#/utils/git/git-ls-files'; + +function git(cwd: string, ...args: string[]): void { + execFileSync('git', args, { cwd, stdio: 'ignore' }); +} + +function commit(cwd: string, message: string): void { + git(cwd, '-c', 'user.email=test@example.com', '-c', 'user.name=Test', 'commit', '-m', message); +} + +describe('createGitLsFilesCache', () => { + let dir: string; + + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'git-ls-files-test-')); + }); + + afterEach(() => { + rmSync(dir, { recursive: true, force: true }); + }); + + it('returns null for a non-git directory', () => { + const cache = createGitLsFilesCache(dir); + expect(cache.isGitRepo()).toBe(false); + expect(cache.list()).toBeNull(); + }); + + it('lists tracked files in a git repo', () => { + git(dir, 'init'); + writeFileSync(join(dir, 'a.ts'), ''); + mkdirSync(join(dir, 'src')); + writeFileSync(join(dir, 'src/b.ts'), ''); + git(dir, 'add', '.'); + commit(dir, 'init'); + + const cache = createGitLsFilesCache(dir); + expect(cache.isGitRepo()).toBe(true); + const snap = cache.getSnapshot(); + expect(snap).not.toBeNull(); + expect(snap!.files).toContain('a.ts'); + expect(snap!.files).toContain('src/b.ts'); + expect(snap!.mtimeByPath.has('a.ts')).toBe(true); + expect(snap!.mtimeByPath.get('a.ts')!).toBeGreaterThan(0); + expect(cache.getSnapshot()).toBe(snap); + }); + + it('includes untracked-but-not-ignored files', () => { + git(dir, 'init'); + writeFileSync(join(dir, '.gitignore'), 'ignored.ts\n'); + writeFileSync(join(dir, 'tracked.ts'), ''); + git(dir, 'add', 'tracked.ts'); + commit(dir, 'init'); + + // Create both an untracked file and one that matches .gitignore. + writeFileSync(join(dir, 'new.ts'), ''); + writeFileSync(join(dir, 'ignored.ts'), ''); + + const cache = createGitLsFilesCache(dir); + const files = cache.list()!; + expect(files).toContain('tracked.ts'); + expect(files).toContain('new.ts'); + expect(files).not.toContain('ignored.ts'); + }); + + it('builds a recency order from recent commits', () => { + git(dir, 'init'); + writeFileSync(join(dir, 'old.ts'), ''); + git(dir, 'add', 'old.ts'); + commit(dir, 'old'); + writeFileSync(join(dir, 'new.ts'), ''); + git(dir, 'add', 'new.ts'); + commit(dir, 'new'); + + const cache = createGitLsFilesCache(dir); + const snap = cache.getSnapshot()!; + const newRank = snap.recencyOrder.get('new.ts'); + const oldRank = snap.recencyOrder.get('old.ts'); + expect(newRank).toBeDefined(); + expect(oldRank).toBeDefined(); + expect(newRank!).toBeLessThan(oldRank!); + }); + + it('invalidates when .git/index mtime changes', () => { + git(dir, 'init'); + writeFileSync(join(dir, 'a.ts'), ''); + git(dir, 'add', '.'); + commit(dir, 'init'); + + const cache = createGitLsFilesCache(dir); + const first = cache.list()!; + expect(first).toContain('a.ts'); + + // Touch .git/index forward to simulate a new commit / add. + const indexPath = join(dir, '.git', 'index'); + const s = statSync(indexPath); + const future = new Date(s.mtimeMs + 5000); + utimesSync(indexPath, future, future); + + writeFileSync(join(dir, 'b.ts'), ''); + git(dir, 'add', 'b.ts'); + + const second = cache.list()!; + expect(second).not.toBe(first); // new snapshot + expect(second).toContain('b.ts'); + }); +}); diff --git a/apps/kimi-code/test/utils/git/git-status.test.ts b/apps/kimi-code/test/utils/git/git-status.test.ts new file mode 100644 index 000000000..fed27dd87 --- /dev/null +++ b/apps/kimi-code/test/utils/git/git-status.test.ts @@ -0,0 +1,200 @@ +/* eslint-disable import/first -- vi.mock setup must run before the imports it stubs out. */ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +const mocks = vi.hoisted(() => ({ + spawnSync: vi.fn(), + execFile: vi.fn(), +})); + +vi.mock('node:child_process', () => ({ + execFile: mocks.execFile, + spawnSync: mocks.spawnSync, +})); + +import { createGitStatusCache, formatGitBadge } from '#/utils/git/git-status'; + +afterEach(() => { + vi.useRealTimers(); + vi.clearAllMocks(); +}); + +describe('git status cache', () => { + it('caches branch and status reads until their TTL expires', async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-04-24T00:00:00Z')); + mocks.execFile.mockImplementation( + ( + _cmd: string, + _args: string[], + _options: unknown, + callback: (error: Error | null, stdout: string, stderr: string) => void, + ) => { + callback(new Error('no pull request'), '', ''); + }, + ); + mocks.spawnSync.mockImplementation((_cmd: string, args: string[]) => { + if (args.includes('rev-parse')) { + return { status: 0, stdout: 'true\n' }; + } + if (args.includes('branch')) { + return { status: 0, stdout: 'main\n' }; + } + if (args.includes('status')) { + return { + status: 0, + stdout: '## main...origin/main [ahead 2, behind 1]\n M src/app.ts\n', + }; + } + if (args.includes('diff')) { + return { status: 0, stdout: '4\t1\tsrc/app.ts\n' }; + } + return { status: 1, stdout: '' }; + }); + + const cache = createGitStatusCache('/tmp/repo'); + + expect(cache.getStatus()).toEqual({ + branch: 'main', + dirty: true, + ahead: 2, + behind: 1, + diffAdded: 4, + diffDeleted: 1, + pullRequest: null, + }); + expect(cache.getStatus()).toEqual({ + branch: 'main', + dirty: true, + ahead: 2, + behind: 1, + diffAdded: 4, + diffDeleted: 1, + pullRequest: null, + }); + expect(mocks.spawnSync).toHaveBeenCalledTimes(4); + expect(mocks.execFile).toHaveBeenCalledTimes(1); + + await Promise.resolve(); + + vi.setSystemTime(new Date('2026-04-24T00:00:06Z')); + cache.getStatus(); + expect(mocks.spawnSync).toHaveBeenCalledTimes(5); + expect(mocks.execFile).toHaveBeenCalledTimes(1); + + vi.setSystemTime(new Date('2026-04-24T00:00:16Z')); + cache.getStatus(); + expect(mocks.spawnSync).toHaveBeenCalledTimes(8); + expect(mocks.execFile).toHaveBeenCalledTimes(1); + }); + + it('reads uncommitted diff line counts and current pull request metadata', async () => { + const onChange = vi.fn(); + mocks.execFile.mockImplementation( + ( + _cmd: string, + _args: string[], + _options: unknown, + callback: (error: Error | null, stdout: string, stderr: string) => void, + ) => { + callback(null, '{"number":12,"url":"https://github.com/acme/repo/pull/12"}\n', ''); + }, + ); + mocks.spawnSync.mockImplementation((_cmd: string, args: string[]) => { + if (args.includes('rev-parse')) { + return { status: 0, stdout: 'true\n' }; + } + if (args.includes('branch')) { + return { status: 0, stdout: 'feature/footer\n' }; + } + if (args.includes('status')) { + return { + status: 0, + stdout: '## feature/footer...origin/feature/footer\n M src/app.ts\n', + }; + } + if (args.includes('diff')) { + return { + status: 0, + stdout: '10\t3\tsrc/app.ts\n-\t-\timage.png\n0\t5\tdeleted.ts\n', + }; + } + return { status: 1, stdout: '' }; + }); + + const cache = createGitStatusCache('/tmp/repo', { onChange }); + expect(cache.getStatus()).toEqual({ + branch: 'feature/footer', + dirty: true, + ahead: 0, + behind: 0, + diffAdded: 10, + diffDeleted: 8, + pullRequest: null, + }); + + await Promise.resolve(); + + expect(onChange).toHaveBeenCalledTimes(1); + expect(cache.getStatus()).toEqual({ + branch: 'feature/footer', + dirty: true, + ahead: 0, + behind: 0, + diffAdded: 10, + diffDeleted: 8, + pullRequest: { + number: 12, + url: 'https://github.com/acme/repo/pull/12', + }, + }); + }); + + it('returns null when the working directory is not a git repo and formats badges', () => { + mocks.spawnSync.mockReturnValue({ status: 1, stdout: '' }); + expect(createGitStatusCache('/tmp/not-a-repo').getStatus()).toBeNull(); + expect( + formatGitBadge({ + branch: 'main', + dirty: true, + ahead: 2, + behind: 1, + diffAdded: 12, + diffDeleted: 3, + pullRequest: null, + }), + ).toBe('main [+12 -3 ↑2↓1]'); + expect( + formatGitBadge({ + branch: 'main', + dirty: true, + ahead: 0, + behind: 0, + diffAdded: 0, + diffDeleted: 0, + pullRequest: null, + }), + ).toBe('main [±]'); + }); + + it('formats pull request badges as terminal hyperlinks when requested', () => { + const linked = formatGitBadge( + { + branch: 'feature/footer', + dirty: false, + ahead: 0, + behind: 0, + diffAdded: 0, + diffDeleted: 0, + pullRequest: { + number: 12, + url: 'https://github.com/acme/repo/pull/12', + }, + }, + { linkPullRequest: true }, + ); + + expect(linked).toContain('[PR#12]'); + expect(linked).toContain('\u001B]8;;https://github.com/acme/repo/pull/12\u0007'); + expect(linked).toContain('\u001B]8;;\u0007'); + }); +}); diff --git a/apps/kimi-code/test/utils/history/input-history.test.ts b/apps/kimi-code/test/utils/history/input-history.test.ts new file mode 100644 index 000000000..43269f3d7 --- /dev/null +++ b/apps/kimi-code/test/utils/history/input-history.test.ts @@ -0,0 +1,84 @@ +import { mkdtempSync, rmSync, writeFileSync, readFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { afterEach, beforeEach, describe, it, expect } from 'vitest'; + +import { loadInputHistory, appendInputHistory } from '#/utils/history/input-history'; + +let dir: string; +let file: string; + +beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'input-history-')); + file = join(dir, 'history.jsonl'); +}); + +afterEach(() => { + rmSync(dir, { recursive: true, force: true }); +}); + +describe('input-history', () => { + it('returns empty when file does not exist', async () => { + expect(await loadInputHistory(file)).toEqual([]); + }); + + it('parses valid jsonl entries in order', async () => { + writeFileSync( + file, + [ + JSON.stringify({ content: 'first' }), + JSON.stringify({ content: 'second' }), + '', + JSON.stringify({ content: 'third' }), + ].join('\n'), + 'utf-8', + ); + const entries = await loadInputHistory(file); + expect(entries).toEqual([{ content: 'first' }, { content: 'second' }, { content: 'third' }]); + }); + + it('skips malformed lines without aborting the load', async () => { + writeFileSync( + file, + [ + JSON.stringify({ content: 'good' }), + 'not json', + JSON.stringify({ wrong_field: 'x' }), + JSON.stringify({ content: 'tail' }), + ].join('\n'), + 'utf-8', + ); + const entries = await loadInputHistory(file); + expect(entries).toEqual([{ content: 'good' }, { content: 'tail' }]); + }); + + it('appends a new entry and creates the parent directory', async () => { + const nested = join(dir, 'nested', 'sub', 'history.jsonl'); + const written = await appendInputHistory(nested, 'hello'); + expect(written).toBe(true); + const raw = readFileSync(nested, 'utf-8').trim().split('\n'); + expect(raw).toHaveLength(1); + expect(JSON.parse(raw[0]!)).toEqual({ content: 'hello' }); + }); + + it('skips empty / whitespace-only entries', async () => { + expect(await appendInputHistory(file, '')).toBe(false); + expect(await appendInputHistory(file, ' ')).toBe(false); + expect(await loadInputHistory(file)).toEqual([]); + }); + + it('skips when content equals the previous entry (consecutive dedup)', async () => { + expect(await appendInputHistory(file, 'a')).toBe(true); + expect(await appendInputHistory(file, 'a', 'a')).toBe(false); + expect(await appendInputHistory(file, 'b', 'a')).toBe(true); + const entries = await loadInputHistory(file); + expect(entries).toEqual([{ content: 'a' }, { content: 'b' }]); + }); + + it('trims whitespace before persisting', async () => { + expect(await appendInputHistory(file, ' hi ')).toBe(true); + const entries = await loadInputHistory(file); + expect(entries).toEqual([{ content: 'hi' }]); + }); +}); diff --git a/apps/kimi-code/test/utils/image/image-mime.test.ts b/apps/kimi-code/test/utils/image/image-mime.test.ts new file mode 100644 index 000000000..bcc426bdf --- /dev/null +++ b/apps/kimi-code/test/utils/image/image-mime.test.ts @@ -0,0 +1,98 @@ +import { describe, it, expect } from 'vitest'; + +import { parseImageMeta } from '#/utils/image/image-mime'; + +function png(width: number, height: number): Uint8Array { + // 8-byte PNG signature + IHDR length (4) + 'IHDR' + width (4 BE) + height (4 BE) + ... + const bytes = new Uint8Array(24); + bytes.set([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a], 0); + // length = 13 (IHDR body) + bytes.set([0x00, 0x00, 0x00, 0x0d], 8); + bytes.set([0x49, 0x48, 0x44, 0x52], 12); // "IHDR" + // width + bytes[16] = (width >>> 24) & 0xff; + bytes[17] = (width >>> 16) & 0xff; + bytes[18] = (width >>> 8) & 0xff; + bytes[19] = width & 0xff; + // height + bytes[20] = (height >>> 24) & 0xff; + bytes[21] = (height >>> 16) & 0xff; + bytes[22] = (height >>> 8) & 0xff; + bytes[23] = height & 0xff; + return bytes; +} + +function jpeg(width: number, height: number): Uint8Array { + // SOI + SOF0 marker with the minimal segment body. + // Layout: FFD8 FFC0 <len16 BE> <precision 08> <height BE> <width BE> <components 03> + const body = new Uint8Array([ + 0xff, + 0xd8, // SOI + 0xff, + 0xc0, // SOF0 + 0x00, + 0x11, // segment length (17 bytes including itself) + 0x08, // precision + (height >> 8) & 0xff, + height & 0xff, + (width >> 8) & 0xff, + width & 0xff, + 0x03, // components + // 3 component spec blocks (3 bytes each) — not parsed but required for realism + 0x01, + 0x22, + 0x00, + 0x02, + 0x11, + 0x01, + 0x03, + 0x11, + 0x01, + ]); + return body; +} + +function gif(width: number, height: number): Uint8Array { + const bytes = new Uint8Array(10); + // "GIF89a" + bytes.set([0x47, 0x49, 0x46, 0x38, 0x39, 0x61], 0); + bytes[6] = width & 0xff; + bytes[7] = (width >> 8) & 0xff; + bytes[8] = height & 0xff; + bytes[9] = (height >> 8) & 0xff; + return bytes; +} + +describe('parseImageMeta', () => { + it('recognises PNG with correct dimensions', () => { + const meta = parseImageMeta(png(640, 480)); + expect(meta).toEqual({ mime: 'image/png', width: 640, height: 480 }); + }); + + it('recognises JPEG with correct dimensions', () => { + const meta = parseImageMeta(jpeg(1280, 720)); + expect(meta).toEqual({ mime: 'image/jpeg', width: 1280, height: 720 }); + }); + + it('recognises GIF (89a)', () => { + const meta = parseImageMeta(gif(100, 200)); + expect(meta).toEqual({ mime: 'image/gif', width: 100, height: 200 }); + }); + + it('recognises GIF (87a)', () => { + const bytes = gif(50, 75); + bytes[4] = 0x37; // '7' + const meta = parseImageMeta(bytes); + expect(meta).toEqual({ mime: 'image/gif', width: 50, height: 75 }); + }); + + it('returns null for non-image bytes', () => { + expect(parseImageMeta(new Uint8Array([1, 2, 3, 4]))).toBeNull(); + expect(parseImageMeta(new Uint8Array([]))).toBeNull(); + }); + + it('returns null for truncated PNG', () => { + const full = png(10, 10); + expect(parseImageMeta(full.slice(0, 20))).toBeNull(); + }); +}); diff --git a/apps/kimi-code/test/utils/paths.test.ts b/apps/kimi-code/test/utils/paths.test.ts new file mode 100644 index 000000000..5c9d3dc03 --- /dev/null +++ b/apps/kimi-code/test/utils/paths.test.ts @@ -0,0 +1,73 @@ +import { createHash } from 'node:crypto'; +import { homedir } from 'node:os'; +import { join } from 'node:path'; + +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { getDataDir, getInputHistoryFile, getLogDir, getUpdateStateFile } from '#/utils/paths'; + +const originalEnv = { ...process.env }; + +beforeEach(() => { + delete process.env['KIMI_CODE_HOME']; +}); + +afterEach(() => { + process.env = { ...originalEnv }; +}); + +describe('getDataDir', () => { + it('returns ~/.kimi-code when KIMI_CODE_HOME is not set', () => { + expect(getDataDir()).toBe(join(homedir(), '.kimi-code')); + }); + + it('returns KIMI_CODE_HOME when set', () => { + process.env['KIMI_CODE_HOME'] = '/tmp/kimi-test-data'; + expect(getDataDir()).toBe('/tmp/kimi-test-data'); + }); + + it('returns KIMI_CODE_HOME even if it is a relative path', () => { + process.env['KIMI_CODE_HOME'] = 'relative/path'; + expect(getDataDir()).toBe('relative/path'); + }); +}); + +describe('getLogDir', () => { + it('returns <dataDir>/logs', () => { + expect(getLogDir()).toBe(join(homedir(), '.kimi-code', 'logs')); + }); + + it('respects KIMI_CODE_HOME', () => { + process.env['KIMI_CODE_HOME'] = '/z'; + expect(getLogDir()).toBe(join('/z', 'logs')); + }); +}); + +describe('getUpdateStateFile', () => { + it('returns <dataDir>/updates/latest.json', () => { + expect(getUpdateStateFile()).toBe(join(homedir(), '.kimi-code', 'updates', 'latest.json')); + }); + + it('respects KIMI_CODE_HOME', () => { + process.env['KIMI_CODE_HOME'] = '/updates-home'; + expect(getUpdateStateFile()).toBe(join('/updates-home', 'updates', 'latest.json')); + }); +}); + +describe('getInputHistoryFile', () => { + it('returns <dataDir>/user-history/<md5(workDir)>.jsonl', () => { + const workDir = '/home/user/project'; + const hash = createHash('md5').update(workDir, 'utf-8').digest('hex'); + expect(getInputHistoryFile(workDir)).toBe( + join(homedir(), '.kimi-code', 'user-history', `${hash}.jsonl`), + ); + }); + + it('respects KIMI_CODE_HOME', () => { + process.env['KIMI_CODE_HOME'] = '/custom/data'; + const hash = createHash('md5').update('/proj', 'utf-8').digest('hex'); + expect(getInputHistoryFile('/proj')).toBe( + join('/custom/data', 'user-history', `${hash}.jsonl`), + ); + }); +}); diff --git a/apps/kimi-code/test/utils/persistence.test.ts b/apps/kimi-code/test/utils/persistence.test.ts new file mode 100644 index 000000000..9ebe3f55a --- /dev/null +++ b/apps/kimi-code/test/utils/persistence.test.ts @@ -0,0 +1,116 @@ +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { z } from 'zod'; + +import { appendJsonlLine, readJsonFile, readJsonlFile, writeJsonFile } from '#/utils/persistence'; + +interface TestJson { + name: string; + count: number; +} + +const TestJsonSchema: z.ZodType<TestJson> = z.object({ + name: z.string(), + count: z.number().int(), +}); + +interface TestLine { + content: string; +} + +const TestLineSchema: z.ZodType<TestLine> = z.object({ + content: z.string(), +}); + +let dir: string; + +beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'kimi-persistence-')); +}); + +afterEach(() => { + rmSync(dir, { recursive: true, force: true }); +}); + +describe('persistence helpers', () => { + it('readJsonFile returns fallback when file is missing', async () => { + const fallback = { name: 'fallback', count: 1 }; + await expect( + readJsonFile(join(dir, 'missing.json'), TestJsonSchema, fallback), + ).resolves.toEqual(fallback); + }); + + it('writeJsonFile writes schema-valid JSON atomically', async () => { + const file = join(dir, 'nested', 'state.json'); + await writeJsonFile(file, TestJsonSchema, { name: 'ok', count: 2 }); + + expect(JSON.parse(readFileSync(file, 'utf-8'))).toEqual({ name: 'ok', count: 2 }); + await expect( + readJsonFile(file, TestJsonSchema, { name: 'fallback', count: 0 }), + ).resolves.toEqual({ name: 'ok', count: 2 }); + }); + + it('readJsonFile rejects schema-invalid JSON', async () => { + const file = join(dir, 'bad.json'); + writeFileSync(file, JSON.stringify({ name: 'bad', count: 'nope' }), 'utf-8'); + + await expect( + readJsonFile(file, TestJsonSchema, { name: 'fallback', count: 0 }), + ).rejects.toThrow(); + }); + + it('writeJsonFile refuses to write config.toml', async () => { + await expect( + writeJsonFile(join(dir, 'config.toml'), TestJsonSchema, { name: 'bad', count: 1 }), + ).rejects.toThrow(/config\.toml/); + }); + + it('readJsonlFile preserves valid line order', async () => { + const file = join(dir, 'history.jsonl'); + writeFileSync( + file, + [ + JSON.stringify({ content: 'first' }), + JSON.stringify({ content: 'second' }), + JSON.stringify({ content: 'third' }), + ].join('\n'), + 'utf-8', + ); + + await expect(readJsonlFile(file, TestLineSchema)).resolves.toEqual([ + { content: 'first' }, + { content: 'second' }, + { content: 'third' }, + ]); + }); + + it('readJsonlFile skips malformed and schema-invalid lines', async () => { + const file = join(dir, 'history.jsonl'); + writeFileSync( + file, + [ + JSON.stringify({ content: 'good' }), + 'not json', + JSON.stringify({ wrong: 'shape' }), + '', + JSON.stringify({ content: 'tail' }), + ].join('\n'), + 'utf-8', + ); + + await expect(readJsonlFile(file, TestLineSchema)).resolves.toEqual([ + { content: 'good' }, + { content: 'tail' }, + ]); + }); + + it('appendJsonlLine creates the parent directory', async () => { + const file = join(dir, 'nested', 'history.jsonl'); + await appendJsonlLine(file, TestLineSchema, { content: 'hello' }); + + expect(readFileSync(file, 'utf-8').trim()).toBe(JSON.stringify({ content: 'hello' })); + }); +}); diff --git a/apps/kimi-code/test/utils/process/external-editor.test.ts b/apps/kimi-code/test/utils/process/external-editor.test.ts new file mode 100644 index 000000000..3e135d019 --- /dev/null +++ b/apps/kimi-code/test/utils/process/external-editor.test.ts @@ -0,0 +1,79 @@ +/* eslint-disable import/first -- vi.mock setup must run before the imports it stubs out. */ +import { EventEmitter } from 'node:events'; +import { writeFile } from 'node:fs/promises'; +import type * as FsPromises from 'node:fs/promises'; + +import { afterEach, describe, expect, it, vi } from 'vitest'; + +const mocks = vi.hoisted(() => ({ + spawn: vi.fn(), + rmCalls: vi.fn(), +})); + +vi.mock('node:child_process', () => ({ + spawn: mocks.spawn, +})); + +vi.mock('node:fs/promises', async () => { + const actual = await vi.importActual<typeof FsPromises>('node:fs/promises'); + return { + ...actual, + rm: (...args: Parameters<typeof actual.rm>) => { + mocks.rmCalls(...args); + return actual.rm(...args); + }, + }; +}); + +import { editInExternalEditor, resolveEditorCommand } from '#/utils/process/external-editor'; + +function shellPath(cmd: string): string { + const match = cmd.match(/'([^']+)'$/); + if (!match) throw new Error(`Could not parse temp path from: ${cmd}`); + return match[1]!; +} + +afterEach(() => { + vi.unstubAllEnvs(); + vi.clearAllMocks(); +}); + +describe('external-editor helpers', () => { + it('prefers configured editor, then VISUAL, then EDITOR', () => { + vi.stubEnv('VISUAL', 'nvim'); + vi.stubEnv('EDITOR', 'vim'); + + expect(resolveEditorCommand('code --wait')).toBe('code --wait'); + expect(resolveEditorCommand(null)).toBe('nvim'); + vi.stubEnv('VISUAL', ''); + expect(resolveEditorCommand()).toBe('vim'); + }); + + it('returns the edited contents on success and cleans up the temp directory', async () => { + mocks.spawn.mockImplementation((_cmd: string, args: string[]) => { + const child = new EventEmitter(); + void writeFile(shellPath(args[1]!), 'edited text', 'utf8').then(() => { + child.emit('exit', 0); + }); + return child as never; + }); + + await expect(editInExternalEditor('seed', 'code --wait')).resolves.toBe('edited text'); + expect(mocks.spawn).toHaveBeenCalledWith( + '/bin/sh', + ['-c', expect.stringMatching(/^code --wait /)], + { stdio: 'inherit' }, + ); + expect(mocks.rmCalls).toHaveBeenCalled(); + }); + + it('returns undefined when the editor exits non-zero', async () => { + mocks.spawn.mockImplementation(() => { + const child = new EventEmitter(); + queueMicrotask(() => child.emit('exit', 1)); + return child as never; + }); + + await expect(editInExternalEditor('seed', 'false')).resolves.toBeUndefined(); + }); +}); diff --git a/apps/kimi-code/test/utils/process/stdin.test.ts b/apps/kimi-code/test/utils/process/stdin.test.ts new file mode 100644 index 000000000..072c48eb0 --- /dev/null +++ b/apps/kimi-code/test/utils/process/stdin.test.ts @@ -0,0 +1,59 @@ +/* eslint-disable import/first -- vi.mock setup must run before the imports it stubs out. */ +import { describe, expect, it, vi, afterEach } from 'vitest'; + +const mocks = vi.hoisted(() => ({ + createInterface: vi.fn(), +})); + +vi.mock('node:readline', () => ({ + createInterface: mocks.createInterface, +})); + +import { createStdinLineReader, readStdinText } from '#/utils/process/stdin'; + +afterEach(() => { + vi.clearAllMocks(); +}); + +describe('stdin helpers', () => { + it('reads stdin text until end and trims the result', async () => { + const listeners = new Map<string, (...args: unknown[]) => void>(); + const onSpy = vi.spyOn(process.stdin, 'on').mockImplementation((( + event: string, + handler: (...args: unknown[]) => void, + ) => { + listeners.set(event, handler); + return process.stdin; + }) as never); + const resumeSpy = vi.spyOn(process.stdin, 'resume').mockImplementation(() => process.stdin); + + const pending = readStdinText(); + listeners.get('data')?.(Buffer.from(' hello world \n')); + listeners.get('end')?.(); + + await expect(pending).resolves.toBe('hello world'); + + onSpy.mockRestore(); + resumeSpy.mockRestore(); + }); + + it('yields lines from readline', async () => { + mocks.createInterface.mockReturnValue( + (async function* () { + yield 'one'; + yield 'two'; + })(), + ); + + const lines: string[] = []; + for await (const line of createStdinLineReader()) { + lines.push(line); + } + + expect(lines).toEqual(['one', 'two']); + expect(mocks.createInterface).toHaveBeenCalledWith({ + input: process.stdin, + crlfDelay: Infinity, + }); + }); +}); diff --git a/apps/kimi-code/test/utils/usage/usage-format.test.ts b/apps/kimi-code/test/utils/usage/usage-format.test.ts new file mode 100644 index 000000000..264bb5c55 --- /dev/null +++ b/apps/kimi-code/test/utils/usage/usage-format.test.ts @@ -0,0 +1,77 @@ +import { describe, it, expect } from 'vitest'; + +import { + formatTokenCount, + renderProgressBar, + ratioSeverity, + safeUsageRatio, +} from '#/utils/usage/usage-format'; + +describe('formatTokenCount', () => { + it('passes small values through unchanged', () => { + expect(formatTokenCount(0)).toBe('0'); + expect(formatTokenCount(1)).toBe('1'); + expect(formatTokenCount(999)).toBe('999'); + }); + + it('rounds integers over 1k to 1 decimal', () => { + expect(formatTokenCount(1_000)).toBe('1.0k'); + expect(formatTokenCount(1_234)).toBe('1.2k'); + expect(formatTokenCount(9_876)).toBe('9.9k'); + }); + + it('switches to M above a million', () => { + expect(formatTokenCount(1_000_000)).toBe('1.0M'); + expect(formatTokenCount(2_500_000)).toBe('2.5M'); + }); + + it('clamps negatives and NaN to 0', () => { + expect(formatTokenCount(-1)).toBe('0'); + expect(formatTokenCount(Number.NaN)).toBe('0'); + expect(formatTokenCount(Number.POSITIVE_INFINITY)).toBe('0'); + }); +}); + +describe('renderProgressBar', () => { + it('empty bar at ratio 0', () => { + expect(renderProgressBar(0, 10)).toBe('░'.repeat(10)); + }); + it('full bar at ratio 1', () => { + expect(renderProgressBar(1, 10)).toBe('█'.repeat(10)); + }); + it('half bar at ratio 0.5', () => { + expect(renderProgressBar(0.5, 10)).toBe('█'.repeat(5) + '░'.repeat(5)); + }); + it('clamps ratios outside [0,1]', () => { + expect(renderProgressBar(-1, 8)).toBe('░'.repeat(8)); + expect(renderProgressBar(2, 8)).toBe('█'.repeat(8)); + }); + it('coerces NaN to 0', () => { + expect(renderProgressBar(Number.NaN, 6)).toBe('░'.repeat(6)); + }); +}); + +describe('safeUsageRatio', () => { + it('matches footer context usage clamping semantics', () => { + expect(safeUsageRatio(Number.NaN)).toBe(0); + expect(safeUsageRatio(-1)).toBe(0); + expect(safeUsageRatio(0.427)).toBe(0.427); + expect(safeUsageRatio(1.5)).toBe(1); + }); +}); + +describe('ratioSeverity', () => { + it('green below 0.5', () => { + expect(ratioSeverity(0)).toBe('ok'); + expect(ratioSeverity(0.49)).toBe('ok'); + }); + it('yellow in [0.5, 0.85)', () => { + expect(ratioSeverity(0.5)).toBe('warn'); + expect(ratioSeverity(0.7)).toBe('warn'); + expect(ratioSeverity(0.849)).toBe('warn'); + }); + it('red at or above 0.85', () => { + expect(ratioSeverity(0.85)).toBe('danger'); + expect(ratioSeverity(1)).toBe('danger'); + }); +}); diff --git a/apps/kimi-code/tsconfig.json b/apps/kimi-code/tsconfig.json new file mode 100644 index 000000000..ea6828176 --- /dev/null +++ b/apps/kimi-code/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "allowJs": true, + "paths": { + "@/*": ["./src/*"] + } + }, + "include": ["src", "test", "../../packages/agent-core/src/prompt-modules.d.ts"] +} diff --git a/apps/kimi-code/tsdown.config.ts b/apps/kimi-code/tsdown.config.ts new file mode 100644 index 000000000..93b0659d0 --- /dev/null +++ b/apps/kimi-code/tsdown.config.ts @@ -0,0 +1,31 @@ +import { resolve } from 'node:path'; + +import { defineConfig } from 'tsdown'; + +import { rawTextPlugin } from '../../build/raw-text-plugin.mjs'; + +const appRoot = import.meta.dirname; + +export default defineConfig({ + entry: ['./src/main.ts'], + format: ['esm'], + outDir: 'dist', + clean: true, + banner: { + js: [ + '#!/usr/bin/env node', + "import { fileURLToPath as __cjsShimFileURLToPath } from 'node:url';", + "import { dirname as __cjsShimDirname } from 'node:path';", + 'const __filename = __cjsShimFileURLToPath(import.meta.url);', + 'const __dirname = __cjsShimDirname(__filename);', + ].join('\n'), + }, + plugins: [rawTextPlugin()], + alias: { + '@': resolve(appRoot, 'src'), + }, + deps: { + alwaysBundle: [/^@moonshot-ai\//], + neverBundle: [], + }, +}); diff --git a/apps/kimi-code/tsdown.native.config.ts b/apps/kimi-code/tsdown.native.config.ts new file mode 100644 index 000000000..eec94d3ba --- /dev/null +++ b/apps/kimi-code/tsdown.native.config.ts @@ -0,0 +1,64 @@ +import { readFileSync } from 'node:fs'; +import { builtinModules } from 'node:module'; +import { resolve } from 'node:path'; + +import { defineConfig } from 'tsdown'; + +import { rawTextPlugin } from '../../build/raw-text-plugin.mjs'; + +const appRoot = import.meta.dirname; +const packageJson = JSON.parse( + readFileSync(new URL('./package.json', import.meta.url), 'utf-8'), +) as { version: string }; + +const builtins = new Set([ + ...builtinModules, + ...builtinModules.map((name) => `node:${name}`), +]); +const optionalNativeDependencies = new Set(['cpu-features']); + +function shouldAlwaysBundle(id: string): boolean { + if (builtins.has(id) || id.startsWith('node:')) return false; + if (optionalNativeDependencies.has(id)) return false; + return true; +} + +function buildTarget(): string { + return process.env['KIMI_CODE_BUILD_TARGET'] ?? `${process.platform}-${process.arch}`; +} + +export default defineConfig({ + entry: ['./src/main.ts'], + format: ['cjs'], + outDir: 'dist-native/intermediates', + clean: true, + dts: false, + fixedExtension: true, + hash: false, + platform: 'node', + target: 'node24', + banner: { js: '#!/usr/bin/env node' }, + plugins: [rawTextPlugin()], + alias: { + '@': resolve(appRoot, 'src'), + }, + define: { + __KIMI_CODE_VERSION__: JSON.stringify(packageJson.version), + __KIMI_CODE_CHANNEL__: JSON.stringify(process.env['KIMI_CODE_CHANNEL'] ?? ''), + __KIMI_CODE_COMMIT__: JSON.stringify(process.env['KIMI_CODE_COMMIT'] ?? ''), + __KIMI_CODE_BUILD_TARGET__: JSON.stringify(buildTarget()), + __KIMI_CODE_NATIVE_BUNDLE__: 'true', + }, + deps: { + alwaysBundle: shouldAlwaysBundle, + neverBundle: [...optionalNativeDependencies], + onlyBundle: false, + }, + outputOptions: { + codeSplitting: false, + entryFileNames: 'main.cjs', + }, + checks: { + legacyCjs: false, + }, +}); diff --git a/apps/kimi-code/vitest.config.ts b/apps/kimi-code/vitest.config.ts new file mode 100644 index 000000000..49cedd3dd --- /dev/null +++ b/apps/kimi-code/vitest.config.ts @@ -0,0 +1,20 @@ +import { resolve } from 'node:path'; + +import { defineConfig } from 'vitest/config'; + +import { rawTextPlugin } from '../../build/raw-text-plugin.mjs'; + +const appRoot = import.meta.dirname; + +export default defineConfig({ + plugins: [rawTextPlugin()], + resolve: { + alias: { + '@': resolve(appRoot, 'src'), + }, + }, + test: { + name: 'cli', + include: ['test/**/*.test.ts', 'test/**/*.test.tsx'], + }, +}); diff --git a/apps/vis/package.json b/apps/vis/package.json new file mode 100644 index 000000000..837569fcb --- /dev/null +++ b/apps/vis/package.json @@ -0,0 +1,19 @@ +{ + "name": "@moonshot-ai/vis", + "version": "0.1.1", + "private": true, + "description": "Debug visualization tool for kimi-code sessions", + "license": "MIT", + "type": "module", + "scripts": { + "build:deps": "pnpm --filter @moonshot-ai/agent-core... build", + "predev": "pnpm run build:deps", + "dev": "concurrently -k -n server,web -c cyan,magenta \"pnpm --filter @moonshot-ai/vis-server dev\" \"pnpm --filter @moonshot-ai/vis-web dev\"", + "build": "pnpm run build:deps && pnpm --filter @moonshot-ai/vis-server build && pnpm --filter @moonshot-ai/vis-web build && node scripts/copy-web-dist.mjs", + "prestart": "pnpm run build", + "start": "node server/dist/server.mjs" + }, + "devDependencies": { + "concurrently": "^9.1.2" + } +} diff --git a/apps/vis/scripts/copy-web-dist.mjs b/apps/vis/scripts/copy-web-dist.mjs new file mode 100644 index 000000000..18978247f --- /dev/null +++ b/apps/vis/scripts/copy-web-dist.mjs @@ -0,0 +1,26 @@ +#!/usr/bin/env node +// Copy web/dist/** into server/dist/public/ so the bundled server can serve +// the SPA out of a single deploy artifact. + +import { cp, mkdir, rm, stat } from 'node:fs/promises'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const here = dirname(fileURLToPath(import.meta.url)); +const root = join(here, '..'); +const src = join(root, 'web', 'dist'); +const dst = join(root, 'server', 'dist', 'public'); + +try { + await stat(src); +} catch { + process.stderr.write(`[copy-web-dist] source missing: ${src}\n`); + process.stderr.write('Did you run `pnpm --filter @moonshot-ai/vis-web build` first?\n'); + process.exit(1); +} + +await rm(dst, { recursive: true, force: true }); +await mkdir(dirname(dst), { recursive: true }); +await cp(src, dst, { recursive: true }); + +process.stdout.write(`[copy-web-dist] copied ${src} -> ${dst}\n`); diff --git a/apps/vis/server/package.json b/apps/vis/server/package.json new file mode 100644 index 000000000..65232652c --- /dev/null +++ b/apps/vis/server/package.json @@ -0,0 +1,27 @@ +{ + "name": "@moonshot-ai/vis-server", + "version": "0.1.1", + "private": true, + "license": "MIT", + "type": "module", + "imports": { + "#/*": [ + "./src/*.ts", + "./src/*/index.ts" + ] + }, + "scripts": { + "dev": "tsx watch src/index.ts", + "build": "tsdown", + "test": "vitest run" + }, + "dependencies": { + "@hono/node-server": "^1.13.7", + "@moonshot-ai/agent-core": "workspace:^", + "hono": "^4.7.7" + }, + "devDependencies": { + "tsx": "^4.21.0", + "vitest": "4.1.4" + } +} diff --git a/apps/vis/server/src/app.ts b/apps/vis/server/src/app.ts new file mode 100644 index 000000000..41b9abbc5 --- /dev/null +++ b/apps/vis/server/src/app.ts @@ -0,0 +1,138 @@ +import { timingSafeEqual } from 'node:crypto'; +import { readFile, stat } from 'node:fs/promises'; +import { join, resolve } from 'node:path'; + +import { Hono } from 'hono'; + +import { contextRoute } from './routes/context'; +import { sessionDetailRoute } from './routes/session-detail'; +import { sessionsRoute } from './routes/sessions'; +import { subagentsRoute } from './routes/subagents'; +import { toolResultsRoute } from './routes/tool-results'; +import { wireRoute } from './routes/wire'; + +/** Resolve the SPA bundle directory next to the compiled server.mjs, if it + * exists. Returns `null` in dev mode where the web bundle lives elsewhere. */ +async function resolvePublicDir(): Promise<string | null> { + try { + const here = import.meta.dirname; + const candidate = resolve(here, 'public'); + const s = await stat(candidate); + if (s.isDirectory()) return candidate; + } catch { + // not present + } + return null; +} + +const STATIC_EXT_MIME: Record<string, string> = { + '.html': 'text/html; charset=utf-8', + '.js': 'application/javascript; charset=utf-8', + '.mjs': 'application/javascript; charset=utf-8', + '.css': 'text/css; charset=utf-8', + '.json': 'application/json; charset=utf-8', + '.svg': 'image/svg+xml', + '.png': 'image/png', + '.jpg': 'image/jpeg', + '.jpeg': 'image/jpeg', + '.gif': 'image/gif', + '.ico': 'image/x-icon', + '.woff': 'font/woff', + '.woff2': 'font/woff2', + '.ttf': 'font/ttf', + '.map': 'application/json; charset=utf-8', +}; + +function mimeFor(path: string): string { + const i = path.lastIndexOf('.'); + if (i < 0) return 'application/octet-stream'; + const ext = path.slice(i).toLowerCase(); + return STATIC_EXT_MIME[ext] ?? 'application/octet-stream'; +} + +export interface CreateAppOptions { + readonly authToken?: string; +} + +function bearerToken(value: string | undefined): string | null { + if (value === undefined) return null; + const match = /^Bearer\s+(.+)$/i.exec(value); + return match?.[1]?.trim() ?? null; +} + +function tokenMatches(actual: string, expected: string): boolean { + const actualBytes = Buffer.from(actual); + const expectedBytes = Buffer.from(expected); + return actualBytes.length === expectedBytes.length && timingSafeEqual(actualBytes, expectedBytes); +} + +/** Build a Hono app mounting /api/* routes, plus SPA static fallback. */ +export async function createApp(options: CreateAppOptions = {}): Promise<Hono> { + const app = new Hono(); + + // /api/* handlers. + const api = new Hono(); + const authToken = options.authToken; + if (authToken !== undefined && authToken.length > 0) { + api.use('*', async (c, next) => { + const token = bearerToken(c.req.header('authorization')); + if (token !== null && tokenMatches(token, authToken)) { + await next(); + return; + } + c.header('www-authenticate', 'Bearer realm="kimi-vis"'); + return c.json({ error: 'unauthorized', code: 'UNAUTHORIZED' }, 401); + }); + } + api.route('/sessions', sessionsRoute()); + api.route('/sessions', sessionDetailRoute()); + api.route('/sessions', wireRoute()); + api.route('/sessions', contextRoute()); + api.route('/sessions', subagentsRoute()); + api.route('/sessions', toolResultsRoute()); + + app.route('/api', api); + + // Static + SPA fallback (production only). + const publicDir = await resolvePublicDir(); + if (publicDir !== null) { + app.get('*', async (c) => { + const url = new URL(c.req.url); + let pathname = decodeURIComponent(url.pathname); + if (pathname.startsWith('/api')) { + // Should have been routed above; 404 here. + return c.json({ error: `api route not found: ${pathname}`, code: 'NOT_FOUND' }, 404); + } + if (pathname === '/' || pathname === '') pathname = '/index.html'; + const resolved = resolve(publicDir, `.${pathname}`); + if (!resolved.startsWith(publicDir)) { + return c.text('forbidden', 403); + } + try { + const s = await stat(resolved); + if (s.isFile()) { + const buf = await readFile(resolved); + const body = new Uint8Array(buf.buffer, buf.byteOffset, buf.byteLength); + return new Response(body, { + headers: { 'content-type': mimeFor(resolved) }, + }); + } + } catch { + // fall through to SPA fallback + } + // SPA fallback — index.html for any unknown GET so client-side + // React Router can resolve the route. + try { + const indexHtml = await readFile(join(publicDir, 'index.html')); + const body = new Uint8Array(indexHtml.buffer, indexHtml.byteOffset, indexHtml.byteLength); + return new Response(body, { + headers: { 'content-type': 'text/html; charset=utf-8' }, + }); + } catch { + return c.text('not found', 404); + } + }); + } + + return app; +} diff --git a/apps/vis/server/src/config.ts b/apps/vis/server/src/config.ts new file mode 100644 index 000000000..7c9421c14 --- /dev/null +++ b/apps/vis/server/src/config.ts @@ -0,0 +1,156 @@ +import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs'; +import { homedir } from 'node:os'; +import { join } from 'node:path'; + +/** Resolve KIMI_CODE_HOME (env > ~/.kimi-code). */ +function resolveKimiCodeHome(): string { + const envHome = process.env['KIMI_CODE_HOME']; + if (envHome !== undefined && envHome.length > 0) { + return envHome; + } + return join(homedir(), '.kimi-code'); +} + +/** HTTP port for the vis API server. */ +export function resolvePort(): number { + const raw = process.env['PORT']; + if (raw !== undefined && raw.length > 0) { + const n = Number.parseInt(raw, 10); + if (Number.isFinite(n) && n > 0 && n < 65536) { + return n; + } + } + return 3001; +} + +/** HTTP host for the vis API server. Defaults to loopback. */ +export function resolveHost(): string { + const raw = process.env['VIS_HOST'] ?? process.env['HOST']; + const host = raw?.trim(); + return host !== undefined && host.length > 0 ? host : '127.0.0.1'; +} + +export function isLoopbackHost(host: string): boolean { + const normalized = host.trim().toLowerCase().replaceAll('[', '').replaceAll(']', ''); + return ( + normalized === 'localhost' || + normalized === '::1' || + normalized === '0:0:0:0:0:0:0:1' || + normalized.startsWith('127.') + ); +} + +export function resolveVisAuthToken(host: string = resolveHost()): string | undefined { + const raw = process.env['VIS_AUTH_TOKEN']; + const token = raw?.trim(); + if (token !== undefined && token.length > 0) return token; + if (!isLoopbackHost(host)) { + throw new Error( + `VIS_AUTH_TOKEN is required when binding vis-server outside loopback (host=${host})`, + ); + } + return undefined; +} + +export const KIMI_CODE_HOME: string = resolveKimiCodeHome(); + +export class VisPathConfig { + readonly sessionsDir: string; + + constructor(readonly home: string) { + this.sessionsDir = join(home, 'sessions'); + } + + sessionDir(sessionId: string): string { + return ( + this.findIndexedSessionDir(sessionId) ?? + this.findNestedSessionDir(sessionId) ?? + join(this.sessionsDir, sessionId) + ); + } + + statePath(sessionId: string): string { + return join(this.sessionDir(sessionId), 'state.json'); + } + + mainAgentDir(sessionId: string): string { + const sessionDir = this.sessionDir(sessionId); + const mainDir = join(sessionDir, 'agents', 'main'); + return isDirectory(mainDir) ? mainDir : sessionDir; + } + + wirePath(sessionId: string): string { + return join(this.mainAgentDir(sessionId), 'wire.jsonl'); + } + + subagentDir(sessionId: string, agentId: string): string { + const sessionDir = this.sessionDir(sessionId); + const agentDir = join(sessionDir, 'agents', agentId); + return isDirectory(agentDir) ? agentDir : join(sessionDir, 'subagents', agentId); + } + + toolResultArchivePath(sessionId: string, toolCallId: string): string { + const sessionDir = this.sessionDir(sessionId); + const mainPath = join(sessionDir, 'agents', 'main', 'tool-results', `${toolCallId}.txt`); + return existsSync(mainPath) ? mainPath : join(sessionDir, 'tool-results', `${toolCallId}.txt`); + } + + private findIndexedSessionDir(sessionId: string): string | null { + const indexPath = join(this.home, 'session_index.jsonl'); + let raw: string; + try { + raw = readFileSync(indexPath, 'utf8'); + } catch { + return null; + } + for (const line of raw.split(/\r?\n/)) { + if (line.trim().length === 0) continue; + try { + const parsed = JSON.parse(line) as { + sessionId?: unknown; + session_id?: unknown; + sessionDir?: unknown; + session_dir?: unknown; + }; + const id = stringValue(parsed.sessionId) ?? stringValue(parsed.session_id); + if (id !== sessionId) continue; + const dir = stringValue(parsed.sessionDir) ?? stringValue(parsed.session_dir); + if (dir !== null && isDirectory(dir)) return dir; + } catch { + // Ignore malformed index lines. + } + } + return null; + } + + private findNestedSessionDir(sessionId: string): string | null { + const direct = join(this.sessionsDir, sessionId); + if (isDirectory(direct)) return direct; + let entries: string[]; + try { + entries = readdirSync(this.sessionsDir); + } catch { + return null; + } + for (const entry of entries) { + const candidate = join(this.sessionsDir, entry, sessionId); + if (isDirectory(candidate)) return candidate; + } + return null; + } +} + +function stringValue(value: unknown): string | null { + return typeof value === 'string' && value.length > 0 ? value : null; +} + +function isDirectory(path: string): boolean { + try { + return statSync(path).isDirectory(); + } catch { + return false; + } +} + +/** Singleton path config pinned to the resolved KIMI_CODE_HOME. */ +export const pathConfig = new VisPathConfig(KIMI_CODE_HOME); diff --git a/apps/vis/server/src/index.ts b/apps/vis/server/src/index.ts new file mode 100644 index 000000000..e97d6603a --- /dev/null +++ b/apps/vis/server/src/index.ts @@ -0,0 +1,32 @@ +import { serve } from '@hono/node-server'; + +import { createApp } from './app'; +import { KIMI_CODE_HOME, resolveHost, resolvePort, resolveVisAuthToken } from './config'; +import { formatStartupBanner } from './startup-banner'; + +async function main(): Promise<void> { + const host = resolveHost(); + const authToken = resolveVisAuthToken(host); + const app = await createApp({ authToken }); + const port = resolvePort(); + serve({ fetch: app.fetch, hostname: host, port }, (info) => { + // Startup banner. + process.stdout.write( + formatStartupBanner({ + authToken, + host, + kimiCodeHome: KIMI_CODE_HOME, + port: info.port, + }), + ); + }); +} + +try { + await main(); +} catch (error: unknown) { + process.stderr.write( + `[vis-server] fatal: ${error instanceof Error ? (error.stack ?? error.message) : String(error)}\n`, + ); + process.exit(1); +} diff --git a/apps/vis/server/src/lib/context-builder.ts b/apps/vis/server/src/lib/context-builder.ts new file mode 100644 index 000000000..b31df2023 --- /dev/null +++ b/apps/vis/server/src/lib/context-builder.ts @@ -0,0 +1,500 @@ +import type { + AnnotatedMessage, + ContentPart, + NotificationRecord, + ProjectedStateSummary, + SessionInitializedRecord, + SessionState, + ToolCallEntry, + UserInputPart, + VisWireRecord, +} from './types'; + +const PERSISTED_OUTPUT_RE = /^<persisted-output path="([^"]+)">/; + +/** + * Extract the on-disk path from a `<persisted-output path="...">` marker + * at the start of a tool-result output. Returns null when no marker is + * present. + */ +export function extractPersistedOutputPath(text: string): string | null { + const m = PERSISTED_OUTPUT_RE.exec(text.trimStart()); + return m?.[1] ?? null; +} + +/** + * Render a notification's `data` payload as the XML string that the live + * ContextMemory would inject as a user-role message. + */ +export function renderNotificationXml(data: NotificationRecord['data']): string { + const id = escAttr(data.id); + const category = escAttr(data.category); + const type = escAttr(data.type); + const sourceKind = escAttr(data.source_kind); + const sourceId = escAttr(data.source_id); + const title = data.title; + const severity = data.severity; + const body = data.body; + + const lines: string[] = [ + `<notification id="${id}" category="${category}" type="${type}" source_kind="${sourceKind}" source_id="${sourceId}">`, + ]; + if (title.length > 0) lines.push(`Title: ${title}`); + if (severity.length > 0) lines.push(`Severity: ${severity}`); + if (body.length > 0) lines.push(body); + + // background_task notifications append a <task-notification> block + // with the last 20 lines / 3000 chars of tail_output, matching what + // the live projector would emit. + if (data.source_kind === 'background_task') { + const tail = (data as { tail_output?: string }).tail_output ?? ''; + if (tail.length > 0) { + const tailLines = tail.split('\n'); + const trimmed = (tailLines.length > 20 ? tailLines.slice(-20) : tailLines) + .join('\n') + .slice(-3000); + lines.push('<task-notification>', trimmed, '</task-notification>'); + } + } + + lines.push('</notification>'); + return lines.join('\n'); +} + +function escAttr(s: string): string { + if (s.length === 0) return 'unknown'; + return s.replaceAll('&', '&').replaceAll('"', '"'); +} + +export interface BuildAnnotatedOptions { + /** When true (default), rewind-orphaned records are kept with `out_of_context: true`. */ + preserveOutOfContext?: boolean; +} + +/** + * Walk `records` in order and emit an `AnnotatedMessage[]` that includes + * notification and system_reminder synthetic user messages. Tracks a + * numeric turn counter so `context_edit operation:'rewind' to_turn:N` + * can flag later messages as out-of-context. + */ +export function buildAnnotatedMessages( + records: readonly VisWireRecord[], + options?: BuildAnnotatedOptions, +): AnnotatedMessage[] { + const preserveOutOfContext = options?.preserveOutOfContext ?? true; + + // Track (messageIndex -> turnCounterAtEmission) so a rewind can mark + // later emissions as out_of_context. + let annotated: AnnotatedMessage[] = []; + const msgTurnIdx: number[] = []; + + let turnCounter = 0; + + // An assistant message is not a single record — it's coalesced from + // `step_begin → (content_part|tool_call)* → step_end` atoms anchored + // by `step_uuid`. We buffer the in-flight step here and emit a + // synthetic assistant message on step_end. + interface StepBuffer { + seq: number; + step_uuid: string; + text: string; + think: string; + think_encrypted?: string | undefined; + tool_calls: ToolCallEntry[]; + } + let currentStep: StepBuffer | null = null; + + const flushStep = (): void => { + if (currentStep === null) return; + const s = currentStep; + const content: ContentPart[] = []; + if (s.think.length > 0) { + const part: ContentPart = { type: 'think', think: s.think }; + if (s.think_encrypted !== undefined) part['encrypted'] = s.think_encrypted; + content.push(part); + } + if (s.text.length > 0) content.push({ type: 'text', text: s.text }); + if (content.length > 0 || s.tool_calls.length > 0) { + pushMsg({ + seq: s.seq, + message: { role: 'assistant', content, tool_calls: s.tool_calls }, + origin: { kind: 'assistant' }, + is_ephemeral: false, + out_of_context: false, + }); + } + currentStep = null; + }; + + const pushMsg = (m: AnnotatedMessage): void => { + annotated.push(m); + msgTurnIdx.push(turnCounter); + }; + + for (const r of records) { + switch (r.type) { + case 'turn_begin': { + // Close any dangling step so turn boundaries stay clean. + flushStep(); + turnCounter += 1; + break; + } + + case 'user_message': { + const text = userContentToText(r.content); + pushMsg({ + seq: r.seq, + message: { + role: 'user', + content: [{ type: 'text', text }], + tool_calls: [], + }, + origin: { kind: 'user' }, + is_ephemeral: false, + out_of_context: false, + }); + break; + } + + case 'step_begin': { + // Any prior unclosed step gets flushed; protocol guarantees order + // step_begin → … → step_end, but crashes can leave orphans. + flushStep(); + currentStep = { + seq: r.seq, + step_uuid: r.uuid, + text: '', + think: '', + tool_calls: [], + }; + break; + } + + case 'content_part': { + if (currentStep === null) break; + if (currentStep.step_uuid !== r.step_uuid) break; + if (r.part.kind === 'text') { + currentStep.text += r.part.text; + } else { + currentStep.think += r.part.think; + if (r.part.encrypted !== undefined) { + currentStep.think_encrypted = r.part.encrypted; + } + } + break; + } + + case 'tool_call': { + if (currentStep === null) break; + if (currentStep.step_uuid !== r.step_uuid) break; + currentStep.tool_calls.push({ + type: 'function', + id: r.data.tool_call_id, + function: { + name: r.data.tool_name, + arguments: r.data.args === undefined ? null : JSON.stringify(r.data.args), + }, + }); + break; + } + + case 'step_end': { + // step_end may carry usage; we drop it here since the annotated + // message already represents what the LLM emitted. Usage lives + // on the wire record itself for the Wire tab to show. + flushStep(); + break; + } + + case 'tool_result': { + const text = typeof r.output === 'string' ? r.output : JSON.stringify(r.output); + const msg: AnnotatedMessage = { + seq: r.seq, + message: { + role: 'tool', + content: [{ type: 'text', text }], + tool_calls: [], + tool_call_id: r.tool_call_id, + }, + origin: { kind: 'tool', tool_call_id: r.tool_call_id }, + is_ephemeral: false, + out_of_context: false, + }; + if (typeof r.output === 'string') { + const persistedPath = extractPersistedOutputPath(r.output); + if (persistedPath !== null) { + msg.persisted_output_path = persistedPath; + } + } + pushMsg(msg); + break; + } + + case 'system_reminder': { + const text = `<system-reminder>\n${r.content}\n</system-reminder>`; + pushMsg({ + seq: r.seq, + message: { role: 'user', content: [{ type: 'text', text }], tool_calls: [] }, + origin: { kind: 'system_reminder', seq: r.seq }, + is_ephemeral: true, + out_of_context: false, + }); + break; + } + + case 'notification': { + if (!isLlmVisibleNotification(r)) break; + const text = renderNotificationXml(r.data); + pushMsg({ + seq: r.seq, + message: { role: 'user', content: [{ type: 'text', text }], tool_calls: [] }, + origin: { + kind: 'notification', + seq: r.seq, + notification_id: r.data.id, + severity: r.data.severity, + }, + is_ephemeral: true, + out_of_context: false, + }); + break; + } + + case 'compaction': { + // Wholesale replacement. Drops any open step buffer — atoms + // before compaction are superseded by the summary. + currentStep = null; + annotated = []; + msgTurnIdx.length = 0; + pushMsg({ + seq: r.seq, + message: { + role: 'assistant', + content: [{ type: 'text', text: r.summary }], + tool_calls: [], + }, + origin: { kind: 'assistant' }, + is_ephemeral: false, + out_of_context: false, + }); + break; + } + + case 'context_cleared': { + currentStep = null; + annotated = []; + msgTurnIdx.length = 0; + break; + } + + case 'context_edit': { + if (r.operation === 'rewind' && typeof r.to_turn === 'number') { + const toTurn = r.to_turn; + if (preserveOutOfContext) { + for (let i = 0; i < annotated.length; i += 1) { + const emittedAtTurn = msgTurnIdx[i] ?? 0; + if (emittedAtTurn > toTurn) { + // Replace with a marked copy (immutability not required but + // makes the "after rewind" behaviour obvious to readers). + const m = annotated[i]; + if (m !== undefined) { + annotated[i] = { ...m, out_of_context: true }; + } + } + } + } else { + // Drop messages emitted in turns past `to_turn`. + const keep: AnnotatedMessage[] = []; + const keepTurns: number[] = []; + for (let i = 0; i < annotated.length; i += 1) { + const emittedAtTurn = msgTurnIdx[i] ?? 0; + if (emittedAtTurn <= toTurn) { + const m = annotated[i]; + if (m !== undefined) { + keep.push(m); + keepTurns.push(emittedAtTurn); + } + } + } + annotated = keep; + msgTurnIdx.length = 0; + msgTurnIdx.push(...keepTurns); + } + } + break; + } + + case 'approval_request': + case 'approval_response': + case 'metadata': + case 'ownership_changed': + case 'session_initialized': + case 'skill_completed': + case 'skill_invoked': + case 'subagent_completed': + case 'subagent_failed': + case 'subagent_spawned': + case 'system_prompt_changed': + case 'team_mail': + case 'tool_denied': + case 'tools_changed': + case 'turn_end': + break; + + default: + break; + } + } + + // Flush any trailing open step (session ended mid-step). + flushStep(); + + return annotated; +} + +function userContentToText(content: string | readonly UserInputPart[]): string { + if (typeof content === 'string') return content; + return content + .map((p) => { + if (p.type === 'text') return p.text; + if (p.type === 'image_url') return `<image url="${p.image_url.url}">`; + return `<video url="${p.video_url.url}">`; + }) + .join(''); +} + +function isLlmVisibleNotification(r: NotificationRecord): boolean { + const targets = r.data.targets; + if (!targets.includes('llm')) return false; + // Optimistic: an absent `delivered_at` means a legacy record from before + // delivery tracking was added (delivery actually happened); a value of + // `0` means the LLM sink was intentionally skipped. + const deliveredAt = r.data.delivered_at; + if (deliveredAt === undefined) return true; + return deliveredAt.llm !== 0; +} + +/** + * Build the projected-state summary by walking the wire records locally. + * Only the config-class state is materialized (model, system prompt, + * active tools, permission/plan mode, last seq, token totals). + */ +export function buildProjectedStateSummary( + records: readonly VisWireRecord[], + sessionInitialized: SessionInitializedRecord | null, + state?: SessionState | null, +): ProjectedStateSummary { + const baseline = + sessionInitialized ?? + ({ + type: 'session_initialized', + seq: 0, + time: 0, + agent_type: 'main', + session_id: state?.session_id ?? 'unknown', + system_prompt: '', + active_tools: [], + model: state?.model, + permission_mode: state?.permission_mode, + plan_mode: state?.plan_mode, + workspace_dir: state?.workspace_dir, + } satisfies SessionInitializedRecord); + const breakdown = sumTokenBreakdown(records); + const projected = projectStateLocally(records, baseline, state); + return { + model: projected.model, + system_prompt: projected.system_prompt, + active_tools: projected.active_tools, + last_seq: projected.last_seq, + token_count: breakdown.input_tokens + breakdown.output_tokens, + permission_mode: projected.permission_mode, + plan_mode: projected.plan_mode, + ...breakdown, + }; +} + +function projectStateLocally( + records: readonly VisWireRecord[], + sessionInitialized: SessionInitializedRecord, + state?: SessionState | null, +): Pick< + ProjectedStateSummary, + 'model' | 'system_prompt' | 'active_tools' | 'last_seq' | 'permission_mode' | 'plan_mode' +> { + let systemPrompt: string | null = sessionInitialized.system_prompt; + let activeTools = [...sessionInitialized.active_tools]; + let model: string | null = state?.model ?? sessionInitialized.model ?? null; + let permissionMode: string | null = sessionInitialized.permission_mode ?? null; + let planMode = sessionInitialized.plan_mode ?? false; + let lastSeq = sessionInitialized.seq; + for (const record of records) { + lastSeq = Math.max(lastSeq, record.seq ?? 0); + if (record.type === 'system_prompt_changed') { + systemPrompt = record.new_prompt; + } else if (record.type === 'tools_changed') { + activeTools = [...record.tools]; + } else if (record.type === 'notification') { + const payload = record.data.payload; + if (record.data.type === 'config.update' && isRecord(payload)) { + model = configModel(payload) ?? model; + } else if (record.data.type === 'permission.update' && isRecord(payload)) { + permissionMode = stringValue(payload['mode']) ?? permissionMode; + } + } + } + return { + model, + system_prompt: systemPrompt, + active_tools: activeTools, + last_seq: lastSeq, + permission_mode: state?.permission_mode ?? permissionMode, + plan_mode: state?.plan_mode ?? planMode, + }; +} + +function configModel(payload: Record<string, unknown>): string | null { + const alias = stringValue(payload['modelAlias']); + if (alias !== null) return alias; + const provider = payload['provider']; + if (isRecord(provider)) return stringValue(provider['model']); + return null; +} + +function isRecord(value: unknown): value is Record<string, unknown> { + return typeof value === 'object' && value !== null; +} + +function stringValue(value: unknown): string | null { + return typeof value === 'string' && value.length > 0 ? value : null; +} + +/** Sum `step_end.usage` across all records. Mirrors the core projector's + * tokenCount logic (step-level) so partial/running turns are still counted + * — turn_end.usage is the turn total but it arrives only after the turn + * closes, so step_end sums stay responsive on live sessions. */ +function sumTokenBreakdown(records: readonly VisWireRecord[]): { + input_tokens: number; + output_tokens: number; + cache_read_tokens: number; + cache_write_tokens: number; +} { + let input = 0; + let output = 0; + let cacheRead = 0; + let cacheWrite = 0; + for (const r of records) { + if (r.type !== 'step_end') continue; + const usage = (r as { usage?: Record<string, number | undefined> }).usage; + if (usage === undefined) continue; + input += usage['input_tokens'] ?? 0; + output += usage['output_tokens'] ?? 0; + cacheRead += usage['cache_read_tokens'] ?? 0; + cacheWrite += usage['cache_write_tokens'] ?? 0; + } + return { + input_tokens: input, + output_tokens: output, + cache_read_tokens: cacheRead, + cache_write_tokens: cacheWrite, + }; +} diff --git a/apps/vis/server/src/lib/session-delete.ts b/apps/vis/server/src/lib/session-delete.ts new file mode 100644 index 000000000..c6e6010c6 --- /dev/null +++ b/apps/vis/server/src/lib/session-delete.ts @@ -0,0 +1,50 @@ +import { rm, stat } from 'node:fs/promises'; +import { basename, isAbsolute, relative, resolve } from 'node:path'; + +import { pathConfig } from '../config'; +import { listSessions } from './session-lister'; +import type { ClearSessionsResponse, DeleteSessionResponse } from './types'; + +export const SESSION_ID_RE = /^session_[a-zA-Z0-9_-]+$/; + +function isInside(root: string, target: string): boolean { + const rel = relative(root, target); + return rel === '' || (!rel.startsWith('..') && !isAbsolute(rel)); +} + +function resolveSafeSessionDir(sessionId: string): string { + const sessionsRoot = resolve(pathConfig.sessionsDir); + const sessionDir = resolve(pathConfig.sessionDir(sessionId)); + if (!isInside(sessionsRoot, sessionDir) || basename(sessionDir) !== sessionId) { + throw new Error(`refusing to delete unsafe session path: ${sessionDir}`); + } + return sessionDir; +} + +export async function deleteSession(sessionId: string): Promise<DeleteSessionResponse | null> { + const sessionDir = resolveSafeSessionDir(sessionId); + const info = await stat(sessionDir).catch(() => null); + if (info?.isDirectory() !== true) return null; + await rm(sessionDir, { recursive: true, force: true }); + return { session_id: sessionId, deleted: true }; +} + +export async function clearSessions(): Promise<ClearSessionsResponse> { + const sessions = await listSessions(pathConfig.sessionsDir); + const failed: ClearSessionsResponse['failed'] = []; + let deleted_count = 0; + + for (const session of sessions) { + try { + const result = await deleteSession(session.session_id); + if (result?.deleted === true) deleted_count += 1; + } catch (error) { + failed.push({ + session_id: session.session_id, + error: error instanceof Error ? error.message : String(error), + }); + } + } + + return { deleted_count, failed }; +} diff --git a/apps/vis/server/src/lib/session-lister.ts b/apps/vis/server/src/lib/session-lister.ts new file mode 100644 index 000000000..c07ec2e96 --- /dev/null +++ b/apps/vis/server/src/lib/session-lister.ts @@ -0,0 +1,196 @@ +import { createReadStream } from 'node:fs'; +import { readFile, readdir, stat } from 'node:fs/promises'; +import { join } from 'node:path'; +import { createInterface } from 'node:readline'; + +import type { SessionState, SessionSummary, WireFileMetadata } from './types'; +import { readSessionLastPrompt, readSessionTitle } from './session-title'; + +const ARCHIVE_RE = /^wire\.\d+\.jsonl$/; +const SESSION_ID_RE = /^session_[a-zA-Z0-9_-]+$/; + +/** Count NDJSON body lines (excluding metadata header) without loading the + * full file into memory. Returns `null` if the file is unreadable. */ +async function countWireBodyLines(wirePath: string): Promise<{ + count: number; + metadata: WireFileMetadata | null; +} | null> { + try { + const stream = createReadStream(wirePath, { encoding: 'utf8' }); + const rl = createInterface({ input: stream, crlfDelay: Infinity }); + + let headerConsumed = false; + let count = 0; + let metadata: WireFileMetadata | null = null; + + for await (const line of rl) { + if (line.length === 0) continue; + if (!headerConsumed) { + headerConsumed = true; + try { + const parsed = JSON.parse(line) as unknown; + if ( + typeof parsed === 'object' && + parsed !== null && + (parsed as { type?: unknown }).type === 'metadata' + ) { + metadata = parsed as WireFileMetadata; + } + } catch { + // first line malformed — session is broken, not missing + } + continue; + } + count += 1; + } + return { count, metadata }; + } catch { + return null; + } +} + +async function readStateJson(statePath: string): Promise<SessionState | null> { + try { + const raw = await readFile(statePath, 'utf8'); + return JSON.parse(raw) as SessionState; + } catch { + return null; + } +} + +async function countSubagents(sessionDir: string): Promise<number> { + const agentCount = await countAgentSubagents(sessionDir); + if (agentCount > 0) return agentCount; + try { + const entries = await readdir(join(sessionDir, 'subagents'), { withFileTypes: true }); + return entries.filter((e) => e.isDirectory()).length; + } catch { + return 0; + } +} + +async function countAgentSubagents(sessionDir: string): Promise<number> { + try { + const entries = await readdir(join(sessionDir, 'agents'), { withFileTypes: true }); + return entries.filter((e) => e.isDirectory() && e.name !== 'main').length; + } catch { + return 0; + } +} + +async function resolveMainAgentDir(sessionDir: string): Promise<string> { + const mainDir = join(sessionDir, 'agents', 'main'); + const info = await stat(mainDir).catch(() => null); + return info?.isDirectory() === true ? mainDir : sessionDir; +} + +async function countArchives(sessionDir: string): Promise<number> { + try { + const entries = await readdir(sessionDir); + return entries.filter((e) => ARCHIVE_RE.test(e)).length; + } catch { + return 0; + } +} + +/** + * Build a `SessionSummary` from a single session directory. Malformed + * state.json / missing wire.jsonl yield health markers rather than errors. + */ +export async function loadSessionSummary(sessionDir: string): Promise<SessionSummary> { + const sessionId = sessionDir.split('/').pop() ?? sessionDir; + const statePath = join(sessionDir, 'state.json'); + const mainAgentDir = await resolveMainAgentDir(sessionDir); + const wirePath = join(mainAgentDir, 'wire.jsonl'); + + const state = await readStateJson(statePath); + const wireStat = await stat(wirePath).catch(() => null); + const wireInfo = wireStat === null ? null : await countWireBodyLines(wirePath); + const subagent_count = await countSubagents(sessionDir); + const archive_count = await countArchives(mainAgentDir); + + let health: SessionSummary['health']; + if (wireStat === null) { + health = 'missing_wire'; + } else if (wireInfo === null) { + health = 'broken'; + } else if (state === null) { + // state.json missing / unreadable — still usable, flag as broken. + health = 'broken'; + } else { + health = 'ok'; + } + + return { + session_id: state?.session_id ?? sessionId, + title: readSessionTitle(state), + last_prompt: readSessionLastPrompt(state), + created_at: state?.created_at ?? 0, + updated_at: state?.updated_at ?? 0, + last_turn_time: state?.last_turn_time ?? null, + model: state?.model ?? null, + permission_mode: state?.permission_mode ?? null, + last_exit_code: state?.last_exit_code ?? null, + custom_title: state?.custom_title ?? null, + tags: state?.tags ?? [], + archived: state?.archived ?? false, + workspace_dir: state?.workspace_dir ?? null, + wire_protocol_version: wireInfo?.metadata?.protocol_version ?? null, + wire_record_count: wireInfo?.count ?? 0, + archive_count, + subagent_count, + health, + }; +} + +/** + * Enumerate sessions under `sessionsDir`. Current dev stores sessions under a + * workdir bucket (`sessions/<workdir-key>/<sessionId>`), while older fixtures + * may still be direct `sessions/<sessionId>` directories. + */ +export async function listSessions(sessionsDir: string): Promise<SessionSummary[]> { + const candidates: string[] = []; + let entries: string[]; + try { + entries = await readdir(sessionsDir); + } catch { + return []; + } + for (const entry of entries) { + const sessionDir = join(sessionsDir, entry); + const s = await stat(sessionDir).catch(() => null); + if (s?.isDirectory() !== true) continue; + if (SESSION_ID_RE.test(entry)) { + candidates.push(sessionDir); + continue; + } + let children: string[]; + try { + children = await readdir(sessionDir); + } catch { + continue; + } + for (const child of children) { + if (!SESSION_ID_RE.test(child)) continue; + const childDir = join(sessionDir, child); + const childStat = await stat(childDir).catch(() => null); + if (childStat?.isDirectory() === true) candidates.push(childDir); + } + } + + // Cheap pre-filter on state.json producer before doing any wire I/O. + const keep = await Promise.all( + candidates.map(async (dir) => { + const state = await readStateJson(join(dir, 'state.json')); + if (state === null) return null; + return state.producer?.kind === 'python' ? null : dir; + }), + ); + + const summaries = await Promise.all( + keep.filter((d): d is string => d !== null).map((dir) => loadSessionSummary(dir)), + ); + + summaries.sort((a, b) => b.updated_at - a.updated_at); + return summaries; +} diff --git a/apps/vis/server/src/lib/session-title.ts b/apps/vis/server/src/lib/session-title.ts new file mode 100644 index 000000000..bd06a903b --- /dev/null +++ b/apps/vis/server/src/lib/session-title.ts @@ -0,0 +1,25 @@ +import type { SessionState } from './types'; + +function titleString(value: unknown): string | null { + if (typeof value !== 'string') return null; + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : null; +} + +export function readSessionTitle(state: SessionState | null | undefined): string | null { + if (state === null || state === undefined) return null; + if (typeof state.isCustomTitle === 'boolean') { + const title = titleString(state.title); + if (title !== null) return title; + } + return ( + titleString(state.customTitle) ?? + titleString(state.custom_title) ?? + titleString(state.title) + ); +} + +export function readSessionLastPrompt(state: SessionState | null | undefined): string | null { + if (state === null || state === undefined) return null; + return titleString(state.lastPrompt) ?? titleString(state.last_prompt); +} diff --git a/apps/vis/server/src/lib/subagent-loader.ts b/apps/vis/server/src/lib/subagent-loader.ts new file mode 100644 index 000000000..84b316084 --- /dev/null +++ b/apps/vis/server/src/lib/subagent-loader.ts @@ -0,0 +1,213 @@ +import { readFile, readdir, stat } from 'node:fs/promises'; +import { join } from 'node:path'; + +import type { + SubagentCompletedRecord, + SubagentFailedRecord, + SubagentNode, + SubagentSpawnedRecord, + VisWireRecord, +} from './types'; +import { loadWireRecords } from './wire-loader'; + +interface LifecyclePair { + spawned: SubagentSpawnedRecord; + completed: SubagentCompletedRecord | null; + failed: SubagentFailedRecord | null; +} + +/** Directly list subagent ids for a session (only names, no tree build). */ +export async function listSubagents(sessionDir: string): Promise<string[]> { + try { + const entries = await readdir(join(sessionDir, 'agents')); + const out: string[] = []; + for (const e of entries) { + if (e === 'main') continue; + const s = await stat(join(sessionDir, 'agents', e)).catch(() => null); + if (s?.isDirectory()) out.push(e); + } + if (out.length > 0) return out; + } catch { + // Fall back to legacy subagents/ below. + } + try { + const entries = await readdir(join(sessionDir, 'subagents')); + const out: string[] = []; + for (const e of entries) { + const s = await stat(join(sessionDir, 'subagents', e)).catch(() => null); + if (s?.isDirectory()) out.push(e); + } + return out; + } catch { + return []; + } +} + +function extractLifecycleRecords(records: readonly VisWireRecord[]): Map<string, LifecyclePair> { + const map = new Map<string, LifecyclePair>(); + for (const r of records) { + if (r.type === 'subagent_spawned') { + const existing = map.get(r.data.agent_id); + if (existing === undefined) { + map.set(r.data.agent_id, { spawned: r, completed: null, failed: null }); + } else { + existing.spawned = r; + } + } else if (r.type === 'subagent_completed') { + const existing = map.get(r.data.agent_id); + if (existing === undefined) { + // Rare: completion without spawn in this wire — create skeleton. + const fakeSpawn: SubagentSpawnedRecord = { + type: 'subagent_spawned', + seq: 0, + time: 0, + data: { + agent_id: r.data.agent_id, + parent_tool_call_id: r.data.parent_tool_call_id, + run_in_background: false, + }, + }; + map.set(r.data.agent_id, { spawned: fakeSpawn, completed: r, failed: null }); + } else { + existing.completed = r; + } + } else if (r.type === 'subagent_failed') { + const existing = map.get(r.data.agent_id); + if (existing === undefined) { + const fakeSpawn: SubagentSpawnedRecord = { + type: 'subagent_spawned', + seq: 0, + time: 0, + data: { + agent_id: r.data.agent_id, + parent_tool_call_id: r.data.parent_tool_call_id, + run_in_background: false, + }, + }; + map.set(r.data.agent_id, { spawned: fakeSpawn, completed: null, failed: r }); + } else { + existing.failed = r; + } + } + } + return map; +} + +async function readSubagentMeta( + sessionDir: string, + agentId: string, +): Promise<{ subagent_type: string | null; status: string | null }> { + try { + const raw = await readFile(join(await resolveSubagentDir(sessionDir, agentId), 'meta.json'), 'utf8'); + const parsed = JSON.parse(raw) as { subagent_type?: unknown; status?: unknown }; + return { + subagent_type: typeof parsed.subagent_type === 'string' ? parsed.subagent_type : null, + status: typeof parsed.status === 'string' ? parsed.status : null, + }; + } catch { + return { subagent_type: null, status: null }; + } +} + +async function resolveSubagentDir(sessionDir: string, agentId: string): Promise<string> { + const agentDir = join(sessionDir, 'agents', agentId); + const agentStat = await stat(agentDir).catch(() => null); + return agentStat?.isDirectory() === true ? agentDir : join(sessionDir, 'subagents', agentId); +} + +function deriveStatus(pair: LifecyclePair, metaStatus: string | null): SubagentNode['status'] { + if (pair.failed !== null) return 'failed'; + if (pair.completed !== null) return 'completed'; + if (metaStatus !== null) { + const allowed: ReadonlyArray<SubagentNode['status']> = [ + 'running', + 'completed', + 'failed', + 'killed', + 'lost', + ]; + if ((allowed as string[]).includes(metaStatus)) { + return metaStatus as SubagentNode['status']; + } + } + return 'unknown'; +} + +/** + * Build the subagent tree for a session, recursing into each subagent's + * wire.jsonl to discover nested spawns. Depth-capped at `maxDepth` + * (default 5) to prevent runaway recursion on corrupt fixtures. + */ +export async function buildSubagentTree( + sessionDir: string, + mainWireRecords: readonly VisWireRecord[], + maxDepth = 5, +): Promise<SubagentNode[]> { + const visited = new Set<string>(); + + async function buildLevel( + records: readonly VisWireRecord[], + depth: number, + parentAgentId: string | null, + ): Promise<SubagentNode[]> { + if (depth > maxDepth) return []; + const pairs = extractLifecycleRecords(records); + + // Reserve all agentIds at this level synchronously so parallel recursion + // cannot re-visit a cousin branch's already-claimed agent. + const toProcess: Array<[string, LifecyclePair]> = []; + for (const [agentId, pair] of pairs) { + if (visited.has(agentId)) continue; + visited.add(agentId); + toProcess.push([agentId, pair]); + } + + const nodes = await Promise.all( + toProcess.map(async ([agentId, pair]): Promise<SubagentNode> => { + const meta = await readSubagentMeta(sessionDir, agentId); + let children: SubagentNode[] = []; + const subDir = await resolveSubagentDir(sessionDir, agentId); + const dirStat = await stat(subDir).catch(() => null); + if (dirStat?.isDirectory() && depth < maxDepth) { + try { + const sub = await loadWireRecords(subDir); + children = await buildLevel(sub.records, depth + 1, agentId); + } catch { + // ignore — leave children empty + } + } + + const status = deriveStatus(pair, meta.status); + let success: boolean | null; + if (pair.completed !== null) success = true; + else if (pair.failed !== null) success = false; + else success = null; + + const resolvedParent = + pair.spawned.data.parent_agent_id ?? parentAgentId; + + return { + agent_id: agentId, + agent_name: pair.spawned.data.agent_name ?? null, + subagent_type: meta.subagent_type, + run_in_background: pair.spawned.data.run_in_background, + parent_agent_id: resolvedParent, + depth, + status, + success, + result_summary: pair.completed?.data.result_summary ?? null, + error: pair.failed?.data.error ?? null, + spawn_seq: pair.spawned.seq, + spawn_time: pair.spawned.time, + children, + }; + }), + ); + + // Sort deterministically — earliest spawn first. + nodes.sort((a, b) => a.spawn_seq - b.spawn_seq); + return nodes; + } + + return buildLevel(mainWireRecords, 0, null); +} diff --git a/apps/vis/server/src/lib/types.ts b/apps/vis/server/src/lib/types.ts new file mode 100644 index 000000000..90a0149e2 --- /dev/null +++ b/apps/vis/server/src/lib/types.ts @@ -0,0 +1,725 @@ +// Vis-local mirror of the core WireRecord interfaces. Copied so the +// `VisWireRecord` union stays structurally equal to the core `WireRecord` +// union; the sole cast lives in `lib/wire-loader.ts` and is safe because +// these interfaces are field-for-field compatible. +// +// Plus the vis-specific response types consumed by `server/src/routes/*` +// and the `web/` SPA. + +// ── Supporting types (subset of core helpers) ──────────────────────────── + +export interface TextPart { + type: 'text'; + text: string; +} +export interface ImageURLPart { + type: 'image_url'; + image_url: { url: string }; +} +export interface VideoURLPart { + type: 'video_url'; + video_url: { url: string }; +} +export type UserInputPart = TextPart | ImageURLPart | VideoURLPart; + +export interface TokenUsage { + input: number; + output: number; + cache_read?: number | undefined; + cache_write?: number | undefined; +} + +// ── File metadata header ──────────────────────────────────────────────── + +export interface WireFileMetadata { + type: 'metadata'; + protocol_version: string; + created_at: number; + kimi_version?: string | undefined; + /** Implementation that wrote the wire file. Present on TS-native sessions. */ + producer?: WireProducer | undefined; +} + +/** Implementation that wrote the wire file (Python migration vs native TS). */ +export interface WireProducer { + kind: 'python' | 'typescript'; + name: string; + version: string; +} + +// ── Vis-synthetic timeline records ───────────────────────────────────── +// `replayWire()` strips the file header (line 1) and session_initialized +// (line 2) out of `records[]` and exposes them separately. To make the +// Wire tab show EVERYTHING that lives in wire.jsonl, we splice them back +// in as synthetic timeline records, with `seq`/`time` synthesised where +// the on-disk record lacks them. + +export interface MetadataRecord { + type: 'metadata'; + /** Synthetic: 0 (placed just before session_initialized). */ + seq: number; + /** Synthetic: mirrors `created_at`. */ + time: number; + protocol_version: string; + created_at: number; + kimi_version?: string | undefined; + producer?: WireProducer | undefined; + /** Vis-only: which wire file this header came from (useful across archives). */ + file_name?: string | undefined; +} + +// ── session_initialized ─────────────────────────────────────────────── +// Line 2 of every TS-native wire.jsonl. Carries startup baseline that +// the projector reads as authoritative — extracted by replayWire out of +// `records[]` onto `ReplayResult.sessionInitialized`. + +interface SessionInitializedCommonFields { + type: 'session_initialized'; + seq: number; + time: number; + system_prompt: string; + active_tools: string[]; + model?: string | undefined; + permission_mode?: 'default' | 'acceptEdits' | 'bypassPermissions' | undefined; + plan_mode?: boolean | undefined; + workspace_dir?: string | undefined; + thinking_level?: string | undefined; +} + +export interface SessionInitializedMainRecord extends SessionInitializedCommonFields { + agent_type: 'main'; + session_id: string; +} + +export interface SessionInitializedSubRecord extends SessionInitializedCommonFields { + agent_type: 'sub'; + agent_id: string; + agent_name?: string | undefined; + parent_session_id: string; + parent_agent_id?: string | undefined; + parent_tool_call_id: string; + run_in_background: boolean; +} + +export interface SessionInitializedIndependentRecord extends SessionInitializedCommonFields { + agent_type: 'independent'; + agent_id: string; + agent_name?: string | undefined; +} + +export type SessionInitializedRecord = + | SessionInitializedMainRecord + | SessionInitializedSubRecord + | SessionInitializedIndependentRecord; + +// ── Record branches ────────────────────────────────────────────────── + +export interface TurnBeginRecord { + type: 'turn_begin'; + seq: number; + time: number; + turn_id: string; + agent_type: 'main' | 'sub' | 'independent'; + user_input?: string | undefined; + input_kind: 'user' | 'system_trigger'; + trigger_source?: string | undefined; +} + +export interface TurnEndRecord { + type: 'turn_end'; + seq: number; + time: number; + turn_id: string; + agent_type: 'main' | 'sub' | 'independent'; + success: boolean; + reason: 'done' | 'cancelled' | 'error' | 'interrupted'; + usage?: + | { + input_tokens: number; + output_tokens: number; + cache_read_tokens?: number | undefined; + cache_write_tokens?: number | undefined; + cost_usd?: number | undefined; + } + | undefined; + synthetic?: boolean | undefined; +} + +export interface UserMessageRecord { + type: 'user_message'; + seq: number; + time: number; + turn_id: string; + content: string | readonly UserInputPart[]; + uuid?: string | undefined; +} + +// ── Atomic streaming records (replaces assistant_message) ───────────── +// Each LLM step is a sequence: step_begin → (content_part|tool_call)* → step_end. +// An "assistant message" is reconstructed by coalescing these atoms by step_uuid. + +export interface StepBeginRecord { + type: 'step_begin'; + seq: number; + time: number; + /** Unique id for this step (anchor for its content_part / tool_call atoms). */ + uuid: string; + turn_id: string; + step: number; +} + +export interface StepEndRecord { + type: 'step_end'; + seq: number; + time: number; + uuid: string; + turn_id: string; + step: number; + usage?: + | { + input_tokens: number; + output_tokens: number; + cache_read_tokens?: number | undefined; + cache_write_tokens?: number | undefined; + } + | undefined; + finish_reason?: string | undefined; +} + +export interface ContentPartRecord { + type: 'content_part'; + seq: number; + time: number; + uuid: string; + turn_id: string; + step: number; + /** Anchors to the step_begin.uuid that opened this step. */ + step_uuid: string; + role: 'assistant'; + /** Inner key is `kind` (not `type`) to avoid outer-union collision. */ + part: + | { kind: 'text'; text: string } + | { kind: 'think'; think: string; encrypted?: string | undefined }; +} + +export interface ToolResultRecord { + type: 'tool_result'; + seq: number; + time: number; + turn_id: string; + tool_call_id: string; + output: unknown; + is_error?: boolean | undefined; + synthetic?: boolean | undefined; + uuid?: string | undefined; + parent_uuid?: string | undefined; +} + +export interface CompactionRecord { + type: 'compaction'; + seq: number; + time: number; + summary: string; + pre_compact_tokens: number; + post_compact_tokens: number; + uuid?: string | undefined; +} + +export interface SystemPromptChangedRecord { + type: 'system_prompt_changed'; + seq: number; + time: number; + new_prompt: string; +} + +export interface ToolsChangedRecord { + type: 'tools_changed'; + seq: number; + time: number; + operation: 'register' | 'remove' | 'set_active'; + tools: string[]; +} + +export interface SystemReminderRecord { + type: 'system_reminder'; + seq: number; + time: number; + content: string; + consumed_at_turn?: number | undefined; +} + +export interface NotificationRecord { + type: 'notification'; + seq: number; + time: number; + data: { + id: string; + category: 'task' | 'agent' | 'system' | 'team'; + type: string; + source_kind: string; + source_id: string; + title: string; + body: string; + severity: 'info' | 'success' | 'warning' | 'error'; + payload?: Record<string, unknown> | undefined; + targets: Array<'llm' | 'wire' | 'shell'>; + dedupe_key?: string | undefined; + delivered_at?: + | { + llm?: number | undefined; + wire?: number | undefined; + shell?: number | undefined; + } + | undefined; + envelope_id?: string | undefined; + // background_task notifications attach streamed tail output that the + // live projector renders into a <task-notification> block. + tail_output?: string | undefined; + }; +} + +// First-class tool-call record (was once telemetry-only under a different name). +export interface ToolCallRecord { + type: 'tool_call'; + seq: number; + time: number; + uuid: string; + turn_id: string; + step: number; + /** Anchors to the step_begin.uuid that opened this step. */ + step_uuid: string; + data: { + tool_call_id: string; + tool_name: string; + args: unknown; + description?: string | undefined; + display?: ApprovalDisplay; + }; +} + +export interface ToolDeniedRecord { + type: 'tool_denied'; + seq: number; + time: number; + turn_id: string; + step: number; + data: { + tool_call_id: string; + tool_name: string; + rule_id: string; + reason: string; + }; +} + +export type McpApprovalReason = 'elicitation' | 'auth' | 'tool_call'; + +export type ApprovalSource = + | { kind: 'loop'; agent_id: string } + | { kind: 'subagent'; agent_id: string; subagent_type?: string | undefined } + | { kind: 'turn'; turn_id: string } + | { kind: 'session'; session_id: string } + | { kind: 'mcp'; server_id: string; reason: McpApprovalReason }; + +// `ApprovalDisplay` is `ToolInputDisplay` in core. We intentionally keep the +// shape opaque here — vis only passes it through to the UI. +export type ApprovalDisplay = unknown; + +export type SkillInvocationTrigger = 'user-slash' | 'model-tool' | 'nested-skill'; + +export interface SkillInvokedRecord { + type: 'skill_invoked'; + seq: number; + time: number; + turn_id: string; + agent_type?: 'main' | 'sub' | 'independent' | undefined; + data: { + skill_name: string; + execution_mode: 'inline' | 'fork'; + original_input: string; + sub_agent_id?: string | undefined; + invocation_trigger?: SkillInvocationTrigger | undefined; + query_depth?: number | undefined; + }; +} + +export interface SkillCompletedRecord { + type: 'skill_completed'; + seq: number; + time: number; + turn_id: string; + agent_type?: 'main' | 'sub' | 'independent' | undefined; + data: { + skill_name: string; + execution_mode: 'inline' | 'fork'; + success: boolean; + error?: string | undefined; + sub_agent_id?: string | undefined; + invocation_trigger?: SkillInvocationTrigger | undefined; + query_depth?: number | undefined; + }; +} + +export interface ApprovalRequestRecord { + type: 'approval_request'; + seq: number; + time: number; + turn_id: string; + step: number; + data: { + request_id: string; + tool_call_id: string; + tool_name: string; + action: string; + display: ApprovalDisplay; + source: ApprovalSource; + }; +} + +export interface ApprovalResponseRecord { + type: 'approval_response'; + seq: number; + time: number; + turn_id: string; + step: number; + data: { + request_id: string; + response: 'approved' | 'rejected' | 'cancelled'; + feedback?: string | undefined; + selected_label?: string | undefined; + synthetic?: boolean | undefined; + }; +} + +export interface TeamMailRecord { + type: 'team_mail'; + seq: number; + time: number; + data: { + mail_id: string; + reply_to?: string | undefined; + from_agent: string; + to_agent: string; + content: string; + summary?: string | undefined; + }; +} + +export interface SubagentSpawnedRecord { + type: 'subagent_spawned'; + seq: number; + time: number; + uuid?: string | undefined; + data: { + agent_id: string; + agent_name?: string | undefined; + parent_tool_call_id: string; + parent_agent_id?: string | undefined; + parent_tool_call_uuid?: string | undefined; + run_in_background: boolean; + }; +} + +export interface SubagentCompletedRecord { + type: 'subagent_completed'; + seq: number; + time: number; + uuid?: string | undefined; + parent_uuid?: string | undefined; + data: { + agent_id: string; + parent_tool_call_id: string; + result_summary: string; + usage?: TokenUsage | undefined; + }; +} + +export interface SubagentFailedRecord { + type: 'subagent_failed'; + seq: number; + time: number; + uuid?: string | undefined; + parent_uuid?: string | undefined; + data: { + agent_id: string; + parent_tool_call_id: string; + error: string; + }; +} + +export interface OwnershipChangedRecord { + type: 'ownership_changed'; + seq: number; + time: number; + old_owner: string | null; + new_owner: string; +} + +export interface ContextEditRecord { + type: 'context_edit'; + seq: number; + time: number; + operation: 'edit_message' | 'delete_message' | 'rewind' | 'insert_message' | 'replace_message'; + target_seq?: number | undefined; + to_turn?: number | undefined; + after_seq?: number | undefined; + new_content?: string | undefined; + new_role?: 'user' | 'assistant' | 'system' | undefined; + cascade?: boolean | undefined; +} + +export interface ContextClearedRecord { + type: 'context_cleared'; + seq: number; + time: number; +} + +// ── Union ───────────────────────────────────────────────────────────── + +export type VisWireRecord = + | MetadataRecord + | SessionInitializedRecord + | TurnBeginRecord + | TurnEndRecord + | UserMessageRecord + | ToolResultRecord + | CompactionRecord + | SystemPromptChangedRecord + | ToolsChangedRecord + | SystemReminderRecord + | NotificationRecord + | StepBeginRecord + | StepEndRecord + | ContentPartRecord + | ToolCallRecord + | ToolDeniedRecord + | SkillInvokedRecord + | SkillCompletedRecord + | ApprovalRequestRecord + | ApprovalResponseRecord + | TeamMailRecord + | SubagentSpawnedRecord + | SubagentCompletedRecord + | SubagentFailedRecord + | OwnershipChangedRecord + | ContextEditRecord + | ContextClearedRecord; + +// ── Vis-specific response types ───────────────────────────────────────── + +export interface ApiError { + error: string; + code: + | 'NOT_FOUND' + | 'BAD_REQUEST' + | 'UNAUTHORIZED' + | 'READ_ERROR' + | 'PARSE_ERROR' + | 'DELETE_ERROR'; +} + +export interface DeleteSessionResponse { + session_id: string; + deleted: true; +} + +export interface ClearSessionsResponse { + deleted_count: number; + failed: Array<{ session_id: string; error: string }>; +} + +export interface SessionState { + session_id: string; + title?: string; + isCustomTitle?: boolean; + customTitle?: string; + lastPrompt?: string; + last_prompt?: string; + model?: string; + last_turn_id?: string; + last_turn_time?: number; + turn_count?: number; + created_at: number; + updated_at: number; + workspace_dir?: string; + auto_approve_actions?: string[]; + permission_mode?: 'default' | 'acceptEdits' | 'bypassPermissions'; + thinking_level?: string; + custom_title?: string; + plan_mode?: boolean; + plan_slug?: string; + tags?: string[]; + description?: string; + archived?: boolean; + color?: string; + last_exit_code?: 'clean' | 'dirty'; + /** Implementation that wrote this session. Vis only surfaces sessions + * with `kind === 'typescript'`. */ + producer?: WireProducer; +} + +export interface SessionSummary { + session_id: string; + title: string | null; + last_prompt: string | null; + created_at: number; + updated_at: number; + last_turn_time: number | null; + model: string | null; + permission_mode: string | null; + last_exit_code: 'clean' | 'dirty' | null; + custom_title: string | null; + tags: string[]; + archived: boolean; + workspace_dir: string | null; + wire_protocol_version: string | null; + wire_record_count: number; + archive_count: number; + subagent_count: number; + health: 'ok' | 'broken' | 'missing_wire'; +} + +export interface SessionDetail { + session_id: string; + title: string | null; + last_prompt: string | null; + state: SessionState; + subagent_ids: string[]; + archive_files: string[]; + tool_result_ids: string[]; + wire_metadata: WireFileMetadata | null; +} + +export interface WireResponse { + session_id: string; + agent_id: string | null; + files_read: string[]; + health: 'ok' | 'broken'; + broken_reason?: string; + warnings: string[]; + records: VisWireRecord[]; +} + +export interface ContentPart { + type: 'text' | 'think' | 'image_url' | 'video_url'; + [key: string]: unknown; +} + +export interface ToolCallEntry { + type: 'function'; + id: string; + function: { name: string; arguments: string | null }; +} + +export type MessageOrigin = + | { kind: 'user' } + | { kind: 'assistant' } + | { kind: 'tool'; tool_call_id: string } + | { kind: 'system_reminder'; seq: number } + | { kind: 'notification'; seq: number; notification_id: string; severity: string }; + +export interface AnnotatedMessage { + seq: number; + message: { + role: 'user' | 'assistant' | 'tool'; + content: ContentPart[]; + tool_calls: ToolCallEntry[]; + tool_call_id?: string; + }; + origin: MessageOrigin; + is_ephemeral: boolean; + out_of_context: boolean; + persisted_output_path?: string; +} + +export interface ProjectedStateSummary { + model: string | null; + system_prompt: string | null; + active_tools: string[]; + last_seq: number; + token_count: number; + permission_mode: string | null; + plan_mode: boolean; + /** Token breakdown, summed from every `step_end.usage` in the session. + * Provides the 4-segment bar shown at the top of the Context tab. + * Zero when no step_ends have been observed (e.g. pre-protocol session). */ + input_tokens: number; + output_tokens: number; + cache_read_tokens: number; + cache_write_tokens: number; +} + +export interface ContextResponse { + session_id: string; + agent_id: string | null; + annotated_messages: AnnotatedMessage[]; + projected_state: ProjectedStateSummary; +} + +export interface SubagentNode { + agent_id: string; + agent_name: string | null; + subagent_type: string | null; + run_in_background: boolean; + parent_agent_id: string | null; + depth: number; + status: 'running' | 'completed' | 'failed' | 'killed' | 'lost' | 'unknown'; + success: boolean | null; + result_summary: string | null; + error: string | null; + spawn_seq: number; + spawn_time: number; + children: SubagentNode[]; +} + +export interface SubagentTreeResponse { + session_id: string; + tree: SubagentNode[]; +} + +export interface SubagentMetaResponse { + agent_id: string; + session_id: string; + meta_json: { + agent_id: string; + subagent_type: string; + status: string; + description: string; + parent_tool_call_id: string; + created_at: number; + updated_at: number; + } | null; + spawned_record: { + agent_name?: string; + parent_tool_call_id: string; + parent_agent_id?: string; + run_in_background: boolean; + seq: number; + time: number; + } | null; + completed_record: { + parent_tool_call_id: string; + result_summary: string; + usage?: { + input_tokens: number; + output_tokens: number; + cache_read_tokens?: number; + cache_write_tokens?: number; + }; + seq: number; + time: number; + } | null; + failed_record: { + parent_tool_call_id: string; + error: string; + seq: number; + time: number; + } | null; + depth: number; +} + +export interface ToolResultFileResponse { + tool_call_id: string; + session_id: string; + size_bytes: number; + content: string; +} diff --git a/apps/vis/server/src/lib/wire-loader.ts b/apps/vis/server/src/lib/wire-loader.ts new file mode 100644 index 000000000..3f29eaf54 --- /dev/null +++ b/apps/vis/server/src/lib/wire-loader.ts @@ -0,0 +1,179 @@ +import { readdir, readFile } from 'node:fs/promises'; +import { basename, join } from 'node:path'; + +import type { + MetadataRecord, + SessionInitializedRecord, + VisWireRecord, + WireFileMetadata, +} from './types'; +import { replayWire } from './wire-replay'; + +const ARCHIVE_RE = /^wire\.(\d+)\.jsonl$/; + +export interface WireLoadResult { + records: VisWireRecord[]; + /** session_initialized from the newest wire file's line 2. + * `null` when the session predates the line-2 header convention or + * the file lacks a `session_initialized` line. */ + session_initialized: SessionInitializedRecord | null; + health: 'ok' | 'broken'; + broken_reason?: string; + warnings: string[]; + files_read: string[]; +} + +/** + * Enumerate all wire files in `agentDir` (archives first, ascending by N; + * then current `wire.jsonl`). Re-implemented locally because the + * corresponding helper inside `@moonshot-ai/agent-core` is not exported. + */ +export async function listWireFilesManually(agentDir: string): Promise<string[]> { + const wireDir = await resolveWireDir(agentDir); + let entries: string[]; + try { + entries = await readdir(wireDir); + } catch { + return []; + } + + const archives: { path: string; n: number }[] = []; + let current: string | null = null; + for (const entry of entries) { + if (entry === 'wire.jsonl') { + current = join(wireDir, entry); + continue; + } + const m = ARCHIVE_RE.exec(entry); + if (m?.[1] !== undefined) { + archives.push({ path: join(wireDir, entry), n: Number.parseInt(m[1], 10) }); + } + } + archives.sort((a, b) => a.n - b.n); + const out = archives.map((a) => a.path); + if (current !== null) { + out.push(current); + } + return out; +} + +async function resolveWireDir(agentDir: string): Promise<string> { + const mainDir = join(agentDir, 'agents', 'main'); + try { + const entries = await readdir(mainDir); + if (entries.includes('wire.jsonl')) return mainDir; + } catch { + // `agentDir` is already an agent dir or has no main agent. + } + return agentDir; +} + +/** + * Load all wire records for an agent (main or subagent) in age order. + * + * Calls `replayWire()` per file and concatenates — core only exports the + * single-file replay primitive, so multi-file replay is composed here. + * + * The cast to `VisWireRecord[]` is the sole trust boundary in vis. Safe + * because the vis mirror types are field-for-field identical to the core + * `WireRecord` union and `replayWire()` only emits validated records. + */ +export async function loadWireRecords(agentDir: string): Promise<WireLoadResult> { + const files = await listWireFilesManually(agentDir); + + if (files.length === 0) { + return { + records: [], + session_initialized: null, + health: 'broken', + broken_reason: `no wire files found under ${agentDir}`, + warnings: [], + files_read: [], + }; + } + + const records: VisWireRecord[] = []; + const warnings: string[] = []; + let overallHealth: 'ok' | 'broken' = 'ok'; + let brokenReason: string | undefined; + const filesRead: string[] = []; + // session_initialized is the line-2 truth source. Keep the most-recent + // (current wire's) value, not archive values, so post-compaction the + // live model/prompt is reflected. + let sessionInitialized: SessionInitializedRecord | null = null; + + for (const filePath of files) { + try { + const result = await replayWire(filePath); + filesRead.push(filePath); + const fileMeta = await readMetadataLine(filePath); + const init = result.sessionInitialized; + // Splice the two wire.jsonl header lines back into the timeline so + // the Wire tab shows EVERYTHING in the file (including system_prompt). + if (fileMeta !== null) { + const metaRecord: MetadataRecord = { + type: 'metadata', + seq: 0, + time: fileMeta.created_at, + protocol_version: fileMeta.protocol_version, + created_at: fileMeta.created_at, + file_name: basename(filePath), + }; + if (fileMeta.kimi_version !== undefined) metaRecord.kimi_version = fileMeta.kimi_version; + if (fileMeta.producer !== undefined) metaRecord.producer = fileMeta.producer; + records.push(metaRecord); + } + if (init !== null) records.push(init); + records.push(...(result.records)); + sessionInitialized = init; + for (const w of result.warnings) warnings.push(`${filePath}: ${w}`); + if (result.health === 'broken') { + overallHealth = 'broken'; + brokenReason ??= result.brokenReason ?? `broken wire file: ${filePath}`; + } + } catch (error) { + // Filesystem / unexpected errors surface as a soft broken state — + // vis still lists the session and shows its raw records if we + // have any. + overallHealth = 'broken'; + const reason = error instanceof Error ? error.message : String(error); + brokenReason ??= `${filePath}: ${reason}`; + warnings.push(`${filePath}: ${reason}`); + } + } + + const out: WireLoadResult = { + records, + session_initialized: sessionInitialized, + health: overallHealth, + warnings, + files_read: filesRead, + }; + if (brokenReason !== undefined) out.broken_reason = brokenReason; + return out; +} + +/** Parse line 1 of a wire.jsonl as the file-header metadata record. + * Returns null on any read/parse failure — the caller already ran + * `replayWire()`, which validates the header independently, so this + * helper stays forgiving: missing metadata just means we can't splice + * it into the timeline, not that the session is broken. */ +async function readMetadataLine(filePath: string): Promise<WireFileMetadata | null> { + try { + const text = await readFile(filePath, 'utf8'); + const nl = text.indexOf('\n'); + const first = nl === -1 ? text : text.slice(0, nl); + if (first.length === 0) return null; + const parsed = JSON.parse(first) as unknown; + if ( + typeof parsed !== 'object' || + parsed === null || + (parsed as { type?: unknown }).type !== 'metadata' + ) { + return null; + } + return parsed as WireFileMetadata; + } catch { + return null; + } +} diff --git a/apps/vis/server/src/lib/wire-replay.ts b/apps/vis/server/src/lib/wire-replay.ts new file mode 100644 index 000000000..3060eac6e --- /dev/null +++ b/apps/vis/server/src/lib/wire-replay.ts @@ -0,0 +1,488 @@ +import { readFile } from 'node:fs/promises'; + +import type { + ContentPartRecord, + NotificationRecord, + SessionInitializedRecord, + StepBeginRecord, + StepEndRecord, + SystemReminderRecord, + SystemPromptChangedRecord, + ToolCallRecord, + ToolResultRecord, + ToolsChangedRecord, + TurnBeginRecord, + UserInputPart, + UserMessageRecord, + VisWireRecord, + WireFileMetadata, +} from './types'; + +export interface ReplayWireResult { + health: 'ok' | 'broken'; + brokenReason?: string; + warnings: string[]; + metadata: WireFileMetadata | null; + sessionInitialized: SessionInitializedRecord | null; + records: VisWireRecord[]; +} + +export async function replayWire(wirePath: string): Promise<ReplayWireResult> { + const raw = await readFile(wirePath, 'utf8'); + const lines = raw.split(/\r?\n/).filter((line) => line.length > 0); + const warnings: string[] = []; + const records: VisWireRecord[] = []; + const rawRecords: RawWireRecord[] = []; + let metadata: WireFileMetadata | null = null; + let sessionInitialized: SessionInitializedRecord | null = null; + let nextTurnIndex = 0; + let currentTurnId = '0'; + + for (const [index, line] of lines.entries()) { + const seq = index + 1; + let parsed: unknown; + try { + parsed = JSON.parse(line) as unknown; + } catch (error) { + warnings.push(`line ${index + 1}: ${error instanceof Error ? error.message : String(error)}`); + continue; + } + if (!isRecord(parsed) || typeof parsed['type'] !== 'string') { + warnings.push(`line ${index + 1}: missing record type`); + continue; + } + rawRecords.push({ seq, raw: parsed }); + } + + for (const [recordIndex, record] of rawRecords.entries()) { + const parsed = record.raw; + const seq = record.seq; + if (parsed['type'] === 'metadata') { + metadata ??= parsed as unknown as WireFileMetadata; + continue; + } + if (parsed['type'] === 'session_initialized') { + sessionInitialized = parsed as unknown as SessionInitializedRecord; + continue; + } + const normalized = normalizeRecord( + parsed, + seq, + currentTurnId, + nextTurnIndex, + rawRecords, + recordIndex, + ); + records.push(...normalized.records); + if (normalized.currentTurnId !== undefined) currentTurnId = normalized.currentTurnId; + if (normalized.nextTurnIndex !== undefined) nextTurnIndex = normalized.nextTurnIndex; + } + + const out: ReplayWireResult = { + health: warnings.length === 0 ? 'ok' : 'broken', + warnings, + metadata, + sessionInitialized, + records, + }; + if (warnings.length > 0) out.brokenReason = warnings[0]; + return out; +} + +function isRecord(value: unknown): value is Record<string, unknown> { + return typeof value === 'object' && value !== null; +} + +interface RawWireRecord { + seq: number; + raw: Record<string, unknown>; +} + +interface NormalizeResult { + records: VisWireRecord[]; + currentTurnId?: string; + nextTurnIndex?: number; +} + +function normalizeRecord( + raw: Record<string, unknown>, + fallbackSeq: number, + currentTurnId: string, + nextTurnIndex: number, + rawRecords: readonly RawWireRecord[], + recordIndex: number, +): NormalizeResult { + const seq = numberValue(raw['seq']) ?? fallbackSeq; + const time = numberValue(raw['time']) ?? 0; + const type = stringValue(raw['type']) ?? 'unknown'; + + switch (type) { + case 'turn.prompt': + return normalizeTurnStart(raw, seq, time, nextTurnIndex); + + case 'turn.steer': { + const turnId = inferSteerStartedTurnId(rawRecords, recordIndex, currentTurnId, nextTurnIndex); + if (turnId === undefined) return { records: [] }; + return normalizeTurnStart(raw, seq, time, nextTurnIndex, turnId); + } + + case 'context.user_message': + case 'context.append_message': { + // `context.append_message` (newer producer) nests `{role, content}` + // under a `message` field; `context.user_message` (older) puts + // `content` at the top level. Only surface user-authored messages — + // assistant messages are already represented by content.part events + // inside loop events. + if (!isUserAuthoredAppendMessage(raw)) { + return { records: [notificationFromRaw(raw, seq, time)] }; + } + const message = isRecord(raw['message']) ? raw['message'] : undefined; + const rawContent = message !== undefined ? message['content'] : raw['content']; + const content = Array.isArray(rawContent) + ? (rawContent as readonly UserInputPart[]) + : (stringValue(rawContent) ?? ''); + const record: UserMessageRecord = { + type: 'user_message', + seq, + time, + turn_id: currentTurnId, + content, + }; + return { records: [record] }; + } + + case 'context.delta': + case 'context.append_loop_event': { + const event = raw['event']; + if (!isRecord(event)) return { records: [notificationFromRaw(raw, seq, time)] }; + const eventTurnId = stringValue(event['turnId']); + const records = normalizeLoopEvent(event, seq, time, currentTurnId); + return { + records, + currentTurnId: eventTurnId, + }; + } + + case 'config.update': { + const prompt = stringValue(raw['systemPrompt']); + if (prompt !== undefined) { + const record: SystemPromptChangedRecord = { + type: 'system_prompt_changed', + seq, + time, + new_prompt: prompt, + }; + return { records: [record] }; + } + return { records: [notificationFromRaw(raw, seq, time)] }; + } + + case 'tool.set_active': + case 'tools.set_active_tools': { + const names = Array.isArray(raw['names']) + ? raw['names'].filter((name): name is string => typeof name === 'string') + : []; + const record: ToolsChangedRecord = { + type: 'tools_changed', + seq, + time, + operation: 'set_active', + tools: names, + }; + return { records: [record] }; + } + + case 'context.system_reminder': { + const record: SystemReminderRecord = { + type: 'system_reminder', + seq, + time, + content: stringValue(raw['content']) ?? '', + }; + return { records: [record] }; + } + + case 'context.clear': + return { records: [{ type: 'context_cleared', seq, time }] }; + + case 'context.mark_last_user_prompt_blocked': + return { records: [] }; + + default: + return { + records: [ + hasLegacyShape(raw) + ? (raw as unknown as VisWireRecord) + : notificationFromRaw(raw, seq, time), + ], + }; + } +} + +function normalizeTurnStart( + raw: Record<string, unknown>, + seq: number, + time: number, + nextTurnIndex: number, + turnId = String(nextTurnIndex), +): NormalizeResult { + const input = Array.isArray(raw['input']) ? (raw['input'] as readonly UserInputPart[]) : []; + const record: TurnBeginRecord = { + type: 'turn_begin', + seq, + time, + turn_id: turnId, + agent_type: 'main', + input_kind: 'user', + user_input: inputToText(input), + }; + return { + records: [record], + currentTurnId: turnId, + nextTurnIndex: nextTurnIndexAfter(turnId, nextTurnIndex), + }; +} + +function inferSteerStartedTurnId( + rawRecords: readonly RawWireRecord[], + recordIndex: number, + currentTurnId: string, + nextTurnIndex: number, +): string | undefined { + let sawUserMessage = false; + + // Wire does not persist turn.ended, so past records cannot prove whether + // core still had an active turn. The next explicit loop turnId is the durable + // signal for whether this steer stayed buffered or launched a fresh turn. + for (let index = recordIndex + 1; index < rawRecords.length; index += 1) { + const raw = rawRecords[index]?.raw; + if (raw === undefined) continue; + + const type = stringValue(raw['type']); + if (type === 'turn.prompt' || type === 'turn.steer') break; + if (type === 'context.user_message' || type === 'context.append_message') { + // Only an actual user message should count as evidence that the + // steer launched a fresh turn. The newer `context.append_message` + // producer carries assistant / system roles too, and + // `ContextMemory.appendSystemReminder()` persists injected + // reminders with role: 'user' but a non-user origin — both of + // those would fabricate a fresh turn ID below and mis-tag + // subsequent records. + if (isUserAuthoredAppendMessage(raw)) { + sawUserMessage = true; + } + continue; + } + if (type !== 'context.delta' && type !== 'context.append_loop_event') continue; + + const event = raw['event']; + if (!isRecord(event)) continue; + + const eventTurnId = stringValue(event['turnId']); + if (eventTurnId === undefined) continue; + return eventTurnId === currentTurnId ? undefined : eventTurnId; + } + + return sawUserMessage ? String(nextTurnIndex) : undefined; +} + +// Distinguishes real user input from non-user content that shares the +// `role: 'user'` slot for the LLM (system reminders, skill activation +// payloads, injection content, compaction summaries). The older +// `context.user_message` shape has no `origin`, so absence is treated +// as user authorship to preserve back-compat with older wire logs. +function isUserAuthoredAppendMessage(raw: Record<string, unknown>): boolean { + const message = isRecord(raw['message']) ? raw['message'] : undefined; + const role = stringValue(message?.['role']) ?? stringValue(raw['role']); + if (role !== undefined && role !== 'user') return false; + const origin = isRecord(message?.['origin']) ? message['origin'] : undefined; + const originKind = stringValue(origin?.['kind']); + if (originKind !== undefined && originKind !== 'user') return false; + return true; +} + +function nextTurnIndexAfter(turnId: string, nextTurnIndex: number): number { + const parsed = Number.parseInt(turnId, 10); + if (String(parsed) === turnId && parsed >= nextTurnIndex) return parsed + 1; + return nextTurnIndex + 1; +} + +function normalizeLoopEvent( + event: Record<string, unknown>, + seq: number, + time: number, + currentTurnId: string, +): VisWireRecord[] { + const eventType = stringValue(event['type']); + const turnId = stringValue(event['turnId']) ?? currentTurnId; + + switch (eventType) { + case undefined: + return [notificationFromRaw({ type: 'context.delta', event }, seq, time)]; + + case 'step.begin': { + const record: StepBeginRecord = { + type: 'step_begin', + seq, + time, + uuid: stringValue(event['uuid']) ?? `step_${seq}`, + turn_id: turnId, + step: numberValue(event['step']) ?? 0, + }; + return [record]; + } + + case 'content.part': { + const rawPart = isRecord(event['part']) ? event['part'] : {}; + const kind = rawPart['type'] === 'think' || rawPart['kind'] === 'think' ? 'think' : 'text'; + const record: ContentPartRecord = { + type: 'content_part', + seq, + time, + uuid: stringValue(event['uuid']) ?? `part_${seq}`, + turn_id: turnId, + step: numberValue(event['step']) ?? 0, + step_uuid: stringValue(event['stepUuid']) ?? '', + role: 'assistant', + part: + kind === 'think' + ? { kind, think: stringValue(rawPart['think']) ?? '' } + : { kind, text: stringValue(rawPart['text']) ?? '' }, + }; + return [record]; + } + + case 'tool.call': { + const record: ToolCallRecord = { + type: 'tool_call', + seq, + time, + uuid: stringValue(event['uuid']) ?? `tool_${seq}`, + turn_id: turnId, + step: numberValue(event['step']) ?? 0, + step_uuid: stringValue(event['stepUuid']) ?? '', + data: { + tool_call_id: stringValue(event['toolCallId']) ?? `tool_${seq}`, + tool_name: stringValue(event['name']) ?? 'unknown', + args: event['args'], + description: stringValue(event['description']), + display: event['display'], + }, + }; + return [record]; + } + + case 'tool.result': { + const result = isRecord(event['result']) ? event['result'] : {}; + const record: ToolResultRecord = { + type: 'tool_result', + seq, + time, + turn_id: turnId, + tool_call_id: stringValue(event['toolCallId']) ?? `tool_${seq}`, + output: result['output'], + is_error: result['isError'] === true, + parent_uuid: stringValue(event['parentUuid']), + }; + return [record]; + } + + case 'step.end': { + const record: StepEndRecord = { + type: 'step_end', + seq, + time, + uuid: stringValue(event['uuid']) ?? `step_${seq}`, + turn_id: turnId, + step: numberValue(event['step']) ?? 0, + usage: normalizeUsage(event['usage']), + finish_reason: stringValue(event['finishReason']), + }; + return [record]; + } + + default: + return [notificationFromRaw({ type: 'context.delta', event }, seq, time)]; + } +} + +function notificationFromRaw( + raw: Record<string, unknown>, + seq: number, + time: number, +): NotificationRecord { + const type = stringValue(raw['type']) ?? 'unknown'; + return { + type: 'notification', + seq, + time, + data: { + id: `${seq}:${type}`, + category: 'system', + type, + source_kind: 'wire', + source_id: 'vis', + title: type, + body: stringify(raw), + severity: 'info', + payload: raw, + targets: ['wire'], + }, + }; +} + +function hasLegacyShape(raw: Record<string, unknown>): boolean { + const type = stringValue(raw['type']); + return ( + type !== undefined && + !type.includes('.') && + typeof raw['seq'] === 'number' && + typeof raw['time'] === 'number' + ); +} + +function normalizeUsage(value: unknown): StepEndRecord['usage'] { + if (!isRecord(value)) return undefined; + return { + input_tokens: + numberValue(value['input_tokens']) ?? + numberValue(value['input']) ?? + numberValue(value['inputOther']) ?? + 0, + output_tokens: numberValue(value['output_tokens']) ?? numberValue(value['output']) ?? 0, + cache_read_tokens: + numberValue(value['cache_read_tokens']) ?? + numberValue(value['cache_read']) ?? + numberValue(value['inputCacheRead']), + cache_write_tokens: + numberValue(value['cache_write_tokens']) ?? + numberValue(value['cache_write']) ?? + numberValue(value['inputCacheCreation']), + }; +} + +function inputToText(input: readonly UserInputPart[]): string { + return input + .map((part) => { + if (part.type === 'text') return part.text; + if (part.type === 'image_url') return '<image>'; + return '<video>'; + }) + .join(''); +} + +function stringValue(value: unknown): string | undefined { + return typeof value === 'string' && value.length > 0 ? value : undefined; +} + +function numberValue(value: unknown): number | undefined { + return typeof value === 'number' && Number.isFinite(value) ? value : undefined; +} + +function stringify(value: unknown): string { + try { + return JSON.stringify(value); + } catch { + return '[unserializable]'; + } +} diff --git a/apps/vis/server/src/routes/context.ts b/apps/vis/server/src/routes/context.ts new file mode 100644 index 000000000..fdb1d5653 --- /dev/null +++ b/apps/vis/server/src/routes/context.ts @@ -0,0 +1,57 @@ +import { readFile, stat } from 'node:fs/promises'; + +import { Hono } from 'hono'; + +import { pathConfig } from '../config'; +import { buildAnnotatedMessages, buildProjectedStateSummary } from '../lib/context-builder'; +import type { ContextResponse, SessionState } from '../lib/types'; +import { loadWireRecords } from '../lib/wire-loader'; + +const SESSION_ID_RE = /^session_[a-zA-Z0-9_-]+$/; + +export function contextRoute(): Hono { + const app = new Hono(); + + app.get('/:id/context', async (c) => { + const id = c.req.param('id'); + if (!SESSION_ID_RE.test(id)) { + return c.json({ error: `invalid session id: ${id}`, code: 'BAD_REQUEST' }, 400); + } + const sessionDir = pathConfig.sessionDir(id); + try { + const dirStat = await stat(sessionDir); + if (!dirStat.isDirectory()) { + return c.json({ error: `session not found: ${id}`, code: 'NOT_FOUND' }, 404); + } + } catch { + return c.json({ error: `session not found: ${id}`, code: 'NOT_FOUND' }, 404); + } + + try { + const load = await loadWireRecords(sessionDir); + const state = await readSessionState(id); + const annotated = buildAnnotatedMessages(load.records); + const projected = buildProjectedStateSummary(load.records, load.session_initialized, state); + const body: ContextResponse = { + session_id: id, + agent_id: null, + annotated_messages: annotated, + projected_state: projected, + }; + return c.json(body); + } catch (error) { + const msg = error instanceof Error ? error.message : String(error); + return c.json({ error: `failed to build context: ${msg}`, code: 'READ_ERROR' }, 500); + } + }); + + return app; +} + +async function readSessionState(sessionId: string): Promise<SessionState | null> { + try { + return JSON.parse(await readFile(pathConfig.statePath(sessionId), 'utf8')) as SessionState; + } catch { + return null; + } +} diff --git a/apps/vis/server/src/routes/session-detail.ts b/apps/vis/server/src/routes/session-detail.ts new file mode 100644 index 000000000..3ab089740 --- /dev/null +++ b/apps/vis/server/src/routes/session-detail.ts @@ -0,0 +1,155 @@ +import { createReadStream } from 'node:fs'; +import { readFile, readdir, stat } from 'node:fs/promises'; +import { join } from 'node:path'; +import { createInterface } from 'node:readline'; + +import { Hono } from 'hono'; + +import { pathConfig } from '../config'; +import { deleteSession, SESSION_ID_RE } from '../lib/session-delete'; +import { readSessionLastPrompt, readSessionTitle } from '../lib/session-title'; +import type { SessionDetail, SessionState, WireFileMetadata } from '../lib/types'; + +const ARCHIVE_RE = /^wire\.\d+\.jsonl$/; + +async function readFirstLine(path: string): Promise<string | null> { + try { + const stream = createReadStream(path, { encoding: 'utf8' }); + const rl = createInterface({ input: stream, crlfDelay: Infinity }); + for await (const line of rl) { + rl.close(); + stream.close(); + return line; + } + } catch { + return null; + } + return null; +} + +export function sessionDetailRoute(): Hono { + const app = new Hono(); + + app.get('/:id', async (c) => { + const id = c.req.param('id'); + if (!SESSION_ID_RE.test(id)) { + return c.json({ error: `invalid session id: ${id}`, code: 'BAD_REQUEST' }, 400); + } + const sessionDir = pathConfig.sessionDir(id); + + let dirStat; + try { + dirStat = await stat(sessionDir); + } catch { + return c.json({ error: `session not found: ${id}`, code: 'NOT_FOUND' }, 404); + } + if (!dirStat.isDirectory()) { + return c.json({ error: `session not a directory: ${id}`, code: 'NOT_FOUND' }, 404); + } + + // state.json (best-effort). + let state: SessionState; + try { + const raw = await readFile(pathConfig.statePath(id), 'utf8'); + state = JSON.parse(raw) as SessionState; + } catch { + state = { + session_id: id, + created_at: 0, + updated_at: 0, + }; + } + + // Directory listings. + const mainAgentDir = pathConfig.mainAgentDir(id); + const mainEntries = await readdir(mainAgentDir).catch(() => [] as string[]); + const archive_files = mainEntries.filter((e) => ARCHIVE_RE.test(e)).toSorted(); + + const subagent_ids: string[] = []; + try { + const subs = await readdir(join(sessionDir, 'agents')); + for (const s of subs) { + if (s === 'main') continue; + const ss = await stat(join(sessionDir, 'agents', s)).catch(() => null); + if (ss?.isDirectory()) subagent_ids.push(s); + } + } catch { + try { + const subs = await readdir(join(sessionDir, 'subagents')); + for (const s of subs) { + const ss = await stat(join(sessionDir, 'subagents', s)).catch(() => null); + if (ss?.isDirectory()) subagent_ids.push(s); + } + } catch { + // subagents dir missing is fine + } + } + + const tool_result_ids: string[] = []; + try { + const files = await readdir(join(mainAgentDir, 'tool-results')); + for (const f of files) { + if (f.endsWith('.txt')) tool_result_ids.push(f.slice(0, -4)); + } + } catch { + try { + const files = await readdir(join(sessionDir, 'tool-results')); + for (const f of files) { + if (f.endsWith('.txt')) tool_result_ids.push(f.slice(0, -4)); + } + } catch { + // tool-results dir missing is fine + } + } + + // wire metadata (first line of wire.jsonl) + let wire_metadata: WireFileMetadata | null = null; + const firstLine = await readFirstLine(pathConfig.wirePath(id)); + if (firstLine !== null) { + try { + const parsed = JSON.parse(firstLine) as unknown; + if ( + typeof parsed === 'object' && + parsed !== null && + (parsed as { type?: unknown }).type === 'metadata' + ) { + wire_metadata = parsed as WireFileMetadata; + } + } catch { + // ignore malformed header + } + } + + const detail: SessionDetail = { + session_id: id, + title: readSessionTitle(state), + last_prompt: readSessionLastPrompt(state), + state, + subagent_ids, + archive_files, + tool_result_ids, + wire_metadata, + }; + return c.json(detail); + }); + + app.delete('/:id', async (c) => { + const id = c.req.param('id'); + if (!SESSION_ID_RE.test(id)) { + return c.json({ error: `invalid session id: ${id}`, code: 'BAD_REQUEST' }, 400); + } + + try { + const result = await deleteSession(id); + if (result === null) { + return c.json({ error: `session not found: ${id}`, code: 'NOT_FOUND' }, 404); + } + return c.json(result); + } catch (error) { + const msg = error instanceof Error ? error.message : String(error); + return c.json({ error: `failed to delete session: ${msg}`, code: 'DELETE_ERROR' }, 500); + } + }); + + return app; +} diff --git a/apps/vis/server/src/routes/sessions.ts b/apps/vis/server/src/routes/sessions.ts new file mode 100644 index 000000000..4b44d476d --- /dev/null +++ b/apps/vis/server/src/routes/sessions.ts @@ -0,0 +1,28 @@ +import { Hono } from 'hono'; + +import { pathConfig } from '../config'; +import { clearSessions } from '../lib/session-delete'; +import { listSessions } from '../lib/session-lister'; + +export function sessionsRoute(): Hono { + const app = new Hono(); + app.get('/', async (c) => { + try { + const summaries = await listSessions(pathConfig.sessionsDir); + return c.json(summaries); + } catch (error) { + const msg = error instanceof Error ? error.message : String(error); + return c.json({ error: `failed to list sessions: ${msg}`, code: 'READ_ERROR' }, 500); + } + }); + app.delete('/', async (c) => { + try { + const result = await clearSessions(); + return c.json(result); + } catch (error) { + const msg = error instanceof Error ? error.message : String(error); + return c.json({ error: `failed to clear sessions: ${msg}`, code: 'DELETE_ERROR' }, 500); + } + }); + return app; +} diff --git a/apps/vis/server/src/routes/subagents.ts b/apps/vis/server/src/routes/subagents.ts new file mode 100644 index 000000000..5a8ee476c --- /dev/null +++ b/apps/vis/server/src/routes/subagents.ts @@ -0,0 +1,226 @@ +import { readFile, stat } from 'node:fs/promises'; + +import { Hono } from 'hono'; + +import { pathConfig } from '../config'; +import { buildAnnotatedMessages, buildProjectedStateSummary } from '../lib/context-builder'; +import { buildSubagentTree } from '../lib/subagent-loader'; +import type { + ContextResponse, + SubagentMetaResponse, + SubagentNode, + SubagentTreeResponse, + WireResponse +} from '../lib/types'; +import { loadWireRecords } from '../lib/wire-loader'; + +const SESSION_ID_RE = /^session_[a-zA-Z0-9_-]+$/; +// Subagent ids in the wild look like "sub_<uuid-ish>" — allow word chars and dashes. +const AGENT_ID_RE = /^[a-zA-Z0-9_-]+$/; + +function findNode(tree: SubagentNode[], agentId: string): SubagentNode | null { + for (const node of tree) { + if (node.agent_id === agentId) return node; + const found = findNode(node.children, agentId); + if (found !== null) return found; + } + return null; +} + +export function subagentsRoute(): Hono { + const app = new Hono(); + + app.get('/:id/subagents', async (c) => { + const id = c.req.param('id'); + if (!SESSION_ID_RE.test(id)) { + return c.json({ error: `invalid session id: ${id}`, code: 'BAD_REQUEST' }, 400); + } + const sessionDir = pathConfig.sessionDir(id); + try { + const s = await stat(sessionDir); + if (!s.isDirectory()) + return c.json({ error: `session not found: ${id}`, code: 'NOT_FOUND' }, 404); + } catch { + return c.json({ error: `session not found: ${id}`, code: 'NOT_FOUND' }, 404); + } + + try { + const main = await loadWireRecords(sessionDir); + const tree = await buildSubagentTree(sessionDir, main.records); + const body: SubagentTreeResponse = { session_id: id, tree }; + return c.json(body); + } catch (error) { + const msg = error instanceof Error ? error.message : String(error); + return c.json({ error: `failed to build subagent tree: ${msg}`, code: 'READ_ERROR' }, 500); + } + }); + + app.get('/:id/subagents/:agentId/wire', async (c) => { + const id = c.req.param('id'); + const agentId = c.req.param('agentId'); + if (!SESSION_ID_RE.test(id) || !AGENT_ID_RE.test(agentId)) { + return c.json({ error: 'invalid session or agent id', code: 'BAD_REQUEST' }, 400); + } + const subDir = pathConfig.subagentDir(id, agentId); + try { + const s = await stat(subDir); + if (!s.isDirectory()) + return c.json({ error: `subagent not found: ${agentId}`, code: 'NOT_FOUND' }, 404); + } catch { + return c.json({ error: `subagent not found: ${agentId}`, code: 'NOT_FOUND' }, 404); + } + + try { + const result = await loadWireRecords(subDir); + const body: WireResponse = { + session_id: id, + agent_id: agentId, + files_read: result.files_read, + health: result.health, + warnings: result.warnings, + records: result.records, + }; + if (result.broken_reason !== undefined) body.broken_reason = result.broken_reason; + return c.json(body); + } catch (error) { + const msg = error instanceof Error ? error.message : String(error); + return c.json({ error: `failed to load subagent wire: ${msg}`, code: 'READ_ERROR' }, 500); + } + }); + + app.get('/:id/subagents/:agentId/context', async (c) => { + const id = c.req.param('id'); + const agentId = c.req.param('agentId'); + if (!SESSION_ID_RE.test(id) || !AGENT_ID_RE.test(agentId)) { + return c.json({ error: 'invalid session or agent id', code: 'BAD_REQUEST' }, 400); + } + const subDir = pathConfig.subagentDir(id, agentId); + try { + const s = await stat(subDir); + if (!s.isDirectory()) + return c.json({ error: `subagent not found: ${agentId}`, code: 'NOT_FOUND' }, 404); + } catch { + return c.json({ error: `subagent not found: ${agentId}`, code: 'NOT_FOUND' }, 404); + } + + try { + const load = await loadWireRecords(subDir); + const annotated = buildAnnotatedMessages(load.records); + const projected = buildProjectedStateSummary(load.records, load.session_initialized); + const body: ContextResponse = { + session_id: id, + agent_id: agentId, + annotated_messages: annotated, + projected_state: projected, + }; + return c.json(body); + } catch (error) { + const msg = error instanceof Error ? error.message : String(error); + return c.json({ error: `failed to build subagent context: ${msg}`, code: 'READ_ERROR' }, 500); + } + }); + + app.get('/:id/subagents/:agentId/meta', async (c) => { + const id = c.req.param('id'); + const agentId = c.req.param('agentId'); + if (!SESSION_ID_RE.test(id) || !AGENT_ID_RE.test(agentId)) { + return c.json({ error: 'invalid session or agent id', code: 'BAD_REQUEST' }, 400); + } + const sessionDir = pathConfig.sessionDir(id); + const subDir = pathConfig.subagentDir(id, agentId); + try { + const s = await stat(subDir); + if (!s.isDirectory()) + return c.json({ error: `subagent not found: ${agentId}`, code: 'NOT_FOUND' }, 404); + } catch { + return c.json({ error: `subagent not found: ${agentId}`, code: 'NOT_FOUND' }, 404); + } + + let meta_json: SubagentMetaResponse['meta_json'] = null; + try { + const raw = await readFile(`${subDir}/meta.json`, 'utf8'); + meta_json = JSON.parse(raw) as SubagentMetaResponse['meta_json']; + } catch { + meta_json = null; + } + + // Find the agent in the tree, then scan its direct parent's wire for + // lifecycle records. For nested subagents (depth >= 1), lifecycle records + // live on the intermediate parent's wire, not the main session wire. + let spawned_record: SubagentMetaResponse['spawned_record'] = null; + let completed_record: SubagentMetaResponse['completed_record'] = null; + let failed_record: SubagentMetaResponse['failed_record'] = null; + let depth = 0; + + try { + const main = await loadWireRecords(sessionDir); + const tree = await buildSubagentTree(sessionDir, main.records); + const node = findNode(tree, agentId); + if (node !== null) depth = node.depth; + + // Pick the correct wire to scan: main wire for depth 0, else the parent + // subagent's wire (which contains this child's spawn/complete records). + const parentAgentId = node?.parent_agent_id ?? null; + const scanRecords = + parentAgentId === null + ? main.records + : (await loadWireRecords(pathConfig.subagentDir(id, parentAgentId))).records; + + for (const r of scanRecords) { + if (r.type === 'subagent_spawned' && r.data.agent_id === agentId) { + const entry: NonNullable<SubagentMetaResponse['spawned_record']> = { + parent_tool_call_id: r.data.parent_tool_call_id, + run_in_background: r.data.run_in_background, + seq: r.seq, + time: r.time, + }; + if (r.data.agent_name !== undefined) entry.agent_name = r.data.agent_name; + if (r.data.parent_agent_id !== undefined) entry.parent_agent_id = r.data.parent_agent_id; + spawned_record = entry; + } else if (r.type === 'subagent_completed' && r.data.agent_id === agentId) { + const entry: NonNullable<SubagentMetaResponse['completed_record']> = { + parent_tool_call_id: r.data.parent_tool_call_id, + result_summary: r.data.result_summary, + seq: r.seq, + time: r.time, + }; + if (r.data.usage !== undefined) { + const u = r.data.usage; + const usage: { + input_tokens: number; + output_tokens: number; + cache_read_tokens?: number; + cache_write_tokens?: number; + } = { input_tokens: u.input, output_tokens: u.output }; + if (u.cache_read !== undefined) usage.cache_read_tokens = u.cache_read; + if (u.cache_write !== undefined) usage.cache_write_tokens = u.cache_write; + entry.usage = usage; + } + completed_record = entry; + } else if (r.type === 'subagent_failed' && r.data.agent_id === agentId) { + failed_record = { + parent_tool_call_id: r.data.parent_tool_call_id, + error: r.data.error, + seq: r.seq, + time: r.time, + }; + } + } + } catch { + // Fall through — partial response is still useful. + } + + const body: SubagentMetaResponse = { + agent_id: agentId, + session_id: id, + meta_json, + spawned_record, + completed_record, + failed_record, + depth, + }; + return c.json(body); + }); + + return app; +} diff --git a/apps/vis/server/src/routes/tool-results.ts b/apps/vis/server/src/routes/tool-results.ts new file mode 100644 index 000000000..30b67da00 --- /dev/null +++ b/apps/vis/server/src/routes/tool-results.ts @@ -0,0 +1,48 @@ +import { readFile, stat } from 'node:fs/promises'; + +import { Hono } from 'hono'; + +import { pathConfig } from '../config'; +import type { ToolResultFileResponse } from '../lib/types'; + +const SESSION_ID_RE = /^session_[a-zA-Z0-9_-]+$/; +// Tool call ids observed in the wild: simple identifiers, "call_..." or +// "Agent:7". Allow a conservative character set including colon. +const TOOL_CALL_ID_RE = /^[a-zA-Z0-9_:.-]+$/; + +export function toolResultsRoute(): Hono { + const app = new Hono(); + + app.get('/:id/tool-results/:toolCallId', async (c) => { + const id = c.req.param('id'); + const toolCallId = c.req.param('toolCallId'); + if (!SESSION_ID_RE.test(id)) { + return c.json({ error: `invalid session id: ${id}`, code: 'BAD_REQUEST' }, 400); + } + if (!TOOL_CALL_ID_RE.test(toolCallId)) { + return c.json({ error: `invalid tool call id: ${toolCallId}`, code: 'BAD_REQUEST' }, 400); + } + const filePath = pathConfig.toolResultArchivePath(id, toolCallId); + let s; + try { + s = await stat(filePath); + } catch { + return c.json({ error: `tool result not found: ${toolCallId}`, code: 'NOT_FOUND' }, 404); + } + try { + const content = await readFile(filePath, 'utf8'); + const body: ToolResultFileResponse = { + tool_call_id: toolCallId, + session_id: id, + size_bytes: s.size, + content, + }; + return c.json(body); + } catch (error) { + const msg = error instanceof Error ? error.message : String(error); + return c.json({ error: `failed to read tool result: ${msg}`, code: 'READ_ERROR' }, 500); + } + }); + + return app; +} diff --git a/apps/vis/server/src/routes/wire.ts b/apps/vis/server/src/routes/wire.ts new file mode 100644 index 000000000..4bd647900 --- /dev/null +++ b/apps/vis/server/src/routes/wire.ts @@ -0,0 +1,84 @@ +import { stat } from 'node:fs/promises'; +import { join } from 'node:path'; + +import { Hono } from 'hono'; + +import { pathConfig } from '../config'; +import type { WireResponse } from '../lib/types'; +import { loadWireRecords } from '../lib/wire-loader'; +import { replayWire } from '../lib/wire-replay'; + +const SESSION_ID_RE = /^session_[a-zA-Z0-9_-]+$/; +const ARCHIVE_FILE_RE = /^wire\.\d+\.jsonl$/; + +export function wireRoute(): Hono { + const app = new Hono(); + + app.get('/:id/wire', async (c) => { + const id = c.req.param('id'); + if (!SESSION_ID_RE.test(id)) { + return c.json({ error: `invalid session id: ${id}`, code: 'BAD_REQUEST' }, 400); + } + const sessionDir = pathConfig.sessionDir(id); + try { + const dirStat = await stat(sessionDir); + if (!dirStat.isDirectory()) { + return c.json({ error: `session not found: ${id}`, code: 'NOT_FOUND' }, 404); + } + } catch { + return c.json({ error: `session not found: ${id}`, code: 'NOT_FOUND' }, 404); + } + + try { + const result = await loadWireRecords(sessionDir); + const body: WireResponse = { + session_id: id, + agent_id: null, + files_read: result.files_read, + health: result.health, + warnings: result.warnings, + records: result.records, + }; + if (result.broken_reason !== undefined) body.broken_reason = result.broken_reason; + return c.json(body); + } catch (error) { + const msg = error instanceof Error ? error.message : String(error); + return c.json({ error: `failed to load wire: ${msg}`, code: 'READ_ERROR' }, 500); + } + }); + + app.get('/:id/archives/:filename', async (c) => { + const id = c.req.param('id'); + const filename = c.req.param('filename'); + if (!SESSION_ID_RE.test(id)) { + return c.json({ error: `invalid session id: ${id}`, code: 'BAD_REQUEST' }, 400); + } + if (!ARCHIVE_FILE_RE.test(filename)) { + return c.json({ error: `invalid archive filename: ${filename}`, code: 'BAD_REQUEST' }, 400); + } + const archivePath = join(pathConfig.mainAgentDir(id), filename); + try { + await stat(archivePath); + } catch { + return c.json({ error: `archive not found: ${filename}`, code: 'NOT_FOUND' }, 404); + } + try { + const result = await replayWire(archivePath); + const body: WireResponse = { + session_id: id, + agent_id: null, + files_read: [archivePath], + health: result.health, + warnings: [...result.warnings], + records: result.records, + }; + if (result.brokenReason !== undefined) body.broken_reason = result.brokenReason; + return c.json(body); + } catch (error) { + const msg = error instanceof Error ? error.message : String(error); + return c.json({ error: `failed to replay archive: ${msg}`, code: 'READ_ERROR' }, 500); + } + }); + + return app; +} diff --git a/apps/vis/server/src/startup-banner.ts b/apps/vis/server/src/startup-banner.ts new file mode 100644 index 000000000..55f9dd41f --- /dev/null +++ b/apps/vis/server/src/startup-banner.ts @@ -0,0 +1,19 @@ +export interface StartupBannerOptions { + readonly authToken?: string; + readonly host: string; + readonly kimiCodeHome: string; + readonly port: number; +} + +export function formatStartupBanner(options: StartupBannerOptions): string { + const authStatus = options.authToken === undefined ? 'auth=disabled' : 'auth=required'; + return ( + `[vis-server] listening on http://${hostForUrl(options.host)}:${String(options.port)} ` + + `(${authStatus}, KIMI_CODE_HOME=${options.kimiCodeHome})\n` + ); +} + +function hostForUrl(host: string): string { + if (host.includes(':') && !host.startsWith('[')) return `[${host}]`; + return host; +} diff --git a/apps/vis/server/test/_fixture.ts b/apps/vis/server/test/_fixture.ts new file mode 100644 index 000000000..95cde6b18 --- /dev/null +++ b/apps/vis/server/test/_fixture.ts @@ -0,0 +1,251 @@ +import { randomBytes } from 'node:crypto'; +import { mkdirSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +/** Minimal synthetic session dir using the current atomic wire protocol. + * Real on-disk sessions under `~/.kimi-code/sessions/` use a legacy layout + * that can no longer be replayed, so tests use synthetic fixtures. */ +export interface SyntheticOptions { + withReminder?: boolean; + withTools?: boolean; + withSubagent?: boolean; +} + +export interface SyntheticResult { + dir: string; + sessionId: string; + cleanup(): void; +} + +export function createSyntheticSession(opts: SyntheticOptions = {}): SyntheticResult { + const sessionId = `session_${randomBytes(6).toString('hex')}`; + const dir = join(tmpdir(), `vis-test-${sessionId}`); + mkdirSync(dir, { recursive: true }); + + const lines: string[] = []; + let seq = 1; + const push = (rec: Record<string, unknown>): void => { + lines.push(JSON.stringify(rec)); + }; + + push({ + type: 'metadata', + protocol_version: '1.0', + created_at: Date.now(), + producer: { + kind: 'typescript', + name: '@moonshot-ai/agent-core', + version: '0.0.1', + }, + }); + + push({ + type: 'session_initialized', + seq: seq++, + time: Date.now(), + agent_type: 'main', + session_id: sessionId, + system_prompt: 'You are a test assistant.', + model: 'test-model', + active_tools: opts.withTools ? ['Bash'] : [], + permission_mode: 'default', + plan_mode: false, + workspace_dir: '/tmp', + }); + + push({ + type: 'turn_begin', + seq: seq++, + time: Date.now(), + turn_id: 'turn_1', + agent_type: 'main', + input_kind: 'user', + user_input: 'hi', + }); + + push({ + type: 'user_message', + seq: seq++, + time: Date.now(), + turn_id: 'turn_1', + content: 'hi', + }); + + if (opts.withReminder) { + push({ + type: 'system_reminder', + seq: seq++, + time: Date.now(), + content: 'test reminder', + consumed_at_turn: 1, + }); + } + + const stepUuid = 'step-1'; + push({ + type: 'step_begin', + seq: seq++, + time: Date.now(), + uuid: stepUuid, + turn_id: 'turn_1', + step: 0, + }); + push({ + type: 'content_part', + seq: seq++, + time: Date.now(), + uuid: 'cp-1', + turn_id: 'turn_1', + step: 0, + step_uuid: stepUuid, + role: 'assistant', + part: { kind: 'text', text: 'hello!' }, + }); + + if (opts.withTools) { + push({ + type: 'tool_call', + seq: seq++, + time: Date.now(), + uuid: 'tc-wire-1', + turn_id: 'turn_1', + step: 0, + step_uuid: stepUuid, + data: { + tool_call_id: 'tc_abc', + tool_name: 'Bash', + args: { cmd: 'echo hi' }, + }, + }); + } + + push({ + type: 'step_end', + seq: seq++, + time: Date.now(), + uuid: stepUuid, + turn_id: 'turn_1', + step: 0, + usage: { input_tokens: 10, output_tokens: 5 }, + }); + + if (opts.withTools) { + push({ + type: 'tool_result', + seq: seq++, + time: Date.now(), + turn_id: 'turn_1', + tool_call_id: 'tc_abc', + output: 'hi\n', + }); + } + + if (opts.withSubagent) { + push({ + type: 'subagent_spawned', + seq: seq++, + time: Date.now(), + data: { + agent_id: 'sub_test-1', + agent_name: 'coder', + parent_tool_call_id: 'tc_sub', + run_in_background: false, + }, + }); + // Write the subagent's own wire.jsonl + const subDir = join(dir, 'subagents', 'sub_test-1'); + mkdirSync(subDir, { recursive: true }); + const subLines: string[] = [ + JSON.stringify({ + type: 'metadata', + protocol_version: '1.0', + created_at: Date.now(), + producer: { + kind: 'typescript', + name: '@moonshot-ai/agent-core', + version: '0.0.1', + }, + }), + JSON.stringify({ + type: 'session_initialized', + seq: 1, + time: Date.now(), + agent_type: 'sub', + agent_id: 'sub_test-1', + agent_name: 'coder', + parent_session_id: sessionId, + parent_tool_call_id: 'tc_sub', + run_in_background: false, + system_prompt: 'You are a subagent.', + model: 'test-model', + active_tools: [], + permission_mode: 'default', + plan_mode: false, + workspace_dir: '/tmp', + }), + ]; + writeFileSync(join(subDir, 'wire.jsonl'), subLines.join('\n') + '\n'); + writeFileSync( + join(subDir, 'meta.json'), + JSON.stringify({ + agent_id: 'sub_test-1', + subagent_type: 'coder', + status: 'completed', + description: 'test subagent', + parent_tool_call_id: 'tc_sub', + created_at: Date.now() / 1000, + updated_at: Date.now() / 1000, + }), + ); + + push({ + type: 'subagent_completed', + seq: seq++, + time: Date.now(), + data: { + agent_id: 'sub_test-1', + parent_tool_call_id: 'tc_sub', + result_summary: 'all done', + usage: { input: 100, output: 50 }, + }, + }); + } + + push({ + type: 'turn_end', + seq: seq++, + time: Date.now(), + turn_id: 'turn_1', + agent_type: 'main', + success: true, + reason: 'done', + }); + + writeFileSync(join(dir, 'wire.jsonl'), lines.join('\n') + '\n'); + writeFileSync( + join(dir, 'state.json'), + JSON.stringify({ + session_id: sessionId, + model: 'test-model', + status: 'idle', + created_at: Date.now(), + updated_at: Date.now(), + workspace_dir: '/tmp', + // Required for listSessions() producer filter. + producer: { kind: 'typescript', name: '@moonshot-ai/agent-core', version: '0.0.1' }, + }), + ); + + return { + dir, + sessionId, + cleanup: () => { + try { + rmSync(dir, { recursive: true, force: true }); + } catch { + /* ignore */ + } + }, + }; +} diff --git a/apps/vis/server/test/app.test.ts b/apps/vis/server/test/app.test.ts new file mode 100644 index 000000000..23b4cbf33 --- /dev/null +++ b/apps/vis/server/test/app.test.ts @@ -0,0 +1,74 @@ +import { afterEach, describe, expect, it } from 'vitest'; + +import { createApp } from '../src/app'; +import { resolveHost, resolveVisAuthToken } from '../src/config'; +import { formatStartupBanner } from '../src/startup-banner'; + +describe('vis server access controls', () => { + const savedEnv = { ...process.env }; + + afterEach(() => { + process.env = { ...savedEnv }; + }); + + it('binds to loopback by default', () => { + delete process.env['VIS_HOST']; + delete process.env['HOST']; + + expect(resolveHost()).toBe('127.0.0.1'); + }); + + it('allows an explicit bind host', () => { + process.env['VIS_HOST'] = '0.0.0.0'; + + expect(resolveHost()).toBe('0.0.0.0'); + }); + + it('requires a token when binding outside loopback', () => { + delete process.env['VIS_AUTH_TOKEN']; + + expect(() => resolveVisAuthToken('0.0.0.0')).toThrow(/VIS_AUTH_TOKEN/); + + process.env['VIS_AUTH_TOKEN'] = 'secret-token'; + expect(resolveVisAuthToken('0.0.0.0')).toBe('secret-token'); + }); + + it('requires bearer auth for API routes when a token is configured', async () => { + const app = await createApp({ authToken: 'secret-token' }); + + const anonymous = await app.request('/api/sessions'); + expect(anonymous.status).toBe(401); + await expect(anonymous.json()).resolves.toMatchObject({ code: 'UNAUTHORIZED' }); + + const wrong = await app.request('/api/sessions', { + headers: { authorization: 'Bearer wrong-token' }, + }); + expect(wrong.status).toBe(401); + + const allowed = await app.request('/api/sessions', { + headers: { authorization: 'Bearer secret-token' }, + }); + expect(allowed.status).toBe(200); + }); + + it('keeps loopback-only local API access open when no token is configured', async () => { + const app = await createApp(); + + const response = await app.request('/api/sessions'); + + expect(response.status).toBe(200); + }); + + it('does not include the bearer token in the startup banner', () => { + const banner = formatStartupBanner({ + authToken: 'secret-token', + host: '127.0.0.1', + kimiCodeHome: '/tmp/kimi-code', + port: 3001, + }); + + expect(banner).toContain('auth=required'); + expect(banner).not.toContain('secret-token'); + expect(banner).not.toContain('token='); + }); +}); diff --git a/apps/vis/server/test/context-builder.test.ts b/apps/vis/server/test/context-builder.test.ts new file mode 100644 index 000000000..9823a2ccf --- /dev/null +++ b/apps/vis/server/test/context-builder.test.ts @@ -0,0 +1,242 @@ +import { afterEach, describe, expect, it } from 'vitest'; + +import { + buildAnnotatedMessages, + buildProjectedStateSummary, + extractPersistedOutputPath, + renderNotificationXml, +} from '../src/lib/context-builder'; +import type { NotificationRecord, SystemReminderRecord, VisWireRecord } from '../src/lib/types'; +import { loadWireRecords } from '../src/lib/wire-loader'; +import { createSyntheticSession, type SyntheticResult } from './_fixture'; + +describe('context-builder', () => { + let fixture: SyntheticResult | null = null; + afterEach(() => { + fixture?.cleanup(); + fixture = null; + }); + + it('extracts persisted-output path when marker is present', () => { + const text = + '<persisted-output path="/tmp/sessions/ses_foo/tool-results/call_1.txt">\nlorem ipsum\n</persisted-output>'; + expect(extractPersistedOutputPath(text)).toBe('/tmp/sessions/ses_foo/tool-results/call_1.txt'); + }); + + it('returns null when no persisted-output marker', () => { + expect(extractPersistedOutputPath('plain output')).toBeNull(); + }); + + it('renders notification XML with attributes and body', () => { + const data: NotificationRecord['data'] = { + id: 'n1', + category: 'task', + type: 'tick', + source_kind: 'background_task', + source_id: 'bg-1', + title: 'Test', + body: 'Hello', + severity: 'info', + targets: ['llm'], + }; + const xml = renderNotificationXml(data); + expect(xml).toContain('<notification id="n1"'); + expect(xml).toContain('category="task"'); + expect(xml).toContain('Title: Test'); + expect(xml).toContain('Severity: info'); + expect(xml).toContain('Hello'); + expect(xml).toContain('</notification>'); + }); + + it('includes synthetic system_reminder messages in the annotated stream', () => { + const reminder: SystemReminderRecord = { + type: 'system_reminder', + seq: 1, + time: 0, + content: 'please remember', + }; + const records: VisWireRecord[] = [reminder]; + const annotated = buildAnnotatedMessages(records); + expect(annotated).toHaveLength(1); + const m = annotated[0]; + expect(m?.is_ephemeral).toBe(true); + expect(m?.origin.kind).toBe('system_reminder'); + const content = m?.message.content[0]; + if (content !== undefined && 'text' in content) { + expect(String(content['text'])).toContain('<system-reminder>'); + } + }); + + it('includes llm-target notifications as ephemeral messages', () => { + const notif: NotificationRecord = { + type: 'notification', + seq: 2, + time: 0, + data: { + id: 'n2', + category: 'system', + type: 'info', + source_kind: 'system', + source_id: 's', + title: 't', + body: 'b', + severity: 'warning', + targets: ['llm'], + }, + }; + const annotated = buildAnnotatedMessages([notif]); + expect(annotated).toHaveLength(1); + const m = annotated[0]; + expect(m?.origin.kind).toBe('notification'); + if (m?.origin.kind === 'notification') { + expect(m.origin.severity).toBe('warning'); + } + expect(m?.is_ephemeral).toBe(true); + }); + + it('drops notifications without llm in targets', () => { + const notif: NotificationRecord = { + type: 'notification', + seq: 2, + time: 0, + data: { + id: 'n3', + category: 'system', + type: 'info', + source_kind: 's', + source_id: 's', + title: 't', + body: 'b', + severity: 'info', + targets: ['wire'], + }, + }; + const annotated = buildAnnotatedMessages([notif]); + expect(annotated).toHaveLength(0); + }); + + it('drops notifications whose delivered_at.llm === 0', () => { + const notif: NotificationRecord = { + type: 'notification', + seq: 2, + time: 0, + data: { + id: 'n4', + category: 'system', + type: 'info', + source_kind: 's', + source_id: 's', + title: 't', + body: 'b', + severity: 'info', + targets: ['llm'], + delivered_at: { llm: 0 }, + }, + }; + const annotated = buildAnnotatedMessages([notif]); + expect(annotated).toHaveLength(0); + }); + + it('marks rewind-orphaned messages as out_of_context', () => { + const records: VisWireRecord[] = [ + { + type: 'turn_begin', + seq: 1, + time: 0, + turn_id: 't1', + agent_type: 'main', + input_kind: 'user', + }, + { type: 'user_message', seq: 2, time: 0, turn_id: 't1', content: 'hi' }, + { + type: 'turn_begin', + seq: 3, + time: 0, + turn_id: 't2', + agent_type: 'main', + input_kind: 'user', + }, + { type: 'user_message', seq: 4, time: 0, turn_id: 't2', content: 'again' }, + { type: 'context_edit', seq: 5, time: 0, operation: 'rewind', to_turn: 1 }, + ]; + const annotated = buildAnnotatedMessages(records); + expect(annotated).toHaveLength(2); + expect(annotated[0]?.out_of_context).toBe(false); + expect(annotated[1]?.out_of_context).toBe(true); + }); + + it('resets messages on context_cleared', () => { + const records: VisWireRecord[] = [ + { + type: 'turn_begin', + seq: 1, + time: 0, + turn_id: 't1', + agent_type: 'main', + input_kind: 'user', + }, + { type: 'user_message', seq: 2, time: 0, turn_id: 't1', content: 'hi' }, + { type: 'context_cleared', seq: 3, time: 0 }, + { type: 'user_message', seq: 4, time: 0, turn_id: 't1', content: 'after' }, + ]; + const annotated = buildAnnotatedMessages(records); + expect(annotated).toHaveLength(1); + const content = annotated[0]?.message.content[0]; + if (content !== undefined && 'text' in content) { + expect(content['text']).toBe('after'); + } + }); + + it('sets persisted_output_path when tool output references a file', () => { + const records: VisWireRecord[] = [ + { + type: 'tool_result', + seq: 1, + time: 0, + turn_id: 't', + tool_call_id: 'Agent:1', + output: + '<persisted-output path="/tmp/ses_x/tool-results/Agent:1.txt">\npreview...\n</persisted-output>', + }, + ]; + const annotated = buildAnnotatedMessages(records); + expect(annotated[0]?.persisted_output_path).toBe('/tmp/ses_x/tool-results/Agent:1.txt'); + }); + + it('coalesces step_begin / content_part / tool_call / step_end into one assistant message', async () => { + fixture = createSyntheticSession({ withTools: true }); + const load = await loadWireRecords(fixture.dir); + const annotated = buildAnnotatedMessages(load.records); + const asst = annotated.filter((m) => m.origin.kind === 'assistant'); + expect(asst.length).toBe(1); + const m = asst[0]; + const text = m?.message.content.find((p) => p.type === 'text') as + | { type: 'text'; text: string } + | undefined; + expect(text?.text).toBe('hello!'); + expect(m?.message.tool_calls).toHaveLength(1); + expect(m?.message.tool_calls[0]?.function.name).toBe('Bash'); + }); + + it('builds correct origin tagging on a synthetic session with system_reminder', async () => { + fixture = createSyntheticSession({ withReminder: true }); + const load = await loadWireRecords(fixture.dir); + const annotated = buildAnnotatedMessages(load.records); + const hasReminder = annotated.some((m) => m.origin.kind === 'system_reminder'); + expect(hasReminder).toBe(true); + const hasUser = annotated.some((m) => m.origin.kind === 'user'); + const hasAssistant = annotated.some((m) => m.origin.kind === 'assistant'); + expect(hasUser).toBe(true); + expect(hasAssistant).toBe(true); + }); + + it('produces a projected state summary from a synthetic session', async () => { + fixture = createSyntheticSession({ withTools: true }); + const load = await loadWireRecords(fixture.dir); + const projected = buildProjectedStateSummary(load.records, load.session_initialized); + expect(typeof projected.last_seq).toBe('number'); + expect(typeof projected.token_count).toBe('number'); + expect(Array.isArray(projected.active_tools)).toBe(true); + expect(projected.model).toBe('test-model'); + }); +}); diff --git a/apps/vis/server/test/session-lister.test.ts b/apps/vis/server/test/session-lister.test.ts new file mode 100644 index 000000000..e2123e952 --- /dev/null +++ b/apps/vis/server/test/session-lister.test.ts @@ -0,0 +1,116 @@ +import { randomBytes } from 'node:crypto'; +import { mkdirSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { afterEach, describe, expect, it } from 'vitest'; + +import { listSessions, loadSessionSummary } from '../src/lib/session-lister'; +import { createSyntheticSession, type SyntheticResult } from './_fixture'; + +describe('session-lister', () => { + let fixture: SyntheticResult | null = null; + const fixtureRoots: string[] = []; + + afterEach(() => { + fixture?.cleanup(); + fixture = null; + // Cleanup enumeration roots from the second test + for (const root of fixtureRoots) { + try { + rmSync(root, { recursive: true, force: true }); + } catch { + /* ignore */ + } + } + fixtureRoots.length = 0; + }); + + it('loads a single SessionSummary from a synthetic session dir', async () => { + fixture = createSyntheticSession({ withSubagent: true }); + const summary = await loadSessionSummary(fixture.dir); + expect(summary.session_id).toBe(fixture.sessionId); + expect(summary.model).toBe('test-model'); + expect(summary.workspace_dir).toBe('/tmp'); + expect(summary.subagent_count).toBe(1); + expect(summary.wire_protocol_version).toBe('1.0'); + expect(summary.wire_record_count).toBeGreaterThan(0); + expect(summary.health).toBe('ok'); + }); + + it('enumerates session_* subdirs and sorts by updated_at desc', async () => { + const root = join(tmpdir(), `vis-list-${randomBytes(4).toString('hex')}`); + mkdirSync(root, { recursive: true }); + fixtureRoots.push(root); + + // Seed three synthetic sessions with staggered updated_at + for (let i = 0; i < 3; i += 1) { + const sessionId = `session_${randomBytes(6).toString('hex')}`; + const dir = join(root, sessionId); + mkdirSync(dir, { recursive: true }); + writeFileSync( + join(dir, 'wire.jsonl'), + JSON.stringify({ + type: 'metadata', + protocol_version: '1.0', + created_at: Date.now(), + producer: { kind: 'typescript', name: '@moonshot-ai/agent-core', version: '0.0.1' }, + }) + '\n', + ); + writeFileSync( + join(dir, 'state.json'), + JSON.stringify({ + session_id: sessionId, + model: 'test-model', + status: 'idle', + created_at: Date.now() - i * 1000, + updated_at: Date.now() - i * 1000, + workspace_dir: '/tmp', + producer: { kind: 'typescript', name: '@moonshot-ai/agent-core', version: '0.0.1' }, + }), + ); + } + + // Drop a non-session_* entry to verify it is ignored + writeFileSync(join(root, 'random-file.txt'), 'x'); + + // Drop a session_* dir with a non-TS producer — must be filtered out + const pySessionId = `session_${randomBytes(6).toString('hex')}`; + const pyDir = join(root, pySessionId); + mkdirSync(pyDir, { recursive: true }); + writeFileSync(join(pyDir, 'wire.jsonl'), '{}\n'); + writeFileSync( + join(pyDir, 'state.json'), + JSON.stringify({ + session_id: pySessionId, + created_at: Date.now(), + updated_at: Date.now(), + producer: { kind: 'python', name: 'kimi-cli', version: '0.9.0' }, + }), + ); + + // Drop a session_* dir with no state.json — must be filtered out + const oldSessionId = `session_${randomBytes(6).toString('hex')}`; + const oldDir = join(root, oldSessionId); + mkdirSync(oldDir, { recursive: true }); + writeFileSync(join(oldDir, 'wire.jsonl'), '{}\n'); + + const summaries = await listSessions(root); + expect(summaries.length).toBe(3); + // None of the filtered sessions should have leaked through. + expect(summaries.some((s) => s.session_id === pySessionId)).toBe(false); + expect(summaries.some((s) => s.session_id === oldSessionId)).toBe(false); + for (const s of summaries) { + expect(s.session_id.startsWith('session_')).toBe(true); + } + for (let i = 1; i < summaries.length; i += 1) { + const prev = summaries[i - 1]; + const cur = summaries[i]; + if (prev === undefined || cur === undefined) continue; + expect(prev.updated_at).toBeGreaterThanOrEqual(cur.updated_at); + } + for (const s of summaries) { + expect(['ok', 'broken', 'missing_wire']).toContain(s.health); + } + }); +}); diff --git a/apps/vis/server/test/subagent-loader.test.ts b/apps/vis/server/test/subagent-loader.test.ts new file mode 100644 index 000000000..978e621a8 --- /dev/null +++ b/apps/vis/server/test/subagent-loader.test.ts @@ -0,0 +1,47 @@ +import { afterEach, describe, expect, it } from 'vitest'; + +import { buildSubagentTree, listSubagents } from '../src/lib/subagent-loader'; +import { loadWireRecords } from '../src/lib/wire-loader'; +import { createSyntheticSession, type SyntheticResult } from './_fixture'; + +describe('subagent-loader', () => { + let fixture: SyntheticResult | null = null; + afterEach(() => { + fixture?.cleanup(); + fixture = null; + }); + + it('lists subagent ids for a session with subagents', async () => { + fixture = createSyntheticSession({ withSubagent: true }); + const ids = await listSubagents(fixture.dir); + expect(ids.length).toBe(1); + expect(ids[0]?.startsWith('sub_')).toBe(true); + }); + + it('builds a tree rooted at main', async () => { + fixture = createSyntheticSession({ withSubagent: true }); + const main = await loadWireRecords(fixture.dir); + const tree = await buildSubagentTree(fixture.dir, main.records); + expect(tree.length).toBe(1); + const node = tree[0]; + expect(node?.depth).toBe(0); + expect(node?.agent_id.startsWith('sub_')).toBe(true); + expect(node?.parent_agent_id).toBeNull(); + }); + + it('pairs spawn with completed lifecycle records', async () => { + fixture = createSyntheticSession({ withSubagent: true }); + const main = await loadWireRecords(fixture.dir); + const tree = await buildSubagentTree(fixture.dir, main.records); + const node = tree[0]; + expect(node?.status).toBe('completed'); + expect(node?.result_summary).toBe('all done'); + }); + + it('returns empty tree for sessions without subagents', async () => { + fixture = createSyntheticSession(); + const main = await loadWireRecords(fixture.dir); + const tree = await buildSubagentTree(fixture.dir, main.records); + expect(tree).toHaveLength(0); + }); +}); diff --git a/apps/vis/server/test/wire-loader.test.ts b/apps/vis/server/test/wire-loader.test.ts new file mode 100644 index 000000000..c1b3357d2 --- /dev/null +++ b/apps/vis/server/test/wire-loader.test.ts @@ -0,0 +1,558 @@ +import { writeFileSync } from 'node:fs'; +import { join } from 'node:path'; + +import { describe, expect, it, afterEach } from 'vitest'; + +import { listWireFilesManually, loadWireRecords } from '../src/lib/wire-loader'; +import { createSyntheticSession, type SyntheticResult } from './_fixture'; + +describe('wire-loader', () => { + let fixture: SyntheticResult | null = null; + afterEach(() => { + fixture?.cleanup(); + fixture = null; + }); + + it('lists wire.jsonl after any archives, current file last', async () => { + fixture = createSyntheticSession(); + const files = await listWireFilesManually(fixture.dir); + expect(files.length).toBeGreaterThan(0); + expect((files.at(-1) ?? '').endsWith('wire.jsonl')).toBe(true); + }); + + it('loads records from a synthetic session', async () => { + fixture = createSyntheticSession({ withTools: true }); + const result = await loadWireRecords(fixture.dir); + expect(result.health).toBe('ok'); + expect(result.records.length).toBeGreaterThan(0); + expect(result.files_read.length).toBe(1); + expect(result.session_initialized).not.toBeNull(); + expect(result.session_initialized?.agent_type).toBe('main'); + }); + + it('yields seq-ordered records', async () => { + fixture = createSyntheticSession({ withTools: true }); + const result = await loadWireRecords(fixture.dir); + for (let i = 1; i < result.records.length; i += 1) { + const prev = result.records[i - 1]; + const cur = result.records[i]; + if (prev === undefined || cur === undefined) continue; + expect(cur.seq).toBeGreaterThanOrEqual(prev.seq); + } + }); + + it('splices metadata + session_initialized into records so the Wire tab can show them', async () => { + fixture = createSyntheticSession(); + const result = await loadWireRecords(fixture.dir); + expect(result.health).toBe('ok'); + // metadata comes first, session_initialized second. + expect(result.records[0]?.type).toBe('metadata'); + expect(result.records[1]?.type).toBe('session_initialized'); + const meta = result.records[0] as { + protocol_version?: string; + producer?: { kind?: string }; + }; + expect(meta.protocol_version).toBe('1.0'); + expect(meta.producer?.kind).toBe('typescript'); + const init = result.records[1] as { system_prompt?: string }; + expect(init.system_prompt).toBe('You are a test assistant.'); + }); + + it('returns health=broken with a message for a non-existent dir', async () => { + const result = await loadWireRecords('/tmp/vis-test-does-not-exist-xxxxx'); + expect(result.health).toBe('broken'); + expect(result.records.length).toBe(0); + expect(result.broken_reason).toBeTruthy(); + expect(result.session_initialized).toBeNull(); + }); + + it('treats idle turn.steer as a turn start without splitting active steer', async () => { + fixture = createSyntheticSession(); + writeWire(fixture.dir, [ + { + type: 'metadata', + protocol_version: '1.0', + created_at: 1, + producer: { + kind: 'typescript', + name: '@moonshot-ai/agent-core', + version: '0.0.1', + }, + }, + { + type: 'session_initialized', + seq: 1, + time: 1, + agent_type: 'main', + session_id: fixture.sessionId, + system_prompt: 'You are a test assistant.', + model: 'test-model', + active_tools: [], + }, + { + type: 'turn.prompt', + seq: 2, + time: 2, + input: [{ type: 'text', text: 'first' }], + }, + { + type: 'context.user_message', + seq: 3, + time: 3, + content: [{ type: 'text', text: 'first' }], + }, + { + type: 'context.delta', + seq: 4, + time: 4, + event: { type: 'step.begin', uuid: 'step-1', turnId: '0', step: 1 }, + }, + { + type: 'turn.steer', + seq: 5, + time: 5, + input: [{ type: 'text', text: 'active steer' }], + }, + { + type: 'context.delta', + seq: 6, + time: 6, + event: { type: 'step.end', uuid: 'step-1', turnId: '0', step: 1, finishReason: 'tool_use' }, + }, + { + type: 'context.user_message', + seq: 7, + time: 7, + content: [{ type: 'text', text: 'active steer' }], + }, + { + type: 'context.delta', + seq: 8, + time: 8, + event: { type: 'step.begin', uuid: 'step-2', turnId: '0', step: 2 }, + }, + { + type: 'context.delta', + seq: 9, + time: 9, + event: { type: 'step.end', uuid: 'step-2', turnId: '0', step: 2, finishReason: 'end_turn' }, + }, + { + type: 'turn.steer', + seq: 10, + time: 10, + input: [{ type: 'text', text: 'terminal gap steer' }], + }, + { + type: 'context.user_message', + seq: 11, + time: 11, + content: [{ type: 'text', text: 'terminal gap steer' }], + }, + { + type: 'context.delta', + seq: 12, + time: 12, + event: { type: 'step.begin', uuid: 'step-3', turnId: '0', step: 3 }, + }, + { + type: 'context.delta', + seq: 13, + time: 13, + event: { type: 'step.end', uuid: 'step-3', turnId: '0', step: 3, finishReason: 'end_turn' }, + }, + { + type: 'turn.steer', + seq: 14, + time: 14, + input: [{ type: 'text', text: 'idle steer' }], + }, + { + type: 'context.user_message', + seq: 15, + time: 15, + content: [{ type: 'text', text: 'idle steer' }], + }, + { + type: 'context.delta', + seq: 16, + time: 16, + event: { type: 'step.begin', uuid: 'step-4', turnId: '1', step: 1 }, + }, + { + type: 'context.delta', + seq: 17, + time: 17, + event: { + type: 'tool.result', + parentUuid: 'tool-1', + toolCallId: 'tool-1', + result: { output: 'idle result' }, + }, + }, + ]); + + const result = await loadWireRecords(fixture.dir); + const turnBegins = result.records.filter((record) => record.type === 'turn_begin'); + const userMessages = result.records.filter((record) => record.type === 'user_message'); + const toolResult = result.records.find((record) => record.type === 'tool_result'); + + expect(turnBegins.map((record) => [record.turn_id, record.user_input])).toEqual([ + ['0', 'first'], + ['1', 'idle steer'], + ]); + expect(userMessages.map((record) => record.turn_id)).toEqual(['0', '0', '0', '1']); + expect(toolResult).toMatchObject({ turn_id: '1' }); + }); + + it('treats idle turn.steer after a failed pre-step turn as a new turn', async () => { + fixture = createSyntheticSession(); + writeWire(fixture.dir, [ + { + type: 'metadata', + protocol_version: '1.0', + created_at: 1, + producer: { + kind: 'typescript', + name: '@moonshot-ai/agent-core', + version: '0.0.1', + }, + }, + { + type: 'session_initialized', + seq: 1, + time: 1, + agent_type: 'main', + session_id: fixture.sessionId, + system_prompt: 'You are a test assistant.', + active_tools: [], + }, + { + type: 'turn.prompt', + seq: 2, + time: 2, + input: [{ type: 'text', text: 'missing model' }], + }, + { + type: 'context.user_message', + seq: 3, + time: 3, + content: [{ type: 'text', text: 'missing model' }], + }, + { + type: 'turn.steer', + seq: 4, + time: 4, + input: [{ type: 'text', text: 'after failure' }], + }, + { + type: 'context.user_message', + seq: 5, + time: 5, + content: [{ type: 'text', text: 'after failure' }], + }, + { + type: 'context.delta', + seq: 6, + time: 6, + event: { type: 'step.begin', uuid: 'step-1', turnId: '1', step: 1 }, + }, + ]); + + const result = await loadWireRecords(fixture.dir); + const turnBegins = result.records.filter((record) => record.type === 'turn_begin'); + const userMessages = result.records.filter((record) => record.type === 'user_message'); + + expect(turnBegins.map((record) => [record.turn_id, record.user_input])).toEqual([ + ['0', 'missing model'], + ['1', 'after failure'], + ]); + expect(userMessages.map((record) => record.turn_id)).toEqual(['0', '1']); + }); + it('unwraps the newer dot-namespaced record names (append_loop_event, append_message, set_active_tools)', async () => { + fixture = createSyntheticSession(); + writeWire(fixture.dir, [ + { + type: 'metadata', + protocol_version: '1.0', + created_at: 1, + producer: { kind: 'typescript', name: '@moonshot-ai/core', version: '0.0.1' }, + }, + { + type: 'session_initialized', + seq: 1, + time: 1, + agent_type: 'main', + session_id: fixture.sessionId, + system_prompt: 'You are a test assistant.', + active_tools: [], + }, + { + type: 'turn.prompt', + seq: 2, + time: 2, + input: [{ type: 'text', text: 'hi' }], + }, + { + type: 'context.append_message', + seq: 3, + time: 3, + message: { role: 'user', content: [{ type: 'text', text: 'hi' }] }, + }, + { + type: 'tools.set_active_tools', + seq: 4, + time: 4, + names: ['Read', 'Write', 'Bash'], + }, + { + type: 'context.append_loop_event', + seq: 5, + time: 5, + event: { type: 'step.begin', uuid: 'step-1', turnId: '0', step: 1 }, + }, + { + type: 'context.append_loop_event', + seq: 6, + time: 6, + event: { + type: 'tool.call', + uuid: 'tc-1', + turnId: '0', + step: 1, + stepUuid: 'step-1', + toolCallId: 'call-1', + name: 'Write', + args: { path: '/tmp/x', content: 'hello' }, + }, + }, + { + type: 'context.append_loop_event', + seq: 7, + time: 7, + event: { type: 'step.end', uuid: 'step-1', turnId: '0', step: 1, finishReason: 'tool_use' }, + }, + ]); + + const result = await loadWireRecords(fixture.dir); + expect(result.health).toBe('ok'); + const types = result.records.map((r) => r.type); + expect(types).toContain('turn_begin'); + expect(types).toContain('user_message'); + expect(types).toContain('tools_changed'); + expect(types).toContain('step_begin'); + expect(types).toContain('tool_call'); + expect(types).toContain('step_end'); + expect(types).not.toContain('notification'); + + const toolCall = result.records.find((r) => r.type === 'tool_call'); + expect(toolCall).toMatchObject({ turn_id: '0', data: { tool_name: 'Write' } }); + const toolsChanged = result.records.find((r) => r.type === 'tools_changed'); + expect(toolsChanged).toMatchObject({ operation: 'set_active', tools: ['Read', 'Write', 'Bash'] }); + }); + + // The newer `context.append_message` producer carries assistant / + // system roles too. Without a role filter, inferSteerStartedTurnId + // would see any append_message in the lookahead window and conclude + // that the steer launched a fresh turn, fabricating a turn id. + it('ignores non-user append_message when inferring whether a turn.steer launched a fresh turn', async () => { + fixture = createSyntheticSession(); + writeWire(fixture.dir, [ + { + type: 'metadata', + protocol_version: '1.0', + created_at: 1, + producer: { kind: 'typescript', name: '@moonshot-ai/core', version: '0.0.1' }, + }, + { + type: 'session_initialized', + seq: 1, + time: 1, + agent_type: 'main', + session_id: fixture.sessionId, + system_prompt: 'You are a test assistant.', + active_tools: [], + }, + { + type: 'turn.prompt', + seq: 2, + time: 2, + input: [{ type: 'text', text: 'first' }], + }, + { + type: 'context.append_message', + seq: 3, + time: 3, + message: { role: 'user', content: [{ type: 'text', text: 'first' }] }, + }, + { + type: 'context.append_loop_event', + seq: 4, + time: 4, + event: { type: 'step.begin', uuid: 'step-1', turnId: '0', step: 1 }, + }, + // Steer arrives during an active turn (no later loop event reveals + // a new turnId, no later user message follows). Only an assistant + // append_message lands in the lookahead window. + { + type: 'turn.steer', + seq: 5, + time: 5, + input: [{ type: 'text', text: 'buffered steer' }], + }, + { + type: 'context.append_message', + seq: 6, + time: 6, + message: { role: 'assistant', content: [{ type: 'text', text: 'thinking out loud' }] }, + }, + ]); + + const result = await loadWireRecords(fixture.dir); + const turnBegins = result.records.filter((record) => record.type === 'turn_begin'); + // Only the original turn.prompt should produce a turn_begin. The + // steer must stay buffered because the lookahead contains no user + // message — assistant append_messages don't count. + expect(turnBegins.map((record) => record.turn_id)).toEqual(['0']); + }); + + // ContextMemory.appendSystemReminder() persists injected reminders / + // skill activations / system triggers as role: 'user' messages for the + // LLM, but their origin.kind is not 'user'. Replay must not surface + // these as user_message records or count them when inferring whether + // a turn.steer launched a fresh turn. + it('does not surface non-user-origin append_message as user_message', async () => { + fixture = createSyntheticSession(); + writeWire(fixture.dir, [ + { + type: 'metadata', + protocol_version: '1.0', + created_at: 1, + producer: { kind: 'typescript', name: '@moonshot-ai/core', version: '0.0.1' }, + }, + { + type: 'session_initialized', + seq: 1, + time: 1, + agent_type: 'main', + session_id: fixture.sessionId, + system_prompt: 'You are a test assistant.', + active_tools: [], + }, + { + type: 'turn.prompt', + seq: 2, + time: 2, + input: [{ type: 'text', text: 'hi' }], + }, + { + type: 'context.append_message', + seq: 3, + time: 3, + message: { + role: 'user', + content: [{ type: 'text', text: 'hi' }], + origin: { kind: 'user' }, + }, + }, + // System reminder: role: 'user' for the LLM, but origin.kind says + // it's an injection, not a real user message. + { + type: 'context.append_message', + seq: 4, + time: 4, + message: { + role: 'user', + content: [{ type: 'text', text: '<system-reminder>be careful</system-reminder>' }], + origin: { kind: 'injection', variant: 'safety' }, + }, + }, + // Skill activation payload: also role: 'user' under the hood. + { + type: 'context.append_message', + seq: 5, + time: 5, + message: { + role: 'user', + content: [{ type: 'text', text: 'skill bootstrap' }], + origin: { kind: 'skill_activation', activationId: 'a1', skillName: 'plan', trigger: 'user-slash' }, + }, + }, + ]); + + const result = await loadWireRecords(fixture.dir); + const userMessages = result.records.filter((record) => record.type === 'user_message'); + // Only the real user message at seq 3 should surface — the injection + // and skill_activation entries must not show as user_message. + expect(userMessages.map((record) => record.seq)).toEqual([3]); + }); + + it('ignores non-user-origin append_message when inferring whether a turn.steer launched a fresh turn', async () => { + fixture = createSyntheticSession(); + writeWire(fixture.dir, [ + { + type: 'metadata', + protocol_version: '1.0', + created_at: 1, + producer: { kind: 'typescript', name: '@moonshot-ai/core', version: '0.0.1' }, + }, + { + type: 'session_initialized', + seq: 1, + time: 1, + agent_type: 'main', + session_id: fixture.sessionId, + system_prompt: 'You are a test assistant.', + active_tools: [], + }, + { + type: 'turn.prompt', + seq: 2, + time: 2, + input: [{ type: 'text', text: 'first' }], + }, + { + type: 'context.append_message', + seq: 3, + time: 3, + message: { role: 'user', content: [{ type: 'text', text: 'first' }], origin: { kind: 'user' } }, + }, + { + type: 'context.append_loop_event', + seq: 4, + time: 4, + event: { type: 'step.begin', uuid: 'step-1', turnId: '0', step: 1 }, + }, + { + type: 'turn.steer', + seq: 5, + time: 5, + input: [{ type: 'text', text: 'buffered steer' }], + }, + // Injected system reminder lands in the lookahead window. It has + // role: 'user' but origin.kind: 'system_trigger', so it must NOT + // count as evidence of a user-launched fresh turn. + { + type: 'context.append_message', + seq: 6, + time: 6, + message: { + role: 'user', + content: [{ type: 'text', text: '<system-reminder>focus</system-reminder>' }], + origin: { kind: 'system_trigger', name: 'focus-reminder' }, + }, + }, + ]); + + const result = await loadWireRecords(fixture.dir); + const turnBegins = result.records.filter((record) => record.type === 'turn_begin'); + expect(turnBegins.map((record) => record.turn_id)).toEqual(['0']); + }); +}); + +function writeWire(dir: string, records: readonly Record<string, unknown>[]): void { + writeFileSync( + join(dir, 'wire.jsonl'), + `${records.map((record) => JSON.stringify(record)).join('\n')}\n`, + ); +} diff --git a/apps/vis/server/tsconfig.json b/apps/vis/server/tsconfig.json new file mode 100644 index 000000000..1d7a4c2a9 --- /dev/null +++ b/apps/vis/server/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../../../tsconfig.json", + "include": ["src", "test"] +} diff --git a/apps/vis/server/tsdown.config.ts b/apps/vis/server/tsdown.config.ts new file mode 100644 index 000000000..6e618ca51 --- /dev/null +++ b/apps/vis/server/tsdown.config.ts @@ -0,0 +1,9 @@ +import { defineConfig } from 'tsdown'; + +export default defineConfig({ + entry: { server: 'src/index.ts' }, + format: ['esm'], + outDir: 'dist', + clean: true, + external: ['@moonshot-ai/agent-core', '@moonshot-ai/kosong', '@moonshot-ai/kaos'], +}); diff --git a/apps/vis/server/vitest.config.ts b/apps/vis/server/vitest.config.ts new file mode 100644 index 000000000..0131797f1 --- /dev/null +++ b/apps/vis/server/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + name: 'vis-server', + include: ['test/**/*.test.ts'], + }, +}); diff --git a/apps/vis/web/index.html b/apps/vis/web/index.html new file mode 100644 index 000000000..070aafc9b --- /dev/null +++ b/apps/vis/web/index.html @@ -0,0 +1,39 @@ +<!doctype html> +<html lang="en"> + <head> + <meta charset="UTF-8" /> + <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" /> + <meta name="color-scheme" content="light dark" /> + <meta name="theme-color" content="#0b0d12" /> + <title>kimi vis + + + + + + + +
+ + + diff --git a/apps/vis/web/package.json b/apps/vis/web/package.json new file mode 100644 index 000000000..2146e568f --- /dev/null +++ b/apps/vis/web/package.json @@ -0,0 +1,39 @@ +{ + "name": "@moonshot-ai/vis-web", + "version": "0.1.1", + "private": true, + "license": "MIT", + "type": "module", + "imports": { + "#/*": { + "types": [ + "./src/*.ts", + "./src/*.tsx", + "./src/*/index.ts", + "./src/*/index.tsx" + ], + "default": "./src/*" + } + }, + "scripts": { + "dev": "vite", + "build": "vite build", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@tanstack/react-query": "^5.74.4", + "@tanstack/react-virtual": "^3.13.5", + "react": "^19.1.0", + "react-dom": "^19.1.0", + "react-router-dom": "^7.5.2" + }, + "devDependencies": { + "@tailwindcss/vite": "^4.1.4", + "@types/react": "^19.1.2", + "@types/react-dom": "^19.1.2", + "@vitejs/plugin-react": "^4.4.1", + "tailwindcss": "^4.1.4", + "typescript": "6.0.2", + "vite": "^6.3.3" + } +} diff --git a/apps/vis/web/src/App.tsx b/apps/vis/web/src/App.tsx new file mode 100644 index 000000000..4843b677c --- /dev/null +++ b/apps/vis/web/src/App.tsx @@ -0,0 +1,20 @@ +import { Route, Routes } from 'react-router-dom'; +import { AppShell } from './components/layout/AppShell'; +import { SessionListPage } from './pages/SessionListPage'; +import { SessionDetailPage } from './pages/SessionDetailPage'; +import { SubagentDetailPage } from './pages/SubagentDetailPage'; + +export function App() { + return ( + + + } /> + } /> + } + /> + + + ); +} diff --git a/apps/vis/web/src/api.ts b/apps/vis/web/src/api.ts new file mode 100644 index 000000000..66346694a --- /dev/null +++ b/apps/vis/web/src/api.ts @@ -0,0 +1,119 @@ +import type { + SessionSummary, + SessionDetail, + WireResponse, + ContextResponse, + SubagentTreeResponse, + SubagentMetaResponse, + ToolResultFileResponse, + DeleteSessionResponse, + ClearSessionsResponse, + ApiError, +} from './types'; + +const TOKEN_STORAGE_KEY = 'kimi-vis-auth-token'; + +function readTokenParam(raw: string): string | null { + const trimmed = raw.replace(/^[#?]/, ''); + if (trimmed.length === 0) return null; + const params = new URLSearchParams(trimmed); + return params.get('token') ?? params.get('vis_token'); +} + +function deleteTokenParams(params: URLSearchParams): boolean { + const hadToken = params.has('token') || params.has('vis_token'); + params.delete('token'); + params.delete('vis_token'); + return hadToken; +} + +function scrubTokenFromUrl(): void { + const url = new URL(window.location.href); + const changedSearch = deleteTokenParams(url.searchParams); + const hash = url.hash.replace(/^#/, ''); + let changedHash = false; + if (hash.length > 0) { + const hashParams = new URLSearchParams(hash); + changedHash = deleteTokenParams(hashParams); + if (changedHash) { + const nextHash = hashParams.toString(); + url.hash = nextHash.length > 0 ? nextHash : ''; + } + } + if (changedSearch || changedHash) { + window.history.replaceState(null, '', url.toString()); + } +} + +function authToken(): string | null { + if (typeof window === 'undefined') return null; + const fromHash = readTokenParam(window.location.hash); + const fromSearch = readTokenParam(window.location.search); + const token = fromHash ?? fromSearch; + if (token !== null && token.length > 0) { + window.localStorage.setItem(TOKEN_STORAGE_KEY, token); + scrubTokenFromUrl(); + return token; + } + return window.localStorage.getItem(TOKEN_STORAGE_KEY); +} + +async function request(path: string, method: 'GET' | 'DELETE'): Promise { + const headers: Record = { accept: 'application/json' }; + const token = authToken(); + if (token !== null && token.length > 0) { + headers['authorization'] = `Bearer ${token}`; + } + const res = await fetch(path, { method, headers }); + if (!res.ok) { + let err: ApiError | null = null; + try { + err = (await res.json()) as ApiError; + } catch { + /* ignore */ + } + throw new Error(err?.error ?? `HTTP ${res.status} ${res.statusText}`); + } + return (await res.json()) as T; +} + +function get(path: string): Promise { + return request(path, 'GET'); +} + +function del(path: string): Promise { + return request(path, 'DELETE'); +} + +const enc = encodeURIComponent; + +export const api = { + listSessions: () => get('/api/sessions'), + + getSession: (id: string) => get(`/api/sessions/${enc(id)}`), + + deleteSession: (id: string) => del(`/api/sessions/${enc(id)}`), + + clearSessions: () => del('/api/sessions'), + + getWire: (id: string) => get(`/api/sessions/${enc(id)}/wire`), + + getContext: (id: string) => get(`/api/sessions/${enc(id)}/context`), + + getSubagents: (id: string) => get(`/api/sessions/${enc(id)}/subagents`), + + getSubagentWire: (id: string, agentId: string) => + get(`/api/sessions/${enc(id)}/subagents/${enc(agentId)}/wire`), + + getSubagentContext: (id: string, agentId: string) => + get(`/api/sessions/${enc(id)}/subagents/${enc(agentId)}/context`), + + getSubagentMeta: (id: string, agentId: string) => + get(`/api/sessions/${enc(id)}/subagents/${enc(agentId)}/meta`), + + getToolResult: (id: string, toolCallId: string) => + get(`/api/sessions/${enc(id)}/tool-results/${enc(toolCallId)}`), + + getArchive: (id: string, filename: string) => + get(`/api/sessions/${enc(id)}/archives/${enc(filename)}`), +}; diff --git a/apps/vis/web/src/components/context/CompactionRibbon.tsx b/apps/vis/web/src/components/context/CompactionRibbon.tsx new file mode 100644 index 000000000..ca0a0f8cb --- /dev/null +++ b/apps/vis/web/src/components/context/CompactionRibbon.tsx @@ -0,0 +1,28 @@ +interface CompactionRibbonProps { + summary: string; + seq: number; +} + +/** + * Horizontal ribbon marker where a compaction occurred in the message stream. + * Derivation is best-effort from the annotated message stream; if a compaction + * was applied, buildAnnotatedMessages emits a summary assistant message. We + * detect the prior "break" via seq discontinuity, but for now we render a + * stand-alone banner wherever the caller places it. + */ +export function CompactionRibbon({ summary, seq }: CompactionRibbonProps) { + return ( +
+ + + ⏪ compacted · seq {seq} + + + {summary ? ( +
+
{summary}
+
+ ) : null} +
+ ); +} diff --git a/apps/vis/web/src/components/context/ContextTab.tsx b/apps/vis/web/src/components/context/ContextTab.tsx new file mode 100644 index 000000000..cedb1fcfd --- /dev/null +++ b/apps/vis/web/src/components/context/ContextTab.tsx @@ -0,0 +1,281 @@ +import { useState } from 'react'; + +import type { AnnotatedMessage, ProjectedStateSummary } from '../../types'; +import { Pill } from '../shared/Pill'; +import { formatBytes } from '../shared/SizePreview'; +import { EphemeralBubble } from './EphemeralBubble'; +import { MessageBubble } from './MessageBubble'; + +interface ContextTabProps { + sessionId: string; + messages: AnnotatedMessage[]; + projectedState: ProjectedStateSummary; +} + +export function ContextTab({ sessionId, messages, projectedState }: ContextTabProps) { + const [hideOutOfContext, setHideOutOfContext] = useState(false); + + const visible = hideOutOfContext ? messages.filter((m) => !m.out_of_context) : messages; + + const stats = countKinds(messages); + + return ( +
+ {/* Header strip */} +
+ {messages.length} + messages + · + model + {projectedState.model ?? 'unknown'} + · + + + + + + + + + +
+ {/* Token stacked bar — 2px hairline under the header. Replaces the + * border-b so the bar itself is the separator. */} + + + {/* Message stream */} +
+
+ + {visible.length === 0 ? ( +
+ no messages — session has only lifecycle/config records so far. +
+ ) : ( + visible.map((m) => { + if (m.is_ephemeral) { + return ; + } + return ; + }) + )} +
+
+
+ ); +} + +function SystemPromptBubble({ text }: { text: string | null }) { + const [open, setOpen] = useState(false); + const hasText = text !== null && text.length > 0; + + if (!hasText) { + return ( +
+
+ + system + + (no system prompt) +
+
+ ); + } + + return ( +
+ +
+
+          {text}
+        
+ {!open ? ( + +
+ ); +} + +function LegendDot({ color, label, count }: { color: string; label: string; count: number }) { + if (count === 0) return null; + return ( + + + {label} + {count} + + ); +} + +// ─── token breakdown (input / output / cache_read / cache_write) ─── +// Colors are chosen from the existing semantic palette so the bar reads +// coherently with the rest of the app: +// cache_read = success (saved / cached — the "good" share) +// input = info (billed input) +// output = assistant (what the model produced) +// cache_write = warning (billed once, amortised next call) + +const TOK_COLORS = { + cache_read: 'var(--color-sev-success)', + input: 'var(--color-sev-info)', + output: 'var(--color-assistant)', + cache_write: 'var(--color-sev-warning)', +} as const; + +interface TokenBreakdown { + input_tokens: number; + output_tokens: number; + cache_read_tokens: number; + cache_write_tokens: number; +} + +function formatTokens(n: number): string { + if (n < 1000) return String(n); + if (n < 10_000) return (n / 1000).toFixed(1) + 'K'; + if (n < 1_000_000) return Math.round(n / 1000) + 'K'; + return (n / 1_000_000).toFixed(1) + 'M'; +} + +function TokenDots({ breakdown }: { breakdown: TokenBreakdown }) { + const { input_tokens, output_tokens, cache_read_tokens, cache_write_tokens } = breakdown; + const billedIn = input_tokens + cache_read_tokens; + const hitPct = billedIn > 0 ? Math.round((cache_read_tokens / billedIn) * 100) : 0; + const hasAny = + input_tokens > 0 || output_tokens > 0 || cache_read_tokens > 0 || cache_write_tokens > 0; + if (!hasAny) { + return (no tokens yet); + } + return ( + + + + + {cache_write_tokens > 0 ? ( + + ) : null} + {cache_read_tokens > 0 ? ({hitPct}% hit) : null} + + ); +} + +function TokenDot({ color, label, value }: { color: string; label: string; value: number }) { + return ( + + + {formatTokens(value)} + {label} + + ); +} + +/** 2px stacked bar that visually shows the 4-way token composition. + * Proportions use (input + output + cache_read + cache_write) as the + * total so cache_read's share is honest (it's not in the "billed" + * tokenCount but it's real work done on the request). */ +function TokenBar({ breakdown }: { breakdown: TokenBreakdown }) { + const { input_tokens, output_tokens, cache_read_tokens, cache_write_tokens } = breakdown; + const total = input_tokens + output_tokens + cache_read_tokens + cache_write_tokens; + if (total === 0) { + return
; + } + const seg = (n: number) => (n / total) * 100; + return ( +
+ {cache_read_tokens > 0 ? ( +
+ ) : null} + {input_tokens > 0 ? ( +
+ ) : null} + {output_tokens > 0 ? ( +
+ ) : null} + {cache_write_tokens > 0 ? ( +
+ ) : null} +
+ ); +} + +function countKinds(messages: AnnotatedMessage[]) { + let user = 0, + assistant = 0, + tool = 0, + reminder = 0, + notif = 0; + for (const m of messages) { + switch (m.origin.kind) { + case 'user': + user++; + break; + case 'assistant': + assistant++; + break; + case 'tool': + tool++; + break; + case 'system_reminder': + reminder++; + break; + case 'notification': + notif++; + break; + } + } + return { user, assistant, tool, reminder, notif }; +} diff --git a/apps/vis/web/src/components/context/EphemeralBubble.tsx b/apps/vis/web/src/components/context/EphemeralBubble.tsx new file mode 100644 index 000000000..17a555592 --- /dev/null +++ b/apps/vis/web/src/components/context/EphemeralBubble.tsx @@ -0,0 +1,101 @@ +import type { AnnotatedMessage } from '../../types'; +import { Pill } from '../shared/Pill'; + +interface EphemeralBubbleProps { + message: AnnotatedMessage; +} + +/** + * Ephemeral-injected message (system_reminder or notification). + * Rendered with dashed border + inset — unambiguous signal that this + * is NOT a real user turn. + */ +export function EphemeralBubble({ message }: EphemeralBubbleProps) { + const { origin } = message; + if (origin.kind === 'system_reminder') { + return ; + } + if (origin.kind === 'notification') { + return ; + } + return null; +} + +function SystemReminderBubble({ m }: { m: AnnotatedMessage }) { + const text = extractText(m); + // Strip XML wrapper for display (show just the inner content) + const inner = text + .replace(/^\s*\n?/, '') + .replace(/\n?<\/system-reminder>\s*$/, ''); + + return ( +
+
+ system_reminder + + injected @ seq {m.seq} + +
+
+        {inner}
+      
+
+ ); +} + +function NotificationBubble({ m, severity }: { m: AnnotatedMessage; severity: string }) { + const text = extractText(m); + // Strip wrapper + const inner = text + .replace(/^\s*]*>\n?/, '') + .replace(/\n?<\/notification>\s*$/, ''); + const tone = + severity === 'error' + ? 'error' + : severity === 'warning' + ? 'warning' + : severity === 'success' + ? 'success' + : 'info'; + const borderClass = + severity === 'error' + ? 'ephemeral-border-error' + : severity === 'warning' + ? 'ephemeral-border-warning' + : severity === 'success' + ? 'ephemeral-border-success' + : 'ephemeral-border-info'; + + return ( +
+
+ notification + {severity} + + injected @ seq {m.seq} + +
+
+        {inner}
+      
+
+ ); +} + +function extractText(m: AnnotatedMessage): string { + const p = m.message.content.find((x) => x.type === 'text'); + return p ? ((p['text'] as string | undefined) ?? '') : ''; +} diff --git a/apps/vis/web/src/components/context/MessageBubble.tsx b/apps/vis/web/src/components/context/MessageBubble.tsx new file mode 100644 index 000000000..953ad3058 --- /dev/null +++ b/apps/vis/web/src/components/context/MessageBubble.tsx @@ -0,0 +1,193 @@ +import { useState, type ReactNode } from 'react'; +import type { AnnotatedMessage, ContentPart, ToolCallEntry } from '../../types'; +import { Pill } from '../shared/Pill'; +import { PersistedOutputLink } from './PersistedOutputLink'; + +interface MessageBubbleProps { + message: AnnotatedMessage; + sessionId: string; +} + +export function MessageBubble({ message, sessionId }: MessageBubbleProps) { + const { role } = message.message; + if (role === 'user') return ; + if (role === 'assistant') return ; + return ; +} + +function baseClass(out: boolean): string { + return [ + 'relative flex max-w-full min-w-0 flex-col border-l-[3px] bg-surface-1 px-3 py-2', + out ? 'opacity-50 line-through decoration-[var(--color-sev-error)] decoration-dashed' : '', + ].join(' '); +} + +function UserBubble({ m }: { m: AnnotatedMessage }) { + return ( +
+
+ user + seq {m.seq} + {m.out_of_context ? out-of-context : null} +
+ +
+ ); +} + +function AssistantBubble({ m }: { m: AnnotatedMessage }) { + const thinkPart = m.message.content.find((p) => p.type === 'think'); + const think = thinkPart ? (thinkPart['think'] as string | undefined) : undefined; + const textParts = m.message.content.filter((p) => p.type !== 'think'); + return ( +
+
+ assistant + seq {m.seq} + {think ? think : null} + {m.message.tool_calls.length > 0 ? ( + + {m.message.tool_calls.length} tool call{m.message.tool_calls.length > 1 ? 's' : ''} + + ) : null} + {m.out_of_context ? out-of-context : null} +
+ {think ? : null} + + {m.message.tool_calls.length > 0 ? ( +
+ {m.message.tool_calls.map((tc) => )} +
+ ) : null} +
+ ); +} + +function ToolBubble({ m, sessionId }: { m: AnnotatedMessage; sessionId: string }) { + const firstTextPart = m.message.content.find((p) => p.type === 'text'); + const text = firstTextPart ? ((firstTextPart['text'] as string | undefined) ?? '') : ''; + const hasPersisted = !!m.persisted_output_path && m.message.tool_call_id; + return ( +
+
+ tool + {m.message.tool_call_id ? ( + + call {m.message.tool_call_id.slice(0, 12)} + + ) : null} + seq {m.seq} + {m.out_of_context ? out-of-context : null} +
+ {hasPersisted ? ( + + ) : ( +
+          {text}
+        
+ )} +
+ ); +} + +function ThinkBlock({ text }: { text: string }) { + const [open, setOpen] = useState(false); + return ( +
+ + {open ? ( +
+          {text}
+        
+ ) : null} +
+ ); +} + +function ToolCallCard({ call }: { call: ToolCallEntry }) { + const [open, setOpen] = useState(false); + const argsStr = call.function.arguments ?? ''; + return ( +
+ + {open ? ( +
+          {prettyJson(argsStr)}
+        
+ ) : null} +
+ ); +} + +function MessageContent({ parts }: { parts: readonly ContentPart[] }): ReactNode { + return ( +
+ {parts.map((p, i) => { + if (p.type === 'text') { + return ( +
+              {(p['text'] as string) ?? ''}
+            
+ ); + } + if (p.type === 'image_url') { + const url = (p['image_url'] as { url?: string } | undefined)?.url; + return
[image: {url ?? '—'}]
; + } + if (p.type === 'video_url') { + const url = (p['video_url'] as { url?: string } | undefined)?.url; + return
[video: {url ?? '—'}]
; + } + return ( +
+ [{p.type}] +
+ ); + })} +
+ ); +} + +function truncate(s: string, n: number): string { + return s.length <= n ? s : s.slice(0, n) + '…'; +} + +function prettyJson(s: string): string { + try { + return JSON.stringify(JSON.parse(s), null, 2); + } catch { + return s; + } +} diff --git a/apps/vis/web/src/components/context/PersistedOutputLink.tsx b/apps/vis/web/src/components/context/PersistedOutputLink.tsx new file mode 100644 index 000000000..598a11b17 --- /dev/null +++ b/apps/vis/web/src/components/context/PersistedOutputLink.tsx @@ -0,0 +1,53 @@ +import { useState } from 'react'; +import { useToolResult } from '../../hooks/useContext'; +import { formatBytes } from '../shared/SizePreview'; + +interface PersistedOutputLinkProps { + sessionId: string; + toolCallId: string; + path: string; +} + +export function PersistedOutputLink({ + sessionId, + toolCallId, + path, +}: PersistedOutputLinkProps) { + const [load, setLoad] = useState(false); + const { data, isLoading, error } = useToolResult(sessionId, toolCallId, load); + const basename = path.split('/').pop() ?? path; + + return ( +
+ + {load ? ( +
+ {isLoading ? ( +
loading…
+ ) : error ? ( +
+ {(error).message} +
+ ) : data ? ( +
+              {data.content}
+            
+ ) : null} +
+ ) : null} +
+ ); +} diff --git a/apps/vis/web/src/components/files/FilesTab.tsx b/apps/vis/web/src/components/files/FilesTab.tsx new file mode 100644 index 000000000..f3bae940c --- /dev/null +++ b/apps/vis/web/src/components/files/FilesTab.tsx @@ -0,0 +1,202 @@ +import { useState } from 'react'; +import { Link } from 'react-router-dom'; + +import { useToolResult } from '../../hooks/useContext'; +import { useArchive } from '../../hooks/useWire'; +import type { SessionDetail } from '../../types'; +import { Drawer } from '../shared/Drawer'; +import { Pill } from '../shared/Pill'; +import { formatBytes } from '../shared/SizePreview'; +import { WireTab } from '../wire/WireTab'; + +interface FilesTabProps { + sessionId: string; + detail: SessionDetail; +} + +export function FilesTab({ sessionId, detail }: FilesTabProps) { + const [openArchive, setOpenArchive] = useState(null); + const [openResult, setOpenResult] = useState(null); + + return ( +
+ {/* Archives */} +
+

+ wire archives · {detail.archive_files.length} +

+ {detail.archive_files.length === 0 ? ( +

no compaction archives

+ ) : ( +
    + {detail.archive_files.map((f) => ( +
  • + + archive + + {f} + +
  • + ))} +
+ )} +
+ + {/* External tool results */} +
+

+ external tool results · {detail.tool_result_ids.length} +

+ {detail.tool_result_ids.length === 0 ? ( +

none

+ ) : ( +
    + {detail.tool_result_ids.map((id) => ( +
  • + + file + + {id}.txt + +
  • + ))} +
+ )} +
+ + {/* Subagent directories — click a tile to drill into that subagent's + own wire + context + meta. Mirrors the Subagents tab tree but in + a flat, filesystem-ish view. */} +
+

+ subagent directories · {detail.subagent_ids.length} +

+ {detail.subagent_ids.length === 0 ? ( +

none

+ ) : ( +
    + {detail.subagent_ids.map((id) => ( +
  • + +
    + + subagent + + + {id.replace(/^sub_/, '').slice(0, 12)} + + + open → + +
    +
    {id}
    + +
  • + ))} +
+ )} +
+ + {openArchive ? ( + { setOpenArchive(null); }} + /> + ) : null} + {openResult ? ( + { setOpenResult(null); }} + /> + ) : null} +
+ ); +} + +function ArchiveDrawer({ + sessionId, + filename, + onClose, +}: { + sessionId: string; + filename: string; + onClose: () => void; +}) { + const { data, isLoading, error } = useArchive(sessionId, filename); + return ( + archive · {filename}} width={900}> + {isLoading ? ( +
loading…
+ ) : error ? ( +
+ {(error).message} +
+ ) : data ? ( + + ) : null} +
+ ); +} + +function ToolResultDrawer({ + sessionId, + toolCallId, + onClose, +}: { + sessionId: string; + toolCallId: string; + onClose: () => void; +}) { + const { data, isLoading, error } = useToolResult(sessionId, toolCallId, true); + return ( + + tool-result · {toolCallId}.txt + {data ? {formatBytes(data.size_bytes)} : null} + + } + width={780} + > + {isLoading ? ( +
loading…
+ ) : error ? ( +
+ {(error).message} +
+ ) : data ? ( +
+          {data.content}
+        
+ ) : null} +
+ ); +} diff --git a/apps/vis/web/src/components/layout/AppShell.tsx b/apps/vis/web/src/components/layout/AppShell.tsx new file mode 100644 index 000000000..1d7fbeea2 --- /dev/null +++ b/apps/vis/web/src/components/layout/AppShell.tsx @@ -0,0 +1,138 @@ +import type { ReactNode } from 'react'; +import { useQueryClient } from '@tanstack/react-query'; +import { Link } from 'react-router-dom'; +import { SessionRail } from '../sessions/SessionRail'; +import { useTheme, type ThemeChoice, type ResolvedTheme } from '../../hooks/useTheme'; + +interface AppShellProps { + children: ReactNode; +} + +export function AppShell({ children }: AppShellProps) { + const qc = useQueryClient(); + const { choice, resolved, cycle } = useTheme(); + + return ( +
+
+ + + + kimi vis + + + debug · local files + + +
+ + +
+
+
+ +
{children}
+
+
+ ); +} + +function ThemeToggle({ + choice, + resolved, + onCycle, +}: { + choice: ThemeChoice; + resolved: ResolvedTheme; + onCycle: () => void; +}) { + const label = choice === 'auto' ? `auto · ${resolved}` : choice; + const title = `Theme: ${label}. Click to cycle (auto → light → dark → auto).`; + return ( + + ); +} + +function LogoMark() { + return ( + + ); +} + +function RefreshIcon() { + return ( + + ); +} + +function SunIcon() { + return ( + + ); +} + +function MoonIcon() { + return ( + + ); +} + +function AutoIcon() { + return ( + + ); +} diff --git a/apps/vis/web/src/components/layout/TabBar.tsx b/apps/vis/web/src/components/layout/TabBar.tsx new file mode 100644 index 000000000..6f2d42564 --- /dev/null +++ b/apps/vis/web/src/components/layout/TabBar.tsx @@ -0,0 +1,56 @@ +import { useSearchParams } from 'react-router-dom'; + +export interface TabSpec { + id: string; + label: string; + count?: number | null; +} + +interface TabBarProps { + tabs: TabSpec[]; + defaultTab: string; +} + +export function TabBar({ tabs, defaultTab }: TabBarProps) { + const [search, setSearch] = useSearchParams(); + const active = search.get('tab') ?? defaultTab; + + return ( +
+ {tabs.map((t) => { + const on = t.id === active; + return ( + + ); + })} +
+ ); +} + +export function useActiveTab(defaultTab: string): string { + const [search] = useSearchParams(); + return search.get('tab') ?? defaultTab; +} diff --git a/apps/vis/web/src/components/sessions/SessionCard.tsx b/apps/vis/web/src/components/sessions/SessionCard.tsx new file mode 100644 index 000000000..590c158a3 --- /dev/null +++ b/apps/vis/web/src/components/sessions/SessionCard.tsx @@ -0,0 +1,124 @@ +import type { MouseEvent } from 'react'; +import { useParams, Link } from 'react-router-dom'; +import type { SessionSummary } from '../../types'; +import { formatRelativeTime } from '../../util/time'; +import { Pill } from '../shared/Pill'; + +interface SessionCardProps { + session: SessionSummary; + onDelete: (session: SessionSummary) => void; + deleting: boolean; +} + +export function SessionCard({ session, onDelete, deleting }: SessionCardProps) { + const { sessionId } = useParams<{ sessionId: string }>(); + const selected = sessionId === session.session_id; + const dirty = session.last_exit_code === 'dirty'; + const workspaceLabel = session.workspace_dir + ? session.workspace_dir.split('/').slice(-2).join('/') + : '(no workspace)'; + const shortId = session.session_id.replace(/^session_/, '').slice(0, 10); + const title = session.title ?? session.custom_title; + + function handleDeleteClick(e: MouseEvent) { + e.preventDefault(); + e.stopPropagation(); + onDelete(session); + } + + return ( +
+ {selected ? ( + + ) : null} + +
+
+ + {shortId} + {session.model ? ( + + {session.model} + + ) : null} + {session.archived ? ( + + archived + + ) : null} +
+ + {formatRelativeTime(session.updated_at)} + +
+
+ + {workspaceLabel} + + + {session.wire_record_count}ev + + {session.subagent_count > 0 ? ( + + {session.subagent_count}sub + + ) : null} + {session.archive_count > 0 ? ( + + {session.archive_count}arch + + ) : null} + {session.health !== 'ok' ? ( + + {session.health} + + ) : null} +
+ {title ? ( +
+ {title} +
+ ) : null} + {session.last_prompt ? ( +
+ prompt · {session.last_prompt} +
+ ) : null} + + +
+ ); +} + +function TrashIcon() { + return ( + + ); +} diff --git a/apps/vis/web/src/components/sessions/SessionFilter.tsx b/apps/vis/web/src/components/sessions/SessionFilter.tsx new file mode 100644 index 000000000..79581c5bb --- /dev/null +++ b/apps/vis/web/src/components/sessions/SessionFilter.tsx @@ -0,0 +1,127 @@ +import type { SessionSortKey, HealthFilter } from './SessionRail'; + +interface SessionFilterProps { + search: string; + onSearchChange: (v: string) => void; + showArchived: boolean; + onShowArchivedChange: (v: boolean) => void; + sortKey: SessionSortKey; + onSortChange: (v: SessionSortKey) => void; + healthFilter: HealthFilter; + onHealthChange: (v: HealthFilter) => void; + totalCount: number; + filteredCount: number; + onClearSessions: () => void; + clearDisabled: boolean; + clearBusy: boolean; +} + +const SORT_OPTIONS: { value: SessionSortKey; label: string }[] = [ + { value: 'recent', label: 'recent' }, + { value: 'oldest', label: 'oldest' }, + { value: 'most_records', label: 'most records' }, + { value: 'most_subagents', label: 'most subagents' }, +]; + +const HEALTH_OPTIONS: { value: HealthFilter; label: string }[] = [ + { value: 'all', label: 'any' }, + { value: 'ok', label: 'ok' }, + { value: 'broken', label: 'broken' }, + { value: 'missing_wire', label: 'no wire' }, +]; + +export function SessionFilter({ + search, + onSearchChange, + showArchived, + onShowArchivedChange, + sortKey, + onSortChange, + healthFilter, + onHealthChange, + totalCount, + filteredCount, + onClearSessions, + clearDisabled, + clearBusy, +}: SessionFilterProps) { + return ( +
+
+ { onSearchChange(e.target.value); }} + placeholder="search id / title / workspace" + className="w-full border border-border bg-surface-0 px-2 py-1 font-mono text-[12px] text-fg-0 placeholder:text-fg-3 focus:border-border-strong focus:outline-none" + /> +
+
+ + +
+
+ +
+ + {filteredCount} / {totalCount} + + +
+
+
+ ); +} + +function TrashIcon() { + return ( + + ); +} diff --git a/apps/vis/web/src/components/sessions/SessionRail.tsx b/apps/vis/web/src/components/sessions/SessionRail.tsx new file mode 100644 index 000000000..2a73817d6 --- /dev/null +++ b/apps/vis/web/src/components/sessions/SessionRail.tsx @@ -0,0 +1,188 @@ +import { useMemo, useState } from 'react'; +import { useNavigate, useParams } from 'react-router-dom'; + +import { useClearSessions, useDeleteSession, useSessions } from '../../hooks/useSession'; +import type { SessionSummary } from '../../types'; +import { SessionCard } from './SessionCard'; +import { SessionFilter } from './SessionFilter'; + +export type SessionSortKey = 'recent' | 'oldest' | 'most_records' | 'most_subagents'; +export type HealthFilter = 'all' | 'ok' | 'broken' | 'missing_wire'; + +function workspaceKey(s: SessionSummary): string { + if (!s.workspace_dir) return '(no workspace)'; + return s.workspace_dir.split('/').slice(-2).join('/'); +} + +function sortSessions(sessions: readonly SessionSummary[], key: SessionSortKey): SessionSummary[] { + switch (key) { + case 'recent': + return sessions.toSorted((a, b) => b.updated_at - a.updated_at); + case 'oldest': + return sessions.toSorted((a, b) => a.created_at - b.created_at); + case 'most_records': + return sessions.toSorted((a, b) => b.wire_record_count - a.wire_record_count); + case 'most_subagents': + return sessions.toSorted((a, b) => b.subagent_count - a.subagent_count); + } +} + +export function SessionRail() { + const { data, isLoading, error } = useSessions(); + const deleteSession = useDeleteSession(); + const clearSessions = useClearSessions(); + const navigate = useNavigate(); + const { sessionId } = useParams<{ sessionId: string }>(); + const [search, setSearch] = useState(''); + const [showArchived, setShowArchived] = useState(false); + const [sortKey, setSortKey] = useState('recent'); + const [healthFilter, setHealthFilter] = useState('all'); + + const filtered = useMemo(() => { + if (!data) return []; + const q = search.trim().toLowerCase(); + return data.filter((s) => { + if (!showArchived && s.archived) return false; + if (healthFilter !== 'all' && s.health !== healthFilter) return false; + if (!q) return true; + const hay = [ + s.session_id, + s.title ?? '', + s.last_prompt ?? '', + s.custom_title ?? '', + s.workspace_dir ?? '', + s.model ?? '', + ...s.tags, + ] + .join(' ') + .toLowerCase(); + return hay.includes(q); + }); + }, [data, search, showArchived, healthFilter]); + + // Grouping-by-workspace only makes sense when sorted by time — for any + // other sort the user asked for a particular total ordering across + // sessions, so we honour it and display a flat list. + const grouped = useMemo(() => { + if (sortKey !== 'recent') return null; + const map = new Map(); + for (const s of filtered) { + const k = workspaceKey(s); + const existing = map.get(k); + if (existing === undefined) { + map.set(k, [s]); + } else { + existing.push(s); + } + } + return [...map.entries()] + .map(([group, items]) => { + const sorted = items.toSorted((a, b) => b.updated_at - a.updated_at); + return [group, sorted] as const; + }) + .toSorted(([, a], [, b]) => { + const ua = a[0]?.updated_at ?? 0; + const ub = b[0]?.updated_at ?? 0; + return ub - ua; + }); + }, [filtered, sortKey]); + + const flat = useMemo( + () => (grouped === null ? sortSessions(filtered, sortKey) : null), + [filtered, sortKey, grouped], + ); + + async function handleDeleteSession(session: SessionSummary) { + const label = session.title ?? session.last_prompt ?? session.session_id; + if (!window.confirm(`Delete session "${label}"?\n\nThis removes its files from KIMI_CODE_HOME.`)) { + return; + } + try { + await deleteSession.mutateAsync(session.session_id); + if (sessionId === session.session_id) { + void navigate('/'); + } + } catch (deleteError) { + window.alert(deleteError instanceof Error ? deleteError.message : String(deleteError)); + } + } + + async function handleClearSessions() { + const total = data?.length ?? 0; + if (total === 0) return; + if (!window.confirm(`Clear all ${total} sessions shown by vis?\n\nThis removes their files from KIMI_CODE_HOME.`)) { + return; + } + try { + const result = await clearSessions.mutateAsync(); + void navigate('/'); + if (result.failed.length > 0) { + window.alert(`Deleted ${result.deleted_count} sessions; ${result.failed.length} failed.`); + } + } catch (clearError) { + window.alert(clearError instanceof Error ? clearError.message : String(clearError)); + } + } + + return ( + + ); +} diff --git a/apps/vis/web/src/components/shared/CopyButton.tsx b/apps/vis/web/src/components/shared/CopyButton.tsx new file mode 100644 index 000000000..d287dd044 --- /dev/null +++ b/apps/vis/web/src/components/shared/CopyButton.tsx @@ -0,0 +1,28 @@ +import { useState } from 'react'; + +interface CopyButtonProps { + value: string; + label?: string; + className?: string; +} + +export function CopyButton({ value, label = 'copy', className = '' }: CopyButtonProps) { + const [state, setState] = useState<'idle' | 'ok' | 'err'>('idle'); + + return ( + + ); +} diff --git a/apps/vis/web/src/components/shared/Drawer.tsx b/apps/vis/web/src/components/shared/Drawer.tsx new file mode 100644 index 000000000..55312b785 --- /dev/null +++ b/apps/vis/web/src/components/shared/Drawer.tsx @@ -0,0 +1,50 @@ +import { useEffect, type ReactNode } from 'react'; + +interface DrawerProps { + open: boolean; + onClose: () => void; + title?: ReactNode; + children: ReactNode; + width?: number; +} + +export function Drawer({ open, onClose, title, children, width = 560 }: DrawerProps) { + useEffect(() => { + if (!open) return; + const handler = (e: KeyboardEvent) => { + if (e.key === 'Escape') onClose(); + }; + window.addEventListener('keydown', handler); + return () =>{ window.removeEventListener('keydown', handler); }; + }, [open, onClose]); + + if (!open) return null; + + return ( +
+ + ); +} diff --git a/apps/vis/web/src/components/shared/JsonViewer.tsx b/apps/vis/web/src/components/shared/JsonViewer.tsx new file mode 100644 index 000000000..2e134740f --- /dev/null +++ b/apps/vis/web/src/components/shared/JsonViewer.tsx @@ -0,0 +1,155 @@ +import { useState, memo } from 'react'; + +interface JsonViewerProps { + value: unknown; + /** Default-open nesting depth */ + defaultOpenDepth?: number; +} + +export function JsonViewer({ value, defaultOpenDepth = 2 }: JsonViewerProps) { + return ( +
+ +
+ ); +} + +interface NodeProps { + value: unknown; + depth: number; + defaultOpenDepth: number; + keyPath: string; + keyLabel?: string | number; + isLast?: boolean; +} + +const Node = memo(function Node({ + value, + depth, + defaultOpenDepth, + keyPath, + keyLabel, + isLast, +}: NodeProps) { + const [open, setOpen] = useState(depth < defaultOpenDepth); + + if (value === null) + return ; + if (value === undefined) + return ; + if (typeof value === 'boolean') + return ; + if (typeof value === 'number') + return ; + if (typeof value === 'string') { + const short = value.length <= 200; + return ( + + ); + } + if (Array.isArray(value)) { + if (value.length === 0) + return ; + return ( +
+ + {open ? ( +
+ {value.map((v, i) => ( + + ))} +
+ ) : null} +
+ ); + } + if (typeof value === 'object') { + const entries = Object.entries(value as Record); + if (entries.length === 0) + return ; + return ( +
+ + {open ? ( +
+ {entries.map(([k, v], i) => ( + + ))} +
+ ) : null} +
+ ); + } + return ; +}); + +function Leaf({ + keyLabel, + repr, + color, +}: { + keyLabel?: string | number; + repr: string; + color: string; + isLast?: boolean; +}) { + return ( +
+ + {keyLabel !== undefined ? ( + <> + {keyLabel} + : + + ) : null} + {repr} +
+ ); +} diff --git a/apps/vis/web/src/components/shared/Pill.tsx b/apps/vis/web/src/components/shared/Pill.tsx new file mode 100644 index 000000000..58180f871 --- /dev/null +++ b/apps/vis/web/src/components/shared/Pill.tsx @@ -0,0 +1,78 @@ +import type { ReactNode } from 'react'; + +export type PillTone = + | 'conversation' + | 'config' + | 'lifecycle' + | 'subagent' + | 'approval' + | 'ephemeral' + | 'meta' + | 'tools' + | 'user' + | 'assistant' + | 'tool' + | 'compaction' + | 'turn' + | 'info' + | 'success' + | 'warning' + | 'error' + | 'neutral'; + +const TONE_VAR: Record = { + conversation: '--color-cat-conversation', + config: '--color-cat-config', + lifecycle: '--color-cat-lifecycle', + subagent: '--color-cat-subagent', + approval: '--color-cat-approval', + ephemeral: '--color-cat-ephemeral', + meta: '--color-cat-meta', + tools: '--color-cat-tools', + user: '--color-user', + assistant: '--color-assistant', + tool: '--color-tool', + compaction: '--color-compaction', + turn: '--color-turn', + info: '--color-sev-info', + success: '--color-sev-success', + warning: '--color-sev-warning', + error: '--color-sev-error', + neutral: '--color-fg-2', +}; + +interface PillProps { + tone?: PillTone; + variant?: 'solid' | 'soft' | 'outline'; + children: ReactNode; + title?: string; + className?: string; +} + +export function Pill({ + tone = 'neutral', + variant = 'soft', + children, + title, + className = '', +}: PillProps) { + const color = `var(${TONE_VAR[tone]})`; + const style = + variant === 'solid' + ? { backgroundColor: color, color: 'var(--color-on-accent)' } + : variant === 'outline' + ? { border: `1px solid ${color}`, color } + : { + backgroundColor: `color-mix(in oklab, ${color} 18%, transparent)`, + color, + }; + return ( + + {children} + + ); +} diff --git a/apps/vis/web/src/components/shared/SizePreview.tsx b/apps/vis/web/src/components/shared/SizePreview.tsx new file mode 100644 index 000000000..aa3e4ef88 --- /dev/null +++ b/apps/vis/web/src/components/shared/SizePreview.tsx @@ -0,0 +1,55 @@ +import { useState, type ReactNode } from 'react'; + +interface SizePreviewProps { + label?: string; + /** Byte/char count for the dim label */ + sizeBytes: number; + /** Preview text shown when collapsed (first ~200 chars) */ + preview?: string; + /** Full content renderer */ + children: ReactNode; + /** Start expanded if true */ + defaultOpen?: boolean; +} + +export function SizePreview({ + label = 'payload', + sizeBytes, + preview, + children, + defaultOpen = false, +}: SizePreviewProps) { + const [open, setOpen] = useState(defaultOpen); + const size = formatBytes(sizeBytes); + return ( +
+ + {open ? ( +
+ {children} +
+ ) : null} +
+ ); +} + +export function formatBytes(n: number): string { + if (n < 1024) return `${n}B`; + if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)}KB`; + return `${(n / 1024 / 1024).toFixed(2)}MB`; +} diff --git a/apps/vis/web/src/components/state/StateTab.tsx b/apps/vis/web/src/components/state/StateTab.tsx new file mode 100644 index 000000000..39a105d7e --- /dev/null +++ b/apps/vis/web/src/components/state/StateTab.tsx @@ -0,0 +1,217 @@ +import { useState, type ReactNode } from 'react'; + +import type { SessionState } from '../../types'; +import { formatAbsoluteTime, formatRelativeTime } from '../../util/time'; +import { CopyButton } from '../shared/CopyButton'; +import { JsonViewer } from '../shared/JsonViewer'; + +interface StateTabProps { + state: SessionState; + protocolVersion: string | null; +} + +/** Keys whose values should be interpreted as epoch milliseconds and + * decorated with both an absolute time and "2h ago" style relative label. + * Matches the shape of state.json written by `@moonshot-ai/agent-core`. */ +const EPOCH_MS_KEYS = new Set(['created_at', 'updated_at', 'last_turn_time']); + +/** Keys whose values tend to be long paths worth copying with one click. */ +const COPY_KEYS = new Set(['workspace_dir', 'plan_slug', 'session_id']); + +export function StateTab({ state, protocolVersion }: StateTabProps) { + const [showRaw, setShowRaw] = useState(false); + const entries = Object.entries(state); + + return ( +
+
+
+ state.json + {protocolVersion ? ` · wire protocol ${protocolVersion}` : ''} +
+
+ + +
+
+ + + +
+ + + {entries.map(([k, v]) => ( + + ))} + +
+
+ + {showRaw ? ( +
+ +
+ ) : null} +
+ ); +} + +/** Derive a human duration from created_at + updated_at when both are + * valid epoch-ms numbers. Rendered just under the header as a single dim + * line — omitted silently when data is missing. */ +function DurationRow({ state }: { state: SessionState }) { + const created = state.created_at; + const updated = state.updated_at; + if (typeof created !== 'number' || typeof updated !== 'number') return null; + if (updated < created) return null; + const ms = updated - created; + return ( +
+ duration {formatDuration(ms)} + {' · '} + from {formatAbsoluteTime(created)} + {' → '} + {formatAbsoluteTime(updated)} +
+ ); +} + +function StateRow({ keyName, value }: { keyName: string; value: unknown }) { + return ( + + + {keyName} + + + + + + ); +} + +function ValueCell({ keyName, value }: { keyName: string; value: unknown }) { + if (value === null || value === undefined) return null; + + if (typeof value === 'boolean') { + return ( + + {String(value)} + + ); + } + + if (typeof value === 'number') { + if (EPOCH_MS_KEYS.has(keyName) && value > 1_000_000_000_000) { + return ( + + {formatAbsoluteTime(value)} + · + {formatRelativeTime(value)} + · + {value} + + ); + } + return {value}; + } + + if (typeof value === 'string') { + const withCopy = COPY_KEYS.has(keyName); + return ( + + "{value}" + {withCopy ? : null} + + ); + } + + if (Array.isArray(value)) { + if (value.length === 0) return []; + // Arrays of primitives render inline; arrays of objects use JsonViewer. + const allPrim = value.every((v) => typeof v !== 'object' || v === null); + if (allPrim) { + return ( + + {(value as unknown[]).map((v, i) => ( + + {typeof v === 'string' ? v : String(v)} + + ))} + + ); + } + return ; + } + + // Nested object: render as a compact sub-table so fields like `producer` + // read as structured data instead of a JSON.stringify blob. + return } />; +} + +function NestedObject({ value }: { value: Record }) { + const entries = Object.entries(value); + if (entries.length === 0) return {'{}'}; + return ( +
+ + + {entries.map(([k, v]) => ( + + + + + ))} + +
{k} + +
+
+ ); +} + +function NestedValue({ value }: { value: unknown }): ReactNode { + if (value === null || value === undefined) return null; + if (typeof value === 'boolean') { + return ( + + {String(value)} + + ); + } + if (typeof value === 'number') + return {value}; + if (typeof value === 'string') + return "{value}"; + if (Array.isArray(value)) { + return ( + + [{value.length} item{value.length === 1 ? '' : 's'}] + + ); + } + return } />; +} + +/** Format a duration in ms as "3h 14m" / "12m 30s" / "45s". */ +function formatDuration(ms: number): string { + if (ms < 1000) return `${ms}ms`; + const s = Math.round(ms / 1000); + if (s < 60) return `${s}s`; + const m = Math.floor(s / 60); + const rs = s % 60; + if (m < 60) return rs > 0 ? `${m}m ${rs}s` : `${m}m`; + const h = Math.floor(m / 60); + const rm = m % 60; + if (h < 24) return rm > 0 ? `${h}h ${rm}m` : `${h}h`; + const d = Math.floor(h / 24); + const rh = h % 24; + return rh > 0 ? `${d}d ${rh}h` : `${d}d`; +} diff --git a/apps/vis/web/src/components/subagents/SubagentNode.tsx b/apps/vis/web/src/components/subagents/SubagentNode.tsx new file mode 100644 index 000000000..4fedaa485 --- /dev/null +++ b/apps/vis/web/src/components/subagents/SubagentNode.tsx @@ -0,0 +1,92 @@ +import { Link, useParams } from 'react-router-dom'; +import type { SubagentNode as SubagentNodeT } from '../../types'; +import { Pill, type PillTone } from '../shared/Pill'; +import { formatRelativeTime } from '../../util/time'; + +const STATUS_TONE: Record = { + running: 'info', + completed: 'success', + failed: 'error', + killed: 'neutral', + lost: 'warning', + unknown: 'neutral', +}; + +interface Props { + node: SubagentNodeT; + sessionId: string; +} + +export function SubagentNode({ node, sessionId }: Props) { + const { agentId: activeAgentId } = useParams<{ agentId?: string }>(); + const selected = activeAgentId === node.agent_id; + const shortId = node.agent_id.replace(/^sub_/, '').slice(0, 10); + + return ( +
+ + +
+
+ + {node.subagent_type ?? 'unknown'} + + {shortId} + + {node.status} + + {node.run_in_background ? ( + bg + ) : null} + + depth {node.depth} · {formatRelativeTime(node.spawn_time)} + +
+ {node.agent_name ? ( +
+ {node.agent_name} +
+ ) : null} + {node.result_summary ? ( +
+ {node.result_summary} +
+ ) : null} + {node.error ? ( +
+ {node.error} +
+ ) : null} +
+ + {node.children.length > 0 ? ( +
+ {node.children.map((c) => ( + + ))} +
+ ) : null} +
+ ); +} + +const STATUS_COLOR_VAR: Record = { + running: '--color-sev-info', + completed: '--color-sev-success', + failed: '--color-sev-error', + killed: '--color-fg-3', + lost: '--color-sev-warning', + unknown: '--color-fg-3', +}; diff --git a/apps/vis/web/src/components/subagents/SubagentTree.tsx b/apps/vis/web/src/components/subagents/SubagentTree.tsx new file mode 100644 index 000000000..40f8e7f20 --- /dev/null +++ b/apps/vis/web/src/components/subagents/SubagentTree.tsx @@ -0,0 +1,24 @@ +import type { SubagentNode as SubagentNodeT } from '../../types'; +import { SubagentNode } from './SubagentNode'; + +interface SubagentTreeProps { + tree: SubagentNodeT[]; + sessionId: string; +} + +export function SubagentTree({ tree, sessionId }: SubagentTreeProps) { + if (tree.length === 0) { + return ( +
+ no subagents spawned in this session +
+ ); + } + return ( +
+ {tree.map((node) => ( + + ))} +
+ ); +} diff --git a/apps/vis/web/src/components/subagents/SubagentsTab.tsx b/apps/vis/web/src/components/subagents/SubagentsTab.tsx new file mode 100644 index 000000000..1f9879dfa --- /dev/null +++ b/apps/vis/web/src/components/subagents/SubagentsTab.tsx @@ -0,0 +1,28 @@ +import { useSubagents } from '../../hooks/useSubagents'; +import { SubagentTree } from './SubagentTree'; + +interface SubagentsTabProps { + sessionId: string; +} + +export function SubagentsTab({ sessionId }: SubagentsTabProps) { + const { data, isLoading, error } = useSubagents(sessionId); + + if (isLoading) { + return
loading subagents…
; + } + if (error) { + return ( +
+ {(error).message} +
+ ); + } + if (!data) return null; + + return ( +
+ +
+ ); +} diff --git a/apps/vis/web/src/components/wire/IssuesDrawer.tsx b/apps/vis/web/src/components/wire/IssuesDrawer.tsx new file mode 100644 index 000000000..d7cbd1d78 --- /dev/null +++ b/apps/vis/web/src/components/wire/IssuesDrawer.tsx @@ -0,0 +1,144 @@ +import { useEffect } from 'react'; + +import type { Issue, IssueSeverity } from '../../lib/issues'; + +interface IssuesDrawerProps { + issues: Issue[]; + onClose: () => void; + onJumpTo?: (seq: number) => void; + /** Optional predicate: "is this seq currently visible under the active + * filter?". When provided, jump buttons for filtered-out seqs are + * disabled and flagged. */ + isSeqVisible?: (seq: number) => boolean; +} + +const SEV_COLOR: Record = { + error: 'var(--color-sev-error)', + warning: 'var(--color-sev-warning)', + info: 'var(--color-sev-info)', +}; + +const KIND_LABEL: Record = { + subagent_failed: 'subagent failed', + tool_error: 'tool error', + tool_denied: 'tool denied', + turn_failed: 'turn failed', + step_truncated: 'step truncated', + wire_warning: 'wire warning', +}; + +export function IssuesDrawer({ issues, onClose, onJumpTo, isSeqVisible }: IssuesDrawerProps) { + // ESC closes — standard drawer affordance. + useEffect(() => { + const onKey = (e: KeyboardEvent) => { + if (e.key === 'Escape') onClose(); + }; + window.addEventListener('keydown', onKey); + return () =>{ window.removeEventListener('keydown', onKey); }; + }, [onClose]); + + return ( + <> + {/* Backdrop — click outside drawer to dismiss. Subtle tint so the + underlying Wire timeline is still readable. */} + + +
+ {issues.length === 0 ? ( +
no issues detected
+ ) : ( +
    + {issues.map((iss, i) => ( + + ))} +
+ )} +
+ + + ); +} + +function IssueItem({ + issue, + onJumpTo, + onClose, + isSeqVisible, +}: { + issue: Issue; + onJumpTo?: (seq: number) => void; + onClose: () => void; + isSeqVisible?: (seq: number) => boolean; +}) { + const color = SEV_COLOR[issue.severity]; + const seq = issue.seq; + const hidden = seq !== null && isSeqVisible !== undefined && !isSeqVisible(seq); + const canJump = seq !== null && onJumpTo !== undefined && !hidden; + return ( +
  • +
    +
    +
    {issue.summary}
    + {issue.detail !== undefined ? ( +
    {issue.detail}
    + ) : null} +
  • + ); +} diff --git a/apps/vis/web/src/components/wire/TypeBadge.tsx b/apps/vis/web/src/components/wire/TypeBadge.tsx new file mode 100644 index 000000000..fbd1af1cb --- /dev/null +++ b/apps/vis/web/src/components/wire/TypeBadge.tsx @@ -0,0 +1,20 @@ +import type { WireRecordType } from '../../types'; +import { Pill } from '../shared/Pill'; +import { TYPE_ICON, TYPE_TONE } from './typeMeta'; + +interface TypeBadgeProps { + type: WireRecordType; +} + +export function TypeBadge({ type }: TypeBadgeProps) { + const icon = TYPE_ICON[type]; + const tone = TYPE_TONE[type]; + return ( + + + {type} + + ); +} diff --git a/apps/vis/web/src/components/wire/WireHeadline.tsx b/apps/vis/web/src/components/wire/WireHeadline.tsx new file mode 100644 index 000000000..2ff6126ea --- /dev/null +++ b/apps/vis/web/src/components/wire/WireHeadline.tsx @@ -0,0 +1,733 @@ +import type { ReactNode } from 'react'; + +import { useFocus, type FocusKind } from '../../lib/focus-context'; +import type { VisWireRecord } from '../../types'; +import { Pill } from '../shared/Pill'; +import { formatBytes } from '../shared/SizePreview'; + +export interface HeadlineRender { + /** Main headline content — rendered in the flex-grow slot of the row */ + main: ReactNode; + /** Right-side badges / pair refs */ + right?: ReactNode; +} + +function truncate(s: unknown, n: number): string { + let str: string; + if (s === null || s === undefined) str = ''; + else if (typeof s === 'string') str = s; + else if (typeof s === 'number' || typeof s === 'boolean' || typeof s === 'bigint') + str = String(s); + else { + try { + str = JSON.stringify(s); + } catch { + return '[unserializable]'; + } + } + if (str.length <= n) return str; + return str.slice(0, n) + '…'; +} + +/** Narrow an `unknown` to a string — used for wire-record field values that + * are typed `unknown` but practically always string/null in the field we read. */ +function asStr(v: unknown, fallback = '—'): string { + if (typeof v === 'string') return v; + if (typeof v === 'number' || typeof v === 'boolean') return String(v); + return fallback; +} + +function byteLength(v: unknown): number { + if (typeof v === 'string') return v.length; + if (v === undefined || v === null) return 0; + try { + return JSON.stringify(v).length; + } catch { + return 0; + } +} + +/** Render the collapsed-headline for a wire record. */ +export function renderHeadline(r: VisWireRecord): HeadlineRender { + switch (r.type) { + // ─── FILE HEADER / STARTUP BASELINE ─── + case 'metadata': { + const pv = r['protocol_version'] as string | undefined; + const producer = r['producer'] as + | { kind?: string; name?: string; version?: string } + | undefined; + const kimiVer = r['kimi_version'] as string | undefined; + const fileName = r['file_name'] as string | undefined; + const prodStr = producer ? `${producer.name ?? '?'}@${producer.version ?? '?'}` : 'legacy'; + return { + main: ( + + protocol v{pv ?? '?'} + · + {prodStr} + {kimiVer ? · kimi {kimiVer} : null} + + ), + right: fileName ? {fileName} : null, + }; + } + + case 'session_initialized': { + const agentType = r['agent_type'] as string | undefined; + const model = r['model'] as string | undefined; + const sysPrompt = r['system_prompt'] as string | undefined; + const tools = (r['active_tools'] as string[] | undefined) ?? []; + const permMode = r['permission_mode'] as string | undefined; + const planMode = r['plan_mode'] as boolean | undefined; + return { + main: ( + + {model ?? '—'} + · + {tools.length} tools + · + {permMode ?? '—'} + {planMode ? ( + + plan + + ) : null} + · + + sp: {truncate(sysPrompt ?? '', 70) || (empty)} + + + ), + right: ( + + {agentType ?? 'main'} + + ), + }; + } + + // ─── CONVERSATION ─── + case 'turn_begin': { + const agentType = r['agent_type'] as string | undefined; + const kind = r['input_kind'] as string | undefined; + const input = r['user_input'] as string | undefined; + const turnId = r['turn_id'] as string | undefined; + return { + main: ( + + turn # + + kind={kind ?? '—'} + {input ? · {truncate(input, 80)} : null} + + ), + right: ( + + {agentType ?? 'main'} + + ), + }; + } + + case 'turn_end': { + const reason = r['reason'] as string | undefined; + const success = r['success'] as boolean | undefined; + const usage = r['usage'] as Record | undefined; + const synthetic = r['synthetic'] as boolean | undefined; + const outputTokens = usage?.['output_tokens'] as number | undefined; + const cost = usage?.['cost_usd'] as number | undefined; + const turnId = r['turn_id'] as string | undefined; + return { + main: ( + + turn # + + + {reason} + + + ), + right: ( + + {synthetic ? ( + + synthetic + + ) : null} + {outputTokens !== undefined ? {outputTokens}tok : null} + {cost !== undefined ? ${cost.toFixed(4)} : null} + + ), + }; + } + + case 'user_message': { + const content = r['content']; + const text = + typeof content === 'string' + ? content + : Array.isArray(content) + ? (content as { type: string; text?: string }[]) + .map((p) => (p.type === 'text' ? (p.text ?? '') : `<${p.type}>`)) + .join('') + : ''; + const parts = Array.isArray(content) ? content.length : 1; + return { + main: ( + {truncate(text, 120) || (empty)} + ), + right: parts > 1 ? {parts} parts : undefined, + }; + } + + // An "assistant message" is now a sequence of atoms: step_begin → + // (content_part | tool_call)* → step_end. Render each atom distinctly so + // the wire timeline stays a 1:1 view of the underlying records. + case 'step_begin': { + const uuid = r['uuid'] as string | undefined; + const step = r['step'] as number | undefined; + return { + main: ( + + step #{step ?? '—'} + + + ), + }; + } + + case 'step_end': { + const step = r['step'] as number | undefined; + const finish = r['finish_reason'] as string | undefined; + const usage = r['usage'] as Record | undefined; + const outputTokens = usage?.['output_tokens'] as number | undefined; + return { + main: ( + + step #{step ?? '—'} + {finish ? {finish} : null} + + ), + right: outputTokens !== undefined ? {outputTokens}tok : undefined, + }; + } + + case 'content_part': { + const part = r['part'] as + | { kind: 'text' | 'think'; text?: string; think?: string } + | undefined; + if (part === undefined) { + return { main: (empty part) }; + } + const body = part.kind === 'text' ? (part.text ?? '') : (part.think ?? ''); + return { + main: ( + + + {part.kind} + + {truncate(body, 100) || (empty)} + + ), + right: {formatBytes(body.length)}, + }; + } + + case 'tool_call': { + const d = (r as { data?: Record }).data ?? {}; + const name = d['tool_name'] as string | undefined; + const args = d['args']; + const preview = + typeof args === 'string' ? args : args !== undefined ? JSON.stringify(args) : ''; + const activity = d['description'] as string | undefined; + const callId = d['tool_call_id'] as string | undefined; + const step = r['step'] as number | undefined; + return { + main: ( + + {name ?? '—'} + {activity ? ( + — {truncate(activity, 60)} + ) : ( + + ({truncate(preview, 60)}) + + )} + + ), + right: ( + + {callId ? ( + + call= + + + ) : null} + {step !== undefined ? step={step} : null} + + ), + }; + } + + case 'tool_result': { + const output = r['output']; + const isError = r['is_error'] as boolean | undefined; + const synthetic = r['synthetic'] as boolean | undefined; + const callId = r['tool_call_id'] as string | undefined; + const outputBytes = byteLength(output); + const isPersisted = + typeof output === 'string' && output.trimStart().startsWith(' + call= + + + {typeof output === 'string' + ? 'string' + : Array.isArray(output) + ? 'array' + : typeof output} + + {formatBytes(outputBytes)} + {isPersisted ? ( + + persisted + + ) : null} + + ), + right: ( + + {synthetic ? ( + + synthetic + + ) : null} + {isError ? ( + + error + + ) : null} + + ), + }; + } + + case 'compaction': { + const summary = r['summary'] as string | undefined; + const pre = r['pre_compact_tokens'] as number | undefined; + const post = r['post_compact_tokens'] as number | undefined; + return { + main: ( + + + summary + + {summary ? {truncate(summary, 80)} : null} + {pre !== undefined && post !== undefined ? ( + + · {pre}→{post} tok + + ) : null} + + ), + }; + } + + // ─── CONFIG ─── + case 'system_prompt_changed': { + const prompt = r['new_prompt'] as string | undefined; + return { + main: ( + + {truncate(prompt ?? '', 60) || (empty)} + + ), + right: {formatBytes(byteLength(prompt))}, + }; + } + + case 'tools_changed': { + const op = r['operation'] as string | undefined; + const tools = (r['tools'] as string[] | undefined) ?? []; + const head = tools.slice(0, 3).join(', '); + const rest = tools.length > 3 ? ` +${tools.length - 3} more` : ''; + return { + main: ( + + + {op ?? '—'} + + + {head} + {rest} + + + ), + }; + } + + // ─── EPHEMERAL ─── + case 'system_reminder': { + const content = r['content'] as string | undefined; + const consumed = r['consumed_at_turn'] as number | undefined; + return { + main: {truncate(content ?? '', 80)}, + right: + consumed !== undefined ? ( + consumed@turn{consumed} + ) : ( + + pending + + ), + }; + } + + // ─── META ─── + case 'notification': { + const d = (r as { data?: Record }).data ?? {}; + const severity = d['severity'] as string | undefined; + const title = d['title'] as string | undefined; + const category = d['category'] as string | undefined; + const targets = (d['targets'] as string[] | undefined) ?? []; + const sevTone = + severity === 'error' + ? 'error' + : severity === 'warning' + ? 'warning' + : severity === 'success' + ? 'success' + : 'info'; + return { + main: ( + + + {severity ?? '—'} + + {truncate(title ?? '', 80)} + {category ? ({category}) : null} + + ), + right: + targets.length > 0 ? ( + + {targets.map((t) => ( + + {t} + + ))} + + ) : undefined, + }; + } + + case 'team_mail': { + const d = (r as { data?: Record }).data ?? {}; + return { + main: ( + + + {asStr(d['from_agent'])} → {asStr(d['to_agent'])} + + {truncate(d['content'], 60)} + + ), + }; + } + + // ─── TOOLS ─── + // Note: the `tool_call` case is handled in the conversation section + // above (alongside step_begin / step_end / content_part). + + case 'tool_denied': { + const d = (r as { data?: Record }).data ?? {}; + const name = d['tool_name'] as string | undefined; + const reason = d['reason'] as string | undefined; + const rule = d['rule_id'] as string | undefined; + return { + main: ( + + {name ?? '—'} + — {truncate(reason ?? '', 80)} + + ), + right: rule ? ( + + rule={rule} + + ) : undefined, + }; + } + + case 'skill_invoked': { + const d = (r as { data?: Record }).data ?? {}; + const name = d['skill_name'] as string | undefined; + const mode = d['execution_mode'] as string | undefined; + const trigger = d['invocation_trigger'] as string | undefined; + const depth = d['query_depth'] as number | undefined; + return { + main: ( + + {name ?? '—'} + + {mode ?? 'inline'} + + {trigger ? {trigger} : null} + + ), + right: depth !== undefined && depth > 0 ? depth={depth} : undefined, + }; + } + + case 'skill_completed': { + const d = (r as { data?: Record }).data ?? {}; + const name = d['skill_name'] as string | undefined; + const success = d['success'] as boolean | undefined; + const error = d['error'] as string | undefined; + return { + main: ( + + {name ?? '—'} + + {success ? 'ok' : error ? truncate(error, 60) : 'failed'} + + + ), + }; + } + + // ─── APPROVAL ─── + case 'approval_request': { + const d = (r as { data?: Record }).data ?? {}; + const tool = d['tool_name'] as string | undefined; + const action = d['action'] as string | undefined; + const source = d['source'] as Record | undefined; + const display = d['display'] as Record | undefined; + return { + main: ( + + {tool ?? '—'} + · {action ?? '—'} + {source ? ( + + {asStr(source['kind'], '?')} + + ) : null} + + ), + right: display ? display={asStr(display['kind'], '?')} : undefined, + }; + } + + case 'approval_response': { + const d = (r as { data?: Record }).data ?? {}; + const response = d['response'] as string | undefined; + const feedback = d['feedback'] as string | undefined; + const selectedLabel = d['selected_label'] as string | undefined; + const synthetic = d['synthetic'] as boolean | undefined; + const tone = + response === 'approved' ? 'success' : response === 'rejected' ? 'error' : 'neutral'; + return { + main: ( + + + {response ?? '—'} + + {selectedLabel ? · {selectedLabel} : null} + {feedback ? · {truncate(feedback, 40)} : null} + + ), + right: synthetic ? ( + + synthetic + + ) : undefined, + }; + } + + // ─── SUBAGENT ─── + case 'subagent_spawned': { + const d = (r as { data?: Record }).data ?? {}; + const id = d['agent_id'] as string | undefined; + const name = d['agent_name'] as string | undefined; + const bg = d['run_in_background'] as boolean | undefined; + const parent = d['parent_agent_id'] as string | undefined; + return { + main: ( + + + {name ?? 'subagent'} + + + + ), + right: ( + + {bg ? ( + + bg + + ) : null} + {parent ? ( + + parent= + + + ) : null} + + ), + }; + } + + case 'subagent_completed': { + const d = (r as { data?: Record }).data ?? {}; + const id = d['agent_id'] as string | undefined; + const summary = d['result_summary'] as string | undefined; + const usage = d['usage'] as Record | undefined; + const out = (usage?.['output'] ?? usage?.['output_tokens']) as number | undefined; + return { + main: ( + + + {truncate(summary ?? '', 80)} + + ), + right: out !== undefined ? {out}tok : undefined, + }; + } + + case 'subagent_failed': { + const d = (r as { data?: Record }).data ?? {}; + const id = d['agent_id'] as string | undefined; + const error = d['error'] as string | undefined; + return { + main: ( + + + + {truncate(error ?? '', 80)} + + + ), + }; + } + + // ─── LIFECYCLE ─── + case 'ownership_changed': { + const oldO = r['old_owner'] as string | null | undefined; + const newO = r['new_owner'] as string | undefined; + return { + main: ( + + {oldO ?? '(none)'} → {newO ?? '—'} + + ), + }; + } + + case 'context_cleared': { + return { main: context history cleared }; + } + + case 'context_edit': { + const op = r['operation'] as string | undefined; + const target = r['target_seq'] as number | undefined; + const toTurn = r['to_turn'] as number | undefined; + const cascade = r['cascade'] as boolean | undefined; + const ref = + target !== undefined + ? `target=${target}` + : toTurn !== undefined + ? `to_turn=${toTurn}` + : '—'; + return { + main: ( + + + {op ?? '—'} + + {ref} + + ), + right: cascade ? ( + + cascade + + ) : undefined, + }; + } + + default: { + const t = (r as Record)['type']; + return { main: unknown type: {String(t)} }; + } + } +} + +// ─── tiny presentational helpers ─── +function Mono({ children, className = '' }: { children: ReactNode; className?: string }) { + return {children}; +} + +function Dim({ children, className = '' }: { children: ReactNode; className?: string }) { + return {children}; +} + +/** A click-to-focus id badge. Clicking toggles a global focus that dims + * non-related rows. Re-clicking the same id (or pressing ESC) clears. */ +function IdBadge({ + kind, + value, + shortenTo = 8, +}: { + kind: FocusKind; + value: string | undefined; + shortenTo?: number; +}) { + const { focus, toggle } = useFocus(); + if (!value) return ; + const isFocused = focus?.kind === kind && focus.value === value; + const display = value.length > shortenTo ? value.slice(0, shortenTo) : value; + return ( + + ); +} diff --git a/apps/vis/web/src/components/wire/WireRow.tsx b/apps/vis/web/src/components/wire/WireRow.tsx new file mode 100644 index 000000000..51118658c --- /dev/null +++ b/apps/vis/web/src/components/wire/WireRow.tsx @@ -0,0 +1,134 @@ +import { memo } from 'react'; + +import { recordMatchesFocus, useFocus } from '../../lib/focus-context'; +import type { VisWireRecord } from '../../types'; +import { TYPE_CATEGORY, TYPE_CTX_EFFECT } from '../../types'; +import { formatWallClock } from '../../util/time'; +import { TypeBadge } from './TypeBadge'; +import { renderHeadline } from './WireHeadline'; +import { WireRowDetail } from './WireRowDetail'; + +const CAT_COLOR_VAR: Record = { + conversation: '--color-cat-conversation', + config: '--color-cat-config', + lifecycle: '--color-cat-lifecycle', + subagent: '--color-cat-subagent', + approval: '--color-cat-approval', + ephemeral: '--color-cat-ephemeral', + meta: '--color-cat-meta', + tools: '--color-cat-tools', +}; + +interface WireRowProps { + record: VisWireRecord; + expanded: boolean; + onToggle: () => void; + /** For tool_call / tool_result — the counterpart record (if any). */ + paired?: VisWireRecord | undefined; + /** null when `paired` is undefined; true/false when the counterpart exists. */ + pairedInFiltered?: boolean | null; + /** Scroll to a seq and expand it — wired by the Wire tab via the virtualizer. */ + onJumpTo?: (seq: number) => void; +} + +export const WireRow = memo(function WireRow({ + record, + expanded, + onToggle, + paired, + pairedInFiltered, + onJumpTo, +}: WireRowProps) { + const cat = TYPE_CATEGORY[record.type]; + const accentVar = CAT_COLOR_VAR[cat] ?? '--color-fg-3'; + const ctx = TYPE_CTX_EFFECT[record.type]; + const h = renderHeadline(record); + const { focus } = useFocus(); + const related = focus === null ? true : recordMatchesFocus(record, focus); + const timeTitle = formatTimeTitle(record.time); + + return ( +
    +
    +
    + + {expanded ? ( +
    + +
    + ) : null} +
    +
    + ); +}); + +function formatTimeTitle(epochMs: number): string { + if (!epochMs || !Number.isFinite(epochMs)) return 'missing time'; + const date = new Date(epochMs); + if (!Number.isFinite(date.getTime())) return 'invalid time'; + return date.toISOString(); +} + +function Chevron({ open }: { open: boolean }) { + return ( + + ); +} + +function CtxMarker({ effect }: { effect: boolean | 'conditional' }) { + const title = + effect === true + ? 'affects LLM context' + : effect === 'conditional' + ? 'conditionally affects LLM context' + : 'telemetry only — no context effect'; + const symbol = effect === true ? '●' : effect === 'conditional' ? '◑' : '○'; + const color = effect ? 'text-[var(--color-cat-conversation)]' : 'text-fg-3'; + return ( + + {symbol} + + ); +} diff --git a/apps/vis/web/src/components/wire/WireRowDetail.tsx b/apps/vis/web/src/components/wire/WireRowDetail.tsx new file mode 100644 index 000000000..118c75138 --- /dev/null +++ b/apps/vis/web/src/components/wire/WireRowDetail.tsx @@ -0,0 +1,253 @@ +import { useState, type ReactNode } from 'react'; + +import type { VisWireRecord } from '../../types'; +import { CopyButton } from '../shared/CopyButton'; +import { JsonViewer } from '../shared/JsonViewer'; +import { SizePreview } from '../shared/SizePreview'; + +interface WireRowDetailProps { + record: VisWireRecord; + /** Counterpart in a tool_call ↔ tool_result pair (if applicable). */ + paired?: VisWireRecord | undefined; + /** Whether the counterpart is visible in the current filter. null when + * there is no counterpart at all. */ + pairedInFiltered?: boolean | null; + /** Scroll to + expand a given seq. */ + onJumpTo?: (seq: number) => void; +} + +/** Fields that are best rendered as collapsible large-payload blocks rather than + * as key-value pairs. Keyed by the path (dot-separated) from record root. */ +const LARGE_FIELDS: Record = { + new_prompt: true, + system_prompt: true, + summary: true, + think: true, + text: true, + content: true, + output: true, + user_input: true, + 'data.body': true, + 'data.args': true, + 'data.payload': true, + 'data.tail_output': true, + 'data.result_summary': true, + 'data.error': true, + 'data.reason': true, + 'data.content': true, + 'data.summary': true, + result_summary: true, + new_content: true, +}; + +const TOP_LEVEL_META = new Set(['type', 'seq', 'time']); + +export function WireRowDetail({ record, paired, pairedInFiltered, onJumpTo }: WireRowDetailProps) { + const [showRaw, setShowRaw] = useState(false); + const entries = Object.entries(record as Record).filter( + ([k]) => !TOP_LEVEL_META.has(k), + ); + + return ( +
    +
    + {entries.map(([key, value]) => renderField(key, value, key))} +
    + {paired !== undefined ? ( + + ) : null} +
    + + +
    + {showRaw ? ( +
    + +
    + ) : null} +
    + ); +} + +/** Renders the "↳ paired with tool_result (seq N)" section below a + * tool_call row (or the reverse). Shows a compact preview of the + * counterpart and a jump button that scrolls-to + expands that row. */ +function PairedSection({ + self, + paired, + pairedInFiltered, + onJumpTo, +}: { + self: VisWireRecord; + paired: VisWireRecord; + pairedInFiltered: boolean | null; + onJumpTo?: (seq: number) => void; +}) { + const selfIsCall = self.type === 'tool_call'; + const preview = selfIsCall ? previewToolResult(paired) : previewToolCallArgs(paired); + const isError = + paired.type === 'tool_result' && (paired as { is_error?: boolean }).is_error === true; + const hidden = pairedInFiltered === false; + return ( +
    +
    + ↳ paired + {paired.type} + seq + {paired.seq} + {isError ? ( + + error + + ) : null} + {hidden ? (hidden by filter) : null} + +
    + {preview.length > 0 ? ( +
    +          {preview.length > 400 ? preview.slice(0, 400) + '…' : preview}
    +        
    + ) : ( +
    (empty)
    + )} +
    + ); +} + +function previewToolCallArgs(call: VisWireRecord): string { + const args = (call as { data?: { args?: unknown } }).data?.args; + if (args === undefined) return ''; + if (typeof args === 'string') return args; + try { + return JSON.stringify(args, null, 2); + } catch { + // Circular refs / BigInt / etc. — don't fall through to String(unknown) + // which produces "[object Object]" and hides the actual problem. + return '(unserialisable args)'; + } +} + +function previewToolResult(result: VisWireRecord): string { + const out = (result as { output?: unknown }).output; + if (out === undefined || out === null) return ''; + if (typeof out === 'string') return out; + try { + return JSON.stringify(out, null, 2); + } catch { + return '(unserialisable output)'; + } +} + +function renderField(key: string, value: unknown, path: string) { + // Null / undefined + if (value === null || value === undefined) { + return ( + + null + + ); + } + + // Large-payload fields: render via SizePreview with JsonViewer inside + if (LARGE_FIELDS[path] || LARGE_FIELDS[key]) { + if (typeof value === 'string') { + return ( + + +
    {value}
    +
    +
    + ); + } + // non-string large fields: JSON tree + return ( + + + + + + ); + } + + // Primitive + if (typeof value !== 'object') { + const repr = + typeof value === 'string' + ? `"${truncate(value, 160)}"` + : typeof value === 'number' || typeof value === 'boolean' || typeof value === 'bigint' + ? String(value) + : typeof value; + return ( + + {repr} + + ); + } + + // Nested object: inline JsonViewer + return ( + + + + ); +} + +function FieldRow({ + label, + children, + wide = false, +}: { + label: string; + children: ReactNode; + wide?: boolean; +}) { + if (wide) { + return ( + <> +
    + + {label} + +
    {children}
    +
    + + ); + } + return ( + <> + {label} +
    {children}
    + + ); +} + +function typeColor(v: unknown): string { + if (typeof v === 'boolean') return 'text-[var(--color-cat-config)]'; + if (typeof v === 'number') return 'text-[var(--color-sev-info)]'; + if (typeof v === 'string') return 'text-[var(--color-cat-ephemeral)]'; + return 'text-fg-0'; +} + +function truncate(s: string, n: number): string { + return s.length <= n ? s : s.slice(0, n) + '…'; +} diff --git a/apps/vis/web/src/components/wire/WireTab.tsx b/apps/vis/web/src/components/wire/WireTab.tsx new file mode 100644 index 000000000..45b515d20 --- /dev/null +++ b/apps/vis/web/src/components/wire/WireTab.tsx @@ -0,0 +1,524 @@ +import { useVirtualizer } from '@tanstack/react-virtual'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; + +import { FocusProvider, useFocus } from '../../lib/focus-context'; +import { computeIssues, topSeverity } from '../../lib/issues'; +import { + buildMatchContext, + matchesQuery, + parseQuery, + type ParsedQuery, + type QueryToken, +} from '../../lib/query-parser'; +import type { VisWireRecord, WireCategory } from '../../types'; +import { TYPE_CATEGORY } from '../../types'; +import { IssuesDrawer } from './IssuesDrawer'; +import { WireRow } from './WireRow'; + +interface WireTabProps { + records: VisWireRecord[]; + health?: 'ok' | 'broken'; + brokenReason?: string; + warnings?: string[]; +} + +const CATEGORIES: WireCategory[] = [ + 'conversation', + 'tools', + 'approval', + 'subagent', + 'ephemeral', + 'meta', + 'config', + 'lifecycle', +]; + +const CAT_COLOR_VAR: Record = { + conversation: '--color-cat-conversation', + config: '--color-cat-config', + lifecycle: '--color-cat-lifecycle', + subagent: '--color-cat-subagent', + approval: '--color-cat-approval', + ephemeral: '--color-cat-ephemeral', + meta: '--color-cat-meta', + tools: '--color-cat-tools', +}; + +export function WireTab(props: WireTabProps) { + // The FocusProvider wraps the entire tab so IdBadge clicks deep inside + // the virtualized rows can update (and be read by) the toolbar status + // indicator without prop-drilling. + return ( + + + + ); +} + +function WireTabInner({ records, health, brokenReason, warnings = [] }: WireTabProps) { + const [search, setSearch] = useState(''); + const [excluded, setExcluded] = useState(new Set()); + const [expanded, setExpanded] = useState(new Set()); + const [showHelp, setShowHelp] = useState(false); + const [drawerOpen, setDrawerOpen] = useState(false); + const parentRef = useRef(null); + + const query = useMemo(() => parseQuery(search), [search]); + + const matchCtx = useMemo(() => buildMatchContext(records, query), [records, query]); + + const filtered = useMemo(() => { + return records.filter((r) => { + const cat = TYPE_CATEGORY[r.type]; + if (excluded.has(cat)) return false; + return matchesQuery(r, query, matchCtx); + }); + }, [records, excluded, query, matchCtx]); + + // Pair map: tool_call_id → the two sides. Built from the full records[] + // (not `filtered`) so jumping from a filtered view still works when the + // counterpart has been filtered out — the detail panel will just mark it + // "(hidden by current filter)". + const pairMap = useMemo(() => { + const m = new Map(); + for (const r of records) { + if (r.type === 'tool_call') { + const id = (r as { data?: { tool_call_id?: string } }).data?.tool_call_id; + if (id !== undefined) { + const slot = m.get(id) ?? {}; + slot.call = r; + m.set(id, slot); + } + } else if (r.type === 'tool_result') { + const id = (r as { tool_call_id?: string }).tool_call_id; + if (id !== undefined) { + const slot = m.get(id) ?? {}; + slot.result = r; + m.set(id, slot); + } + } + } + return m; + }, [records]); + + /** Find the counterpart for a tool_call / tool_result row (undefined otherwise). */ + const pairedFor = useCallback( + (r: VisWireRecord): VisWireRecord | undefined => { + if (r.type === 'tool_call') { + const id = (r as { data?: { tool_call_id?: string } }).data?.tool_call_id; + return id === undefined ? undefined : pairMap.get(id)?.result; + } + if (r.type === 'tool_result') { + const id = (r as { tool_call_id?: string }).tool_call_id; + return id === undefined ? undefined : pairMap.get(id)?.call; + } + return undefined; + }, + [pairMap], + ); + + // Compute per-category counts over the unfiltered set + const catCounts = useMemo(() => { + const m = new Map(); + for (const r of records) { + const c = TYPE_CATEGORY[r.type]; + m.set(c, (m.get(c) ?? 0) + 1); + } + return m; + }, [records]); + + const turnCount = useMemo(() => records.filter((r) => r.type === 'turn_begin').length, [records]); + + // Issues are computed over the full records[] + file warnings (not the + // filter result) so the pill keeps showing real problems even when the + // user has narrowed their query. Jumping to an issue scrolls the Wire + // list — if the issue's seq is currently filtered out, `jumpToSeq` is + // a no-op and the drawer just closes. + const issues = useMemo(() => computeIssues(records, warnings), [records, warnings]); + const issuesSeverity = topSeverity(issues); + + // Stable estimate — ResizeObserver (wired via `virt.measureElement` ref + // on each item) refines to the actual rendered height. Do NOT vary the + // estimate based on `expanded`: that fights the library's measurement + // cache and produces layout gaps when the two race. + const virt = useVirtualizer({ + count: filtered.length, + getScrollElement: () => parentRef.current, + estimateSize: () => 28, + overscan: 10, + getItemKey: (i) => filtered[i]?.seq ?? i, + }); + + const toggle = useCallback((seq: number) => { + // Trust the library's ResizeObserver to catch the new content size — + // calling `virt.measure()` manually here races with the observer and + // can clear freshly-written cache entries, stranding items at their + // `estimateSize` value and producing visible gaps. + setExpanded((prev) => { + const next = new Set(prev); + if (next.has(seq)) next.delete(seq); + else next.add(seq); + return next; + }); + }, []); + + /** Seq → index lookup built from the filtered list. Doubles as the + * "is this seq currently visible?" test used by pairing + issues jumps. */ + const filteredSeqIdx = useMemo(() => { + const m = new Map(); + for (let i = 0; i < filtered.length; i += 1) { + const r = filtered[i]; + if (r !== undefined) m.set(r.seq, i); + } + return m; + }, [filtered]); + + /** Jump to a specific seq: scroll the row into view and expand it. + * No-op if the seq isn't in the current filtered set — callers can + * check `filteredSeqIdx.has(seq)` ahead of time and disable the link. */ + const jumpToSeq = useCallback( + (seq: number) => { + const idx = filteredSeqIdx.get(seq); + if (idx === undefined) return; + virt.scrollToIndex(idx, { align: 'center' }); + setExpanded((prev) => (prev.has(seq) ? prev : new Set(prev).add(seq))); + }, + [filteredSeqIdx, virt], + ); + + const toggleCat = (c: WireCategory) => { + setExcluded((prev) => { + const next = new Set(prev); + if (next.has(c)) next.delete(c); + else next.add(c); + return next; + }); + }; + + const expandAll = () =>{ setExpanded(new Set(filtered.map((r) => r.seq))); }; + const collapseAll = () =>{ setExpanded(new Set()); }; + + const hasStructuredTokens = query.tokens.some((t) => t.key !== null) || query.errors.length > 0; + + return ( +
    + {/* Toolbar */} +
    +
    + { setSearch(e.target.value); }} + className="w-80 border border-border bg-surface-0 px-2 py-1 pr-7 font-mono text-[12px] text-fg-0 placeholder:text-fg-3 focus:border-border-strong focus:outline-none" + /> + + {showHelp ? { setShowHelp(false); }} /> : null} +
    +
    + {CATEGORIES.map((c) => { + const count = catCounts.get(c) ?? 0; + if (count === 0) return null; + const on = !excluded.has(c); + const color = `var(${CAT_COLOR_VAR[c]})`; + return ( + + ); + })} +
    +
    + + {filtered.length} / {records.length} ev + + {turnCount} turns + {issues.length > 0 && issuesSeverity !== null ? ( + + ) : null} + + +
    +
    + + {/* Query chips — one-line summary of parsed filters, each removable */} + {hasStructuredTokens ? ( +
    + {query.tokens + .filter((t) => t.key !== null) + .map((t, i) => { + const invalid = query.errors.some((e) => e.token === t.raw); + return ( + { setSearch((s) => removeToken(s, t.raw)); }} + /> + ); + })} + {query.errors.length > 0 ? ( + `${e.token}: ${e.reason}`).join('\n')} + > + · {query.errors.length} unknown + + ) : null} +
    + ) : null} + + + + {/* Health warning */} + {health === 'broken' ? ( +
    + broken: {brokenReason ?? 'unknown reason'} +
    + ) : null} + {warnings.length > 0 ? ( +
    + {warnings.length} warning{warnings.length > 1 ? 's' : ''} · first: {warnings[0]} +
    + ) : null} + + {/* Virtualized list */} +
    + {filtered.length === 0 ? ( +
    + no records match the current filter +
    + ) : ( +
    + {virt.getVirtualItems().map((vi) => { + const r = filtered[vi.index]; + if (!r) return null; + // Compute the counterpart once per row — paired lookup runs + // inside a virtualized render loop, so duplicating the call + // meant ~40 redundant ops per scroll tick on deep sessions. + const paired = pairedFor(r); + const pairedInFiltered = paired === undefined ? null : filteredSeqIdx.has(paired.seq); + return ( +
    + { toggle(r.seq); }} + paired={paired} + pairedInFiltered={pairedInFiltered} + onJumpTo={jumpToSeq} + /> +
    + ); + })} +
    + )} +
    + {drawerOpen ? ( + { setDrawerOpen(false); }} + onJumpTo={jumpToSeq} + isSeqVisible={(seq) => filteredSeqIdx.has(seq)} + /> + ) : null} +
    + ); +} + +// ──────── helper subcomponents ──────── + +function QueryChip({ + token, + invalid, + onRemove, +}: { + token: QueryToken; + invalid: boolean; + onRemove: () => void; +}) { + const label = `${token.key}${token.op === '=' ? ':' : token.op}${token.values.join(',')}`; + return ( + + {label} + + + ); +} + +function QueryHelp({ onClose }: { onClose: () => void }) { + // Dismiss on: ESC, or outside click. `onMouseLeave` was too twitchy — + // the panel would flash shut on mouse tremor near its edge. + useEffect(() => { + const onKey = (e: KeyboardEvent) => { + if (e.key === 'Escape') onClose(); + }; + const onDocClick = (e: MouseEvent) => { + const t = e.target as HTMLElement | null; + if (t?.closest('[data-query-help-root]')) return; + onClose(); + }; + window.addEventListener('keydown', onKey); + // defer doc listener one tick so the opening click doesn't immediately close us + const id = window.setTimeout(() => { + document.addEventListener('mousedown', onDocClick); + }, 0); + return () => { + window.removeEventListener('keydown', onKey); + window.clearTimeout(id); + document.removeEventListener('mousedown', onDocClick); + }; + }, [onClose]); + return ( +
    +
    query syntax
    +
      +
    • + type:tool_call · type:user_message,tool_result · + type:!compaction +
    • +
    • + tool:Bash · tool:Read,Write +
    • +
    • + turn:3 · turn:turn_5 +
    • +
    • + seq: + {'>'}100 · seq:<50 · seq:>=200 +
    • +
    • + error:true · error:false +
    • +
    • + agent:sub_abc123 (substring) +
    • +
    • + id:abc123 (matches any id field) +
    • +
    • + bare words → JSON substring (AND) +
    • +
    +
    + tip: click an underlined id in any row to focus all related records · ESC to clear +
    +
    + ); +} + +function FocusStatus() { + const { focus, clear } = useFocus(); + if (focus === null) return null; + return ( +
    + ◎ focus + {focus.kind} + = + + {focus.value.length > 24 ? focus.value.slice(0, 24) + '…' : focus.value} + + +
    + ); +} + +/** Remove a single structured token from the raw search string. Used when + * the user clicks the × on a chip. Matches the token's `raw` text exactly, + * trimming adjacent whitespace. */ +function removeToken(input: string, raw: string): string { + const re = new RegExp(`(^|\\s)${escapeRegExp(raw)}(?=\\s|$)`); + return input.replace(re, '').replaceAll(/\s+/g, ' ').trim(); +} + +function escapeRegExp(s: string): string { + return s.replaceAll(/[.*+?^${}()|[\]\\]/g, String.raw`\$&`); +} diff --git a/apps/vis/web/src/components/wire/typeMeta.ts b/apps/vis/web/src/components/wire/typeMeta.ts new file mode 100644 index 000000000..d965a2b73 --- /dev/null +++ b/apps/vis/web/src/components/wire/typeMeta.ts @@ -0,0 +1,67 @@ +import type { WireCategory, WireRecordType } from '../../types'; + +/** Unicode icon for each wire type. Chosen for distinguishability in a dense list. */ +export const TYPE_ICON: Record = { + metadata: '§', + session_initialized: '✧', + turn_begin: '▶', + turn_end: '◼', + user_message: '▷', + tool_result: '✓', + compaction: '⏪', + system_prompt_changed: '¶', + tools_changed: '⚙', + system_reminder: '!', + notification: '◉', + // Streaming atoms: step boundary, streamed part, tool call + step_begin: '┌', + step_end: '┘', + content_part: '◆', + tool_call: '→', + tool_denied: '⊘', + skill_invoked: '★', + skill_completed: '★', + approval_request: '?', + approval_response: '·', + team_mail: '✉', + subagent_spawned: '┬', + subagent_completed: '└', + subagent_failed: '×', + ownership_changed: '↔', + context_edit: '✂', + context_cleared: '∅', +}; + +/** Tone key used by . Most types fall back to their category color. */ +export const TYPE_TONE: Record< + WireRecordType, + WireCategory | 'user' | 'assistant' | 'tool' | 'compaction' | 'turn' +> = { + metadata: 'meta', + session_initialized: 'config', + turn_begin: 'turn', + turn_end: 'turn', + user_message: 'user', + tool_result: 'tool', + compaction: 'compaction', + system_prompt_changed: 'config', + tools_changed: 'config', + system_reminder: 'ephemeral', + notification: 'meta', + step_begin: 'turn', + step_end: 'turn', + content_part: 'assistant', + tool_call: 'tools', + tool_denied: 'approval', + skill_invoked: 'tools', + skill_completed: 'tools', + approval_request: 'approval', + approval_response: 'approval', + team_mail: 'meta', + subagent_spawned: 'subagent', + subagent_completed: 'subagent', + subagent_failed: 'subagent', + ownership_changed: 'lifecycle', + context_edit: 'lifecycle', + context_cleared: 'lifecycle', +}; diff --git a/apps/vis/web/src/hooks/useContext.ts b/apps/vis/web/src/hooks/useContext.ts new file mode 100644 index 000000000..3b98ee925 --- /dev/null +++ b/apps/vis/web/src/hooks/useContext.ts @@ -0,0 +1,34 @@ +import { useQuery } from '@tanstack/react-query'; +import { api } from '../api'; + +export function useSessionContext(sessionId: string | undefined, enabled = true) { + return useQuery({ + queryKey: ['session', sessionId, 'context'] as const, + queryFn: () => api.getContext(sessionId!), + enabled: !!sessionId && enabled, + }); +} + +export function useSubagentContext( + sessionId: string | undefined, + agentId: string | undefined, + enabled = true, +) { + return useQuery({ + queryKey: ['session', sessionId, 'subagent', agentId, 'context'] as const, + queryFn: () => api.getSubagentContext(sessionId!, agentId!), + enabled: !!sessionId && !!agentId && enabled, + }); +} + +export function useToolResult( + sessionId: string | undefined, + toolCallId: string | undefined, + enabled = true, +) { + return useQuery({ + queryKey: ['session', sessionId, 'tool-result', toolCallId] as const, + queryFn: () => api.getToolResult(sessionId!, toolCallId!), + enabled: !!sessionId && !!toolCallId && enabled, + }); +} diff --git a/apps/vis/web/src/hooks/useSession.ts b/apps/vis/web/src/hooks/useSession.ts new file mode 100644 index 000000000..339541e94 --- /dev/null +++ b/apps/vis/web/src/hooks/useSession.ts @@ -0,0 +1,46 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { api } from '../api'; +import type { SessionSummary } from '../types'; + +export function useSessions() { + return useQuery({ + queryKey: ['sessions'] as const, + queryFn: () => api.listSessions(), + }); +} + +export function useSession(sessionId: string | undefined) { + return useQuery({ + queryKey: ['session', sessionId] as const, + queryFn: () => api.getSession(sessionId!), + enabled: !!sessionId, + }); +} + +export function useDeleteSession() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (sessionId: string) => api.deleteSession(sessionId), + onSuccess: (_result, sessionId) => { + qc.setQueryData(['sessions'], (old) => + old?.filter((s) => s.session_id !== sessionId), + ); + qc.removeQueries({ queryKey: ['session', sessionId] }); + void qc.invalidateQueries({ queryKey: ['sessions'] }); + }, + }); +} + +export function useClearSessions() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: () => api.clearSessions(), + onSuccess: (result) => { + if (result.failed.length === 0) { + qc.setQueryData(['sessions'], []); + qc.removeQueries({ queryKey: ['session'] }); + } + void qc.invalidateQueries({ queryKey: ['sessions'] }); + }, + }); +} diff --git a/apps/vis/web/src/hooks/useSubagents.ts b/apps/vis/web/src/hooks/useSubagents.ts new file mode 100644 index 000000000..075a19a65 --- /dev/null +++ b/apps/vis/web/src/hooks/useSubagents.ts @@ -0,0 +1,21 @@ +import { useQuery } from '@tanstack/react-query'; +import { api } from '../api'; + +export function useSubagents(sessionId: string | undefined, enabled = true) { + return useQuery({ + queryKey: ['session', sessionId, 'subagents'] as const, + queryFn: () => api.getSubagents(sessionId!), + enabled: !!sessionId && enabled, + }); +} + +export function useSubagentMeta( + sessionId: string | undefined, + agentId: string | undefined, +) { + return useQuery({ + queryKey: ['session', sessionId, 'subagent', agentId, 'meta'] as const, + queryFn: () => api.getSubagentMeta(sessionId!, agentId!), + enabled: !!sessionId && !!agentId, + }); +} diff --git a/apps/vis/web/src/hooks/useTheme.ts b/apps/vis/web/src/hooks/useTheme.ts new file mode 100644 index 000000000..5255245d4 --- /dev/null +++ b/apps/vis/web/src/hooks/useTheme.ts @@ -0,0 +1,82 @@ +import { useCallback, useEffect, useState } from 'react'; + +export type ThemeChoice = 'light' | 'dark' | 'auto'; +export type ResolvedTheme = 'light' | 'dark'; + +const STORAGE_KEY = 'vis.theme'; + +function systemTheme(): ResolvedTheme { + if (typeof window === 'undefined') return 'dark'; + return window.matchMedia('(prefers-color-scheme: light)').matches + ? 'light' + : 'dark'; +} + +function readStored(): ThemeChoice { + try { + const v = localStorage.getItem(STORAGE_KEY); + if (v === 'light' || v === 'dark' || v === 'auto') return v; + } catch { + /* ignore quota/permission errors */ + } + return 'auto'; +} + +function apply(resolved: ResolvedTheme): void { + const root = document.documentElement; + root.dataset.theme = resolved; + // Sync the address-bar theme-color so native chrome (URL bar, title bar) + // transitions too. + const meta = document.querySelector('meta[name="theme-color"]'); + if (meta) { + meta.content = resolved === 'light' ? '#fafbfc' : '#0b0d12'; + } +} + +/** + * Three-state theme: auto (follow system), light, dark. The resolved concrete + * theme is reflected on ``. User choice is persisted + * in localStorage; absence ⇒ auto. + */ +export function useTheme(): { + choice: ThemeChoice; + resolved: ResolvedTheme; + cycle: () => void; + set: (c: ThemeChoice) => void; +} { + const [choice, setChoice] = useState(() => readStored()); + const [sys, setSys] = useState(() => systemTheme()); + + // Listen to system changes when in auto mode. + useEffect(() => { + const m = window.matchMedia('(prefers-color-scheme: light)'); + const handler = () =>{ setSys(m.matches ? 'light' : 'dark'); }; + m.addEventListener('change', handler); + return () =>{ m.removeEventListener('change', handler); }; + }, []); + + const resolved: ResolvedTheme = choice === 'auto' ? sys : choice; + + useEffect(() => { + apply(resolved); + }, [resolved]); + + const set = useCallback((c: ThemeChoice) => { + setChoice(c); + try { + if (c === 'auto') localStorage.removeItem(STORAGE_KEY); + else localStorage.setItem(STORAGE_KEY, c); + } catch { + /* ignore */ + } + }, []); + + // Cycle: auto → light → dark → auto + const cycle = useCallback(() => { + const next: ThemeChoice = + choice === 'auto' ? 'light' : choice === 'light' ? 'dark' : 'auto'; + set(next); + }, [choice, set]); + + return { choice, resolved, cycle, set }; +} diff --git a/apps/vis/web/src/hooks/useWire.ts b/apps/vis/web/src/hooks/useWire.ts new file mode 100644 index 000000000..44b3b9737 --- /dev/null +++ b/apps/vis/web/src/hooks/useWire.ts @@ -0,0 +1,33 @@ +import { useQuery } from '@tanstack/react-query'; +import { api } from '../api'; + +export function useWire(sessionId: string | undefined, enabled = true) { + return useQuery({ + queryKey: ['session', sessionId, 'wire'] as const, + queryFn: () => api.getWire(sessionId!), + enabled: !!sessionId && enabled, + }); +} + +export function useSubagentWire( + sessionId: string | undefined, + agentId: string | undefined, + enabled = true, +) { + return useQuery({ + queryKey: ['session', sessionId, 'subagent', agentId, 'wire'] as const, + queryFn: () => api.getSubagentWire(sessionId!, agentId!), + enabled: !!sessionId && !!agentId && enabled, + }); +} + +export function useArchive( + sessionId: string | undefined, + filename: string | undefined, +) { + return useQuery({ + queryKey: ['session', sessionId, 'archive', filename] as const, + queryFn: () => api.getArchive(sessionId!, filename!), + enabled: !!sessionId && !!filename, + }); +} diff --git a/apps/vis/web/src/lib/focus-context.tsx b/apps/vis/web/src/lib/focus-context.tsx new file mode 100644 index 000000000..4174ba107 --- /dev/null +++ b/apps/vis/web/src/lib/focus-context.tsx @@ -0,0 +1,96 @@ +import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react'; +import type { ReactNode } from 'react'; + +import type { VisWireRecord } from '../types'; + +export type FocusKind = 'turn_id' | 'tool_call_id' | 'agent_id' | 'step_uuid'; + +export interface Focus { + kind: FocusKind; + value: string; +} + +interface FocusContextShape { + focus: Focus | null; + /** Toggle — clicking the same focused id again clears it. */ + toggle: (f: Focus) => void; + clear: () => void; +} + +const FocusCtx = createContext(null); + +export function FocusProvider({ children }: { children: ReactNode }) { + const [focus, setFocus] = useState(null); + const toggle = useCallback((f: Focus) => { + setFocus((prev) => + prev !== null && prev.kind === f.kind && prev.value === f.value ? null : f, + ); + }, []); + const clear = useCallback(() =>{ setFocus(null); }, []); + // ESC clears the focus — one-liner keyboard affordance. + useEffect(() => { + const onKey = (e: KeyboardEvent) => { + if (e.key === 'Escape') clear(); + }; + window.addEventListener('keydown', onKey); + return () =>{ window.removeEventListener('keydown', onKey); }; + }, [clear]); + const value = useMemo( + () => ({ focus, toggle, clear }), + [focus, toggle, clear], + ); + return {children}; +} + +export function useFocus(): FocusContextShape { + const ctx = useContext(FocusCtx); + if (ctx === null) { + // Safe default so components outside a provider don't crash. + return { focus: null, toggle: () => {}, clear: () => {} }; + } + return ctx; +} + +/** + * A record "belongs to" the focused id when any of its identifiers matches. + * Scope is intentionally broad per id kind so click-to-focus behaves like + * "show me everything related to this thing": + * + * turn_id → all records with r.turn_id === value + * tool_call_id → the tool_call, its tool_result, any approval linked by id + * agent_id → spawn/completed/failed + mail records mentioning this agent + * step_uuid → the step_begin/end + content_parts + tool_calls under it + */ +export function recordMatchesFocus(r: VisWireRecord, focus: Focus | null): boolean { + if (focus === null) return true; + const { kind, value } = focus; + const rec = r as Record; + const data = (rec['data'] as Record | undefined) ?? undefined; + + switch (kind) { + case 'turn_id': + return rec['turn_id'] === value; + + case 'tool_call_id': { + if (rec['tool_call_id'] === value) return true; + if (data?.['tool_call_id'] === value) return true; + if (data?.['parent_tool_call_id'] === value) return true; + return false; + } + + case 'agent_id': { + if (rec['agent_id'] === value) return true; + if (data?.['agent_id'] === value) return true; + if (data?.['parent_agent_id'] === value) return true; + if (data?.['from_agent'] === value) return true; + if (data?.['to_agent'] === value) return true; + return false; + } + + case 'step_uuid': + return rec['uuid'] === value || rec['step_uuid'] === value; + } + // Unreachable — `FocusKind` is exhausted above. Here to satisfy + // consistent-return (switch-exhaustiveness isn't narrowed by the rule). + return false; +} diff --git a/apps/vis/web/src/lib/issues.ts b/apps/vis/web/src/lib/issues.ts new file mode 100644 index 000000000..6bde9d7f9 --- /dev/null +++ b/apps/vis/web/src/lib/issues.ts @@ -0,0 +1,182 @@ +// Aggregate every "something went wrong" signal from a wire timeline +// into a flat list consumable by the Issues drawer. Pure — no React. + +import type { VisWireRecord } from '../types'; + +export type IssueSeverity = 'error' | 'warning' | 'info'; + +export interface Issue { + severity: IssueSeverity; + /** Human-readable kind — shown as the row's title. */ + kind: + | 'subagent_failed' + | 'tool_error' + | 'tool_denied' + | 'turn_failed' + | 'step_truncated' + | 'wire_warning'; + /** Seq of the offending record. `null` for file-level warnings. */ + seq: number | null; + /** Short summary shown on a single line. */ + summary: string; + /** Optional second line / tooltip detail. */ + detail?: string; +} + +const SEVERITY_ORDER: Record = { + error: 0, + warning: 1, + info: 2, +}; + +/** Scan `records` + `warnings` and produce an ordered issue list. + * Sorted by severity first, then seq ascending. Warnings (no seq) go last. */ +export function computeIssues( + records: readonly VisWireRecord[], + warnings: readonly string[], +): Issue[] { + const out: Issue[] = []; + + for (const r of records) { + switch (r.type) { + case 'subagent_failed': { + const d = (r as { data?: { error?: string; agent_id?: string } }).data ?? {}; + out.push({ + severity: 'error', + kind: 'subagent_failed', + seq: r.seq, + summary: firstLine(d.error ?? '(no error message)'), + detail: d.agent_id ? `agent ${d.agent_id}` : undefined, + }); + break; + } + case 'tool_result': { + const rec = r as { is_error?: boolean; output?: unknown; tool_call_id?: string }; + if (rec.is_error === true) { + const text = + typeof rec.output === 'string' + ? rec.output + : rec.output !== undefined + ? (() => { + try { + return JSON.stringify(rec.output); + } catch { + return '(unserializable output)'; + } + })() + : ''; + out.push({ + severity: 'error', + kind: 'tool_error', + seq: r.seq, + summary: firstLine(text) || '(no output)', + detail: rec.tool_call_id ? `call ${rec.tool_call_id}` : undefined, + }); + } + break; + } + case 'tool_denied': { + const d = + (r as { data?: { tool_name?: string; reason?: string; rule_id?: string } }).data ?? {}; + out.push({ + severity: 'warning', + kind: 'tool_denied', + seq: r.seq, + summary: `${d.tool_name ?? 'tool'} — ${firstLine(d.reason ?? '(no reason)')}`, + detail: d.rule_id ? `rule ${d.rule_id}` : undefined, + }); + break; + } + case 'turn_end': { + const rec = r as { + success?: boolean; + reason?: string; + turn_id?: string; + synthetic?: boolean; + }; + if (rec.success === false || rec.reason === 'error' || rec.reason === 'interrupted') { + out.push({ + severity: 'warning', + kind: 'turn_failed', + seq: r.seq, + summary: `turn ${rec.turn_id ?? ''} — ${rec.reason ?? 'unknown'}`, + detail: rec.synthetic === true ? 'synthetic' : undefined, + }); + } + break; + } + case 'step_end': { + const rec = r as { finish_reason?: string; turn_id?: string }; + if (rec.finish_reason === 'length' || rec.finish_reason === 'error') { + out.push({ + severity: 'info', + kind: 'step_truncated', + seq: r.seq, + summary: `step finished with "${rec.finish_reason}"`, + detail: rec.turn_id ? `turn ${rec.turn_id}` : undefined, + }); + } + break; + } + case 'approval_request': + case 'approval_response': + case 'compaction': + case 'content_part': + case 'context_cleared': + case 'context_edit': + case 'metadata': + case 'notification': + case 'ownership_changed': + case 'session_initialized': + case 'skill_completed': + case 'skill_invoked': + case 'step_begin': + case 'subagent_completed': + case 'subagent_spawned': + case 'system_prompt_changed': + case 'system_reminder': + case 'team_mail': + case 'tool_call': + case 'tools_changed': + case 'turn_begin': + case 'user_message': + break; + default: + break; + } + } + + for (const w of warnings) { + out.push({ + severity: 'warning', + kind: 'wire_warning', + seq: null, + summary: firstLine(w), + }); + } + + out.sort((a, b) => { + const d = SEVERITY_ORDER[a.severity] - SEVERITY_ORDER[b.severity]; + if (d !== 0) return d; + const sa = a.seq ?? Number.POSITIVE_INFINITY; + const sb = b.seq ?? Number.POSITIVE_INFINITY; + return sa - sb; + }); + + return out; +} + +/** Top-level summary tone used for the toolbar pill — "worst wins". */ +export function topSeverity(issues: readonly Issue[]): IssueSeverity | null { + if (issues.length === 0) return null; + for (const i of issues) if (i.severity === 'error') return 'error'; + for (const i of issues) if (i.severity === 'warning') return 'warning'; + return 'info'; +} + +function firstLine(s: string): string { + const trimmed = s.trim(); + const nl = trimmed.indexOf('\n'); + const one = nl === -1 ? trimmed : trimmed.slice(0, nl); + return one.length > 120 ? one.slice(0, 120) + '…' : one; +} diff --git a/apps/vis/web/src/lib/query-parser.ts b/apps/vis/web/src/lib/query-parser.ts new file mode 100644 index 000000000..67c669133 --- /dev/null +++ b/apps/vis/web/src/lib/query-parser.ts @@ -0,0 +1,295 @@ +// Structured query language for the Wire tab's search box. +// +// Grammar (whitespace-separated; AND between tokens): +// key:value exact/contains match on a specific field +// key:a,b,c OR across values +// key:!value NOT match +// key:>N / =N / <=N numeric comparison (seq only) +// bare word substring match on full record JSON (fallback) +// +// Supported keys: +// type — r.type exact (OR-able, NOT-able) +// tool — tool_call.data.tool_name substring (also matches paired tool_result) +// turn — r.turn_id substring +// seq — numeric compare on r.seq +// error — boolean; true → records that represent failure/error states +// agent — substring over any agent_id field +// id — substring over any *_id / uuid field +// +// Unknown keys become `errors[]` entries; chip UI shows them in red. + +import type { VisWireRecord, WireRecordType } from '../types'; + +export type TokenOp = '=' | '!=' | '>' | '<' | '>=' | '<=' | '~'; + +export interface QueryToken { + /** null = bare text token (substring across whole record). */ + key: string | null; + op: TokenOp; + /** Comma-split values; length >= 1 always. */ + values: string[]; + /** Source slice in the query string; useful to rewrite/remove a single chip. */ + raw: string; +} + +export interface ParsedQuery { + tokens: QueryToken[]; + errors: { token: string; reason: string }[]; +} + +const KNOWN_KEYS = new Set(['type', 'tool', 'turn', 'seq', 'error', 'agent', 'id']); + +export function parseQuery(input: string): ParsedQuery { + const tokens: QueryToken[] = []; + const errors: { token: string; reason: string }[] = []; + // Simple whitespace split — no quoted-string support yet (keep it minimal). + const parts = input.split(/\s+/).filter((s) => s.length > 0); + for (const raw of parts) { + const colon = raw.indexOf(':'); + if (colon <= 0) { + tokens.push({ key: null, op: '~', values: [raw], raw }); + continue; + } + const key = raw.slice(0, colon); + let rest = raw.slice(colon + 1); + let op: TokenOp = '='; + if (rest.startsWith('!')) { + op = '!='; + rest = rest.slice(1); + } else if (rest.startsWith('>=')) { + op = '>='; + rest = rest.slice(2); + } else if (rest.startsWith('<=')) { + op = '<='; + rest = rest.slice(2); + } else if (rest.startsWith('>')) { + op = '>'; + rest = rest.slice(1); + } else if (rest.startsWith('<')) { + op = '<'; + rest = rest.slice(1); + } + const values = rest.split(',').filter((s) => s.length > 0); + if (values.length === 0) { + errors.push({ token: raw, reason: 'missing value' }); + continue; + } + if (!KNOWN_KEYS.has(key)) { + errors.push({ + token: raw, + reason: `unknown key "${key}" — try: ${[...KNOWN_KEYS].join(', ')}`, + }); + // Still push so the chip can render (in red) and be removable. + tokens.push({ key, op, values, raw }); + continue; + } + tokens.push({ key, op, values, raw }); + } + return { tokens, errors }; +} + +/** Pre-compute helper sets used by some matchers. Call once per records[] + query. */ +export interface MatchContext { + /** tool_call_ids of tool_call records whose tool_name matches a `tool:` token. */ + toolMatchingCallIds: Set; +} + +export function buildMatchContext( + records: readonly VisWireRecord[], + query: ParsedQuery, +): MatchContext { + const ctx: MatchContext = { toolMatchingCallIds: new Set() }; + const toolTokens = query.tokens.filter((t) => t.key === 'tool'); + if (toolTokens.length === 0) return ctx; + for (const r of records) { + if (r.type !== 'tool_call') continue; + const name = getToolName(r); + if (name === null) continue; + const lower = name.toLowerCase(); + const anyMatch = toolTokens.every((t) => matchTextToken(lower, t)); + if (!anyMatch) continue; + const id = getToolCallId(r); + if (id !== null) ctx.toolMatchingCallIds.add(id); + } + return ctx; +} + +export function matchesQuery( + record: VisWireRecord, + query: ParsedQuery, + ctx: MatchContext, +): boolean { + if (query.tokens.length === 0) return true; + for (const token of query.tokens) { + if (!matchesOne(record, token, ctx)) return false; + } + return true; +} + +function matchesOne(r: VisWireRecord, t: QueryToken, ctx: MatchContext): boolean { + if (t.key === null) { + // Bare text — fallback to JSON substring, case-insensitive. + const needle = (t.values[0] ?? '').toLowerCase(); + if (needle.length === 0) return true; + try { + return JSON.stringify(r).toLowerCase().includes(needle); + } catch { + return false; + } + } + switch (t.key) { + case 'type': + return matchType(r.type, t); + case 'tool': + return matchTool(r, t, ctx); + case 'turn': + return matchText(getField(r, 'turn_id'), t); + case 'seq': + return matchNumber(r.seq, t); + case 'error': + return matchError(r, t); + case 'agent': + return matchAgent(r, t); + case 'id': + return matchAnyId(r, t); + default: + // Unknown key — treat as no-match so chip is clearly inert. + return false; + } +} + +function matchType(type: WireRecordType, t: QueryToken): boolean { + const hit = t.values.includes(type); + return t.op === '!=' ? !hit : hit; +} + +function matchTool(r: VisWireRecord, t: QueryToken, ctx: MatchContext): boolean { + if (r.type === 'tool_call') { + const name = getToolName(r); + return name !== null && matchTextToken(name.toLowerCase(), t); + } + if (r.type === 'tool_result') { + const id = getField(r, 'tool_call_id'); + return id !== null && ctx.toolMatchingCallIds.has(id); + } + if (r.type === 'tool_denied') { + const name = (getField(r, 'data.tool_name') ?? '').toLowerCase(); + return matchTextToken(name, t); + } + return false; +} + +function matchText(value: string | null, t: QueryToken): boolean { + if (value === null) return t.op === '!='; + return matchTextToken(value.toLowerCase(), t); +} + +function matchTextToken(haystackLower: string, t: QueryToken): boolean { + const hit = textHit(haystackLower, t); + return t.op === '!=' ? !hit : hit; +} + +/** Pure "does this haystack contain any of the token values?" — no op + * inversion. Callers that check multiple candidate fields must invert + * the combined result themselves, otherwise `agent:!abc` over a record + * that has one matching field and three null fields would incorrectly + * flip for the null fields and report success. */ +function textHit(haystackLower: string, t: QueryToken): boolean { + return t.values.some((v) => haystackLower.includes(v.toLowerCase())); +} + +function matchNumber(value: number, t: QueryToken): boolean { + const n = Number(t.values[0]); + if (!Number.isFinite(n)) return false; + switch (t.op) { + case '>': + return value > n; + case '<': + return value < n; + case '>=': + return value >= n; + case '<=': + return value <= n; + case '!=': + return value !== n; + case '=': + case '~': + return value === n; + default: + return false; + } +} + +function matchError(r: VisWireRecord, t: QueryToken): boolean { + const want = (t.values[0] ?? 'true').toLowerCase() !== 'false'; + const isError = + r.type === 'subagent_failed' || + r.type === 'tool_denied' || + (r.type === 'tool_result' && (r as { is_error?: boolean }).is_error === true) || + (r.type === 'turn_end' && (r as { success?: boolean }).success === false); + return want ? isError : !isError; +} + +function matchAgent(r: VisWireRecord, t: QueryToken): boolean { + const candidates: string[] = [ + getField(r, 'agent_id'), + getField(r, 'data.agent_id'), + getField(r, 'parent_agent_id'), + getField(r, 'data.parent_agent_id'), + ].filter((c): c is string => c !== null); + return matchAcrossCandidates(candidates, t); +} + +function matchAnyId(r: VisWireRecord, t: QueryToken): boolean { + const candidates: string[] = [ + getField(r, 'uuid'), + getField(r, 'step_uuid'), + getField(r, 'turn_id'), + getField(r, 'tool_call_id'), + getField(r, 'session_id'), + getField(r, 'agent_id'), + getField(r, 'data.agent_id'), + getField(r, 'data.tool_call_id'), + getField(r, 'data.mail_id'), + ].filter((c): c is string => c !== null); + return matchAcrossCandidates(candidates, t); +} + +/** op='=' : any candidate contains any token value + * op='!=': NO candidate contains any token value + * Handles the op inversion at the aggregate level so records with + * multiple candidate fields behave sanely (the per-field `matchTextToken` + * would flip on each null/non-match independently — wrong for multi-field + * records). */ +function matchAcrossCandidates(candidates: string[], t: QueryToken): boolean { + const anyHit = candidates.some((c) => textHit(c.toLowerCase(), t)); + return t.op === '!=' ? !anyHit : anyHit; +} + +// ── field access helpers ───────────────────────────────────────────── + +function getField(r: VisWireRecord, path: string): string | null { + const parts = path.split('.'); + let cur: unknown = r; + for (const p of parts) { + if (cur === null || cur === undefined || typeof cur !== 'object') return null; + cur = (cur as Record)[p]; + } + return typeof cur === 'string' ? cur : null; +} + +function getToolName(r: VisWireRecord): string | null { + // Prefer `tool_call.data.tool_name`; fall back to a legacy top-level field + // for older records. + const data = (r as { data?: Record }).data; + if (data && typeof data['tool_name'] === 'string') return data['tool_name']; + const top = (r as Record)['tool_name']; + return typeof top === 'string' ? top : null; +} + +function getToolCallId(r: VisWireRecord): string | null { + const data = (r as { data?: Record }).data; + if (data && typeof data['tool_call_id'] === 'string') return data['tool_call_id']; + const top = (r as Record)['tool_call_id']; + return typeof top === 'string' ? top : null; +} diff --git a/apps/vis/web/src/main.tsx b/apps/vis/web/src/main.tsx new file mode 100644 index 000000000..7d903ffe9 --- /dev/null +++ b/apps/vis/web/src/main.tsx @@ -0,0 +1,30 @@ +import { StrictMode } from 'react'; +import { createRoot } from 'react-dom/client'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { BrowserRouter } from 'react-router-dom'; +import { App } from './App'; +import './theme.css'; + +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + staleTime: Infinity, + gcTime: 30 * 60 * 1000, + retry: false, + refetchOnWindowFocus: false, + }, + }, +}); + +const rootEl = document.querySelector('#root'); +if (!rootEl) throw new Error('#root not found'); + +createRoot(rootEl).render( + + + + + + + , +); diff --git a/apps/vis/web/src/pages/SessionDetailPage.tsx b/apps/vis/web/src/pages/SessionDetailPage.tsx new file mode 100644 index 000000000..649b3cb0b --- /dev/null +++ b/apps/vis/web/src/pages/SessionDetailPage.tsx @@ -0,0 +1,158 @@ +import { useParams } from 'react-router-dom'; +import { useSession } from '../hooks/useSession'; +import { useWire } from '../hooks/useWire'; +import { useSessionContext } from '../hooks/useContext'; +import { useSubagents } from '../hooks/useSubagents'; +import { TabBar, useActiveTab } from '../components/layout/TabBar'; +import { WireTab } from '../components/wire/WireTab'; +import { ContextTab } from '../components/context/ContextTab'; +import { SubagentsTab } from '../components/subagents/SubagentsTab'; +import { StateTab } from '../components/state/StateTab'; +import { FilesTab } from '../components/files/FilesTab'; +import { CopyButton } from '../components/shared/CopyButton'; +import { Pill } from '../components/shared/Pill'; +import { formatAbsoluteTime, formatRelativeTime } from '../util/time'; + +type TabId = 'wire' | 'context' | 'subagents' | 'state' | 'files'; + +export function SessionDetailPage() { + const { sessionId } = useParams<{ sessionId: string }>(); + const active = useActiveTab('wire') as TabId; + + const { data: session, isLoading: sessionLoading, error: sessionError } = useSession(sessionId); + + // Eagerly count subagents from the session detail; fetch wire/context/subagents + // only when their tab is open. + const wireQ = useWire(sessionId, active === 'wire'); + const contextQ = useSessionContext(sessionId, active === 'context'); + const subQ = useSubagents(sessionId, active === 'subagents'); + + if (!sessionId) return
    (no session id)
    ; + if (sessionLoading) return
    loading session…
    ; + if (sessionError) + return ( +
    + {(sessionError).message} +
    + ); + if (!session) return null; + + return ( +
    + {/* Detail header */} +
    +
    + {session.session_id} + + {session.state.model ? ( + {session.state.model} + ) : null} + {session.state.archived ? ( + archived + ) : null} + {session.state.permission_mode === 'bypassPermissions' ? ( + yolo + ) : null} + {session.title ? ( + + "{session.title}" + + ) : null} +
    +
    + {session.state.workspace_dir ? ( + + {session.state.workspace_dir} + + ) : null} + {session.state.updated_at ? ( + + updated {formatRelativeTime(session.state.updated_at)} ·{' '} + {formatAbsoluteTime(session.state.updated_at)} + + ) : null} +
    + {session.last_prompt ? ( +
    + prompt · {session.last_prompt} +
    + ) : null} +
    + + + +
    + {active === 'wire' ? ( + wireQ.isLoading ? ( + loading wire… + ) : wireQ.error ? ( + + ) : wireQ.data ? ( + + ) : null + ) : null} + + {active === 'context' ? ( + contextQ.isLoading ? ( + loading context… + ) : contextQ.error ? ( + + ) : contextQ.data ? ( + + ) : null + ) : null} + + {active === 'subagents' ? ( + subQ.isLoading ? ( + loading subagents… + ) : ( + + ) + ) : null} + + {active === 'state' ? ( + + ) : null} + + {active === 'files' ? ( + + ) : null} +
    +
    + ); +} + +function Centered({ children }: { children: import('react').ReactNode }) { + return ( +
    + {children} +
    + ); +} + +function ErrorView({ msg }: { msg: string }) { + return ( +
    {msg}
    + ); +} diff --git a/apps/vis/web/src/pages/SessionListPage.tsx b/apps/vis/web/src/pages/SessionListPage.tsx new file mode 100644 index 000000000..556ca63b3 --- /dev/null +++ b/apps/vis/web/src/pages/SessionListPage.tsx @@ -0,0 +1,35 @@ +import { useSessions } from '../hooks/useSession'; + +export function SessionListPage() { + const { data } = useSessions(); + const count = data?.length ?? 0; + + return ( +
    +
    +
    + kimi vis +
    +
    + select a session from the left rail to begin inspecting +
    +
    +
    +
    {count}
    +
    sessions
    +
    +
    +
    +
    + / + focus search +
    +
    + esc + close drawers +
    +
    +
    +
    + ); +} diff --git a/apps/vis/web/src/pages/SubagentDetailPage.tsx b/apps/vis/web/src/pages/SubagentDetailPage.tsx new file mode 100644 index 000000000..c5ef4868f --- /dev/null +++ b/apps/vis/web/src/pages/SubagentDetailPage.tsx @@ -0,0 +1,206 @@ +import { Link, useParams } from 'react-router-dom'; +import { useSubagentWire } from '../hooks/useWire'; +import { useSubagentContext } from '../hooks/useContext'; +import { useSubagentMeta } from '../hooks/useSubagents'; +import { TabBar, useActiveTab } from '../components/layout/TabBar'; +import { WireTab } from '../components/wire/WireTab'; +import { ContextTab } from '../components/context/ContextTab'; +import { JsonViewer } from '../components/shared/JsonViewer'; +import { Pill } from '../components/shared/Pill'; +import { formatAbsoluteTime, formatRelativeTime } from '../util/time'; + +type TabId = 'wire' | 'context' | 'meta'; + +export function SubagentDetailPage() { + const { sessionId, agentId } = useParams<{ sessionId: string; agentId: string }>(); + const active = useActiveTab('wire') as TabId; + + const wireQ = useSubagentWire(sessionId, agentId, active === 'wire'); + const contextQ = useSubagentContext(sessionId, agentId, active === 'context'); + const metaQ = useSubagentMeta(sessionId, agentId); + + if (!sessionId || !agentId) return null; + + return ( +
    + {/* Breadcrumb + header */} +
    +
    + + ‹ back to subagents + + · + main + + + {agentId.replace(/^sub_/, '').slice(0, 12)} + +
    +
    + {agentId} + {metaQ.data?.meta_json?.subagent_type ? ( + + {metaQ.data.meta_json.subagent_type} + + ) : null} + {metaQ.data?.meta_json?.status ? ( + + {metaQ.data.meta_json.status} + + ) : null} + {metaQ.data?.depth !== undefined ? ( + + depth {metaQ.data.depth} + + ) : null} +
    + {metaQ.data?.meta_json?.description ? ( +
    + {metaQ.data.meta_json.description} +
    + ) : null} +
    + + + +
    + {active === 'wire' ? ( + wireQ.isLoading ? ( + loading wire… + ) : wireQ.error ? ( + + ) : wireQ.data ? ( + + ) : null + ) : null} + + {active === 'context' ? ( + contextQ.isLoading ? ( + loading context… + ) : contextQ.error ? ( + + ) : contextQ.data ? ( + + ) : null + ) : null} + + {active === 'meta' ? : null} +
    +
    + ); +} + +function MetaView({ meta, error }: { meta: unknown; error: unknown }) { + if (error) { + return ; + } + if (!meta) return loading meta…; + const m = meta as { + meta_json: unknown; + spawned_record: unknown; + completed_record: unknown; + failed_record: unknown; + }; + return ( +
    + + + + + +
    + ); +} + +function MetaSection({ + title, + subtitle, + value, +}: { + title: string; + subtitle?: string; + value: unknown; +}) { + return ( +
    +

    + {title} + {subtitle ? {subtitle} : null} +

    +
    + {value === null || value === undefined ? ( + (not present) + ) : ( + + )} +
    +
    + ); +} + +function TimestampsSection({ spawned, completed, failed }: { spawned: unknown; completed: unknown; failed: unknown }) { + const rows = [ + spawned ? ['spawn', (spawned as { time?: number }).time] : null, + completed ? ['complete', (completed as { time?: number }).time] : null, + failed ? ['failure', (failed as { time?: number }).time] : null, + ].filter(Boolean) as [string, number | undefined][]; + if (rows.length === 0) return null; + return ( +
    +

    timeline

    +
    + + + {rows.map(([label, t]) => ( + + + + + ))} + +
    + {label} + + {t !== undefined ? ( + <> + {formatAbsoluteTime(t)}{' '} + ({formatRelativeTime(t)}) + + ) : ( + + )} +
    +
    +
    + ); +} + +function Centered({ children }: { children: import('react').ReactNode }) { + return ( +
    + {children} +
    + ); +} + +function ErrorView({ msg }: { msg: string }) { + return ( +
    {msg}
    + ); +} diff --git a/apps/vis/web/src/theme.css b/apps/vis/web/src/theme.css new file mode 100644 index 000000000..aa9f62309 --- /dev/null +++ b/apps/vis/web/src/theme.css @@ -0,0 +1,239 @@ +@import "tailwindcss"; + +/* ───────────────────────────────────────────────────────────────────────── + Design tokens. Tailwind v4 @theme registers the variables AND generates + utilities like `bg-surface-0`, `text-fg-0`. Dark theme is the baseline; + light theme overrides the same variable names below. + ───────────────────────────────────────────────────────────────────────── */ + +@theme { + --font-mono: "JetBrains Mono", "SF Mono", Menlo, Consolas, monospace; + --font-ui: "Geist", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + + /* Surface (dark baseline) */ + --color-surface-0: #0b0d12; + --color-surface-1: #12151c; + --color-surface-2: #1a1e27; + --color-surface-3: #242936; + --color-border: #1f2430; + --color-border-strong: #2d3342; + + /* Foreground (dark baseline) */ + --color-fg-0: #e4e7ef; + --color-fg-1: #a8afc0; + --color-fg-2: #6c7488; + --color-fg-3: #454c5e; + + /* Text color used on top of a solid accent pill (so contrast stays + legible regardless of which accent). Dark theme: near-black. */ + --color-on-accent: #0b0d12; + + /* Category accents (dark baseline) */ + --color-cat-conversation: #22d3ee; + --color-cat-config: #a78bfa; + --color-cat-lifecycle: #94a3b8; + --color-cat-subagent: #8b5cf6; + --color-cat-approval: #fb923c; + --color-cat-ephemeral: #facc15; + --color-cat-meta: #6366f1; + --color-cat-tools: #f59e0b; + + /* Conversation subfamily */ + --color-user: #38bdf8; + --color-assistant: #22d3ee; + --color-tool: #f59e0b; + --color-compaction: #f97316; + --color-turn: #14b8a6; + + /* Severity */ + --color-sev-info: #60a5fa; + --color-sev-success: #4ade80; + --color-sev-warning: #fbbf24; + --color-sev-error: #f87171; +} + +/* ───────────────────────────────────────────────────────────────────────── + Light-theme overrides. Applied when . + Accents get darker variants for AA contrast on light backgrounds. + ───────────────────────────────────────────────────────────────────────── */ + +:root[data-theme="light"] { + color-scheme: light; + + --color-surface-0: #fafbfc; + --color-surface-1: #eef1f6; + --color-surface-2: #e4e8f0; + --color-surface-3: #d4d9e4; + --color-border: #dde1ea; + --color-border-strong: #bfc6d4; + + --color-fg-0: #0f1219; + --color-fg-1: #3a4154; + --color-fg-2: #616878; + --color-fg-3: #9097a8; + + --color-on-accent: #fafbfc; + + --color-cat-conversation: #0891b2; + --color-cat-config: #7c3aed; + --color-cat-lifecycle: #475569; + --color-cat-subagent: #6d28d9; + --color-cat-approval: #c2410c; + --color-cat-ephemeral: #a16207; + --color-cat-meta: #4338ca; + --color-cat-tools: #b45309; + + --color-user: #0369a1; + --color-assistant: #0891b2; + --color-tool: #b45309; + --color-compaction: #c2410c; + --color-turn: #0d9488; + + --color-sev-info: #2563eb; + --color-sev-success: #15803d; + --color-sev-warning: #b45309; + --color-sev-error: #b91c1c; +} + +:root[data-theme="dark"] { + color-scheme: dark; +} + +/* ───────────────────────────────────────────────────────────────────────── + Base + ───────────────────────────────────────────────────────────────────────── */ + +html, +body, +#root { + height: 100%; + margin: 0; +} + +body { + font-family: var(--font-ui); + font-size: 13px; + line-height: 1.45; + background-color: var(--color-surface-0); + color: var(--color-fg-0); +} + +/* Subtle noise grain only on dark — adds industrial texture. On light it + just looks dirty, so omit. */ +:root:not([data-theme="light"]) body { + background-image: url("data:image/svg+xml;utf8,"); + background-repeat: repeat; +} + +/* Force-disable ligatures and contextual alternates in monospace — debug + tool must render characters verbatim. */ +.font-mono { + font-feature-settings: "liga" 0, "calt" 0, "ss01" 0; + font-variant-ligatures: none; + font-family: var(--font-mono); +} + +.tabular { + font-variant-numeric: tabular-nums; +} + +/* Scrollbar */ +* { + scrollbar-width: thin; + scrollbar-color: var(--color-border-strong) transparent; +} + +*::-webkit-scrollbar { + width: 10px; + height: 10px; +} + +*::-webkit-scrollbar-track { + background: transparent; +} + +*::-webkit-scrollbar-thumb { + background: var(--color-border-strong); + border-radius: 0; + border: 2px solid transparent; + background-clip: padding-box; +} + +*::-webkit-scrollbar-thumb:hover { + background: var(--color-fg-3); + background-clip: padding-box; + border: 2px solid transparent; +} + +::selection { + background-color: color-mix(in oklab, var(--color-cat-conversation) 30%, transparent); + color: var(--color-fg-0); +} + +*:focus-visible { + outline: 1px solid var(--color-cat-conversation); + outline-offset: 1px; +} + +/* Button + anchor element resets — kept inside @layer base so Tailwind + utilities (px-*, py-*, etc.) in the utilities layer win over them. + Unlayered rules would otherwise beat layered utilities in the cascade. */ +@layer base { + button { + font-family: inherit; + font-size: inherit; + background: none; + border: none; + padding: 0; + color: inherit; + cursor: pointer; + } + + a { + color: inherit; + text-decoration: none; + } +} + +.rule { + border-top: 1px solid var(--color-border); +} + +.accent-bar { + width: 3px; + flex-shrink: 0; + align-self: stretch; +} + +.ephemeral-border { + border: 1px dashed var(--color-cat-ephemeral); + border-radius: 2px; +} + +.ephemeral-border-info { + border-color: var(--color-sev-info); +} +.ephemeral-border-success { + border-color: var(--color-sev-success); +} +.ephemeral-border-warning { + border-color: var(--color-sev-warning); +} +.ephemeral-border-error { + border-color: var(--color-sev-error); +} + +.pill { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 1px 6px; + font-family: var(--font-mono); + font-size: 10.5px; + font-weight: 500; + letter-spacing: 0.02em; + text-transform: uppercase; + border-radius: 2px; + white-space: nowrap; + line-height: 1.4; +} diff --git a/apps/vis/web/src/types.ts b/apps/vis/web/src/types.ts new file mode 100644 index 000000000..1731b202a --- /dev/null +++ b/apps/vis/web/src/types.ts @@ -0,0 +1,359 @@ +// Client-side types mirroring the server API contract. +// Kept in sync manually — the server owns the source of truth. + +export type WireRecordType = + // File-header (line 1) and startup baseline (line 2) — vis splices + // these back into the timeline so the Wire tab shows every line of + // wire.jsonl, including the system_prompt. + | 'metadata' + | 'session_initialized' + | 'turn_begin' + | 'turn_end' + | 'user_message' + | 'tool_result' + | 'compaction' + | 'system_prompt_changed' + | 'tools_changed' + | 'system_reminder' + | 'notification' + // Atomic streaming records (replace assistant_message + tool_call_dispatched). + | 'step_begin' + | 'step_end' + | 'content_part' + | 'tool_call' + | 'tool_denied' + | 'skill_invoked' + | 'skill_completed' + | 'approval_request' + | 'approval_response' + | 'team_mail' + | 'subagent_spawned' + | 'subagent_completed' + | 'subagent_failed' + | 'ownership_changed' + | 'context_edit' + | 'context_cleared'; + +export type WireCategory = + | 'conversation' + | 'config' + | 'lifecycle' + | 'subagent' + | 'approval' + | 'ephemeral' + | 'meta' + | 'tools'; + +// Discriminated union — loosely typed so we can dispatch on .type without +// needing server-synchronized exact interfaces in the client. The server's +// own `types.ts` has the precise field types. +export interface VisWireRecordBase { + type: WireRecordType; + seq: number; + time: number; + [key: string]: unknown; +} + +export type VisWireRecord = VisWireRecordBase; + +export interface WireFileMetadata { + type: 'metadata'; + protocol_version: string; + created_at: number; + kimi_version?: string; + producer?: { + kind: 'python' | 'typescript'; + name: string; + version: string; + }; +} + +// ──────────── Session ──────────── + +export interface SessionSummary { + session_id: string; + title: string | null; + last_prompt: string | null; + created_at: number; + updated_at: number; + last_turn_time: number | null; + model: string | null; + permission_mode: string | null; + last_exit_code: 'clean' | 'dirty' | null; + custom_title: string | null; + tags: string[]; + archived: boolean; + workspace_dir: string | null; + wire_protocol_version: string | null; + wire_record_count: number; + archive_count: number; + subagent_count: number; + health: 'ok' | 'broken' | 'missing_wire'; +} + +export interface SessionState { + session_id: string; + title?: string; + isCustomTitle?: boolean; + customTitle?: string; + lastPrompt?: string; + last_prompt?: string; + model?: string; + last_turn_id?: string; + last_turn_time?: number; + turn_count?: number; + created_at: number; + updated_at: number; + workspace_dir?: string; + auto_approve_actions?: string[]; + permission_mode?: 'default' | 'acceptEdits' | 'bypassPermissions'; + thinking_level?: string; + custom_title?: string; + plan_mode?: boolean; + plan_slug?: string; + tags?: string[]; + description?: string; + archived?: boolean; + color?: string; + last_exit_code?: 'clean' | 'dirty'; + [key: string]: unknown; +} + +export interface SessionDetail { + session_id: string; + title: string | null; + last_prompt: string | null; + state: SessionState; + subagent_ids: string[]; + archive_files: string[]; + tool_result_ids: string[]; + wire_metadata: WireFileMetadata | null; +} + +// ──────────── Wire ──────────── + +export interface WireResponse { + session_id: string; + agent_id: string | null; + files_read: string[]; + health: 'ok' | 'broken'; + broken_reason?: string; + warnings: string[]; + records: VisWireRecord[]; +} + +// ──────────── Context ──────────── + +export type MessageOrigin = + | { kind: 'user' } + | { kind: 'assistant' } + | { kind: 'tool'; tool_call_id: string } + | { kind: 'system_reminder'; seq: number } + | { kind: 'notification'; seq: number; notification_id: string; severity: string }; + +export interface ContentPart { + type: 'text' | 'think' | 'image_url' | 'video_url'; + [key: string]: unknown; +} + +export interface ToolCallEntry { + type: 'function'; + id: string; + function: { name: string; arguments: string | null }; +} + +export interface AnnotatedMessage { + seq: number; + message: { + role: 'user' | 'assistant' | 'tool'; + content: ContentPart[]; + tool_calls: ToolCallEntry[]; + tool_call_id?: string; + }; + origin: MessageOrigin; + is_ephemeral: boolean; + out_of_context: boolean; + persisted_output_path?: string; +} + +export interface ProjectedStateSummary { + model: string | null; + system_prompt: string | null; + active_tools: string[]; + last_seq: number; + token_count: number; + permission_mode: string | null; + plan_mode: boolean; + input_tokens: number; + output_tokens: number; + cache_read_tokens: number; + cache_write_tokens: number; +} + +export interface ContextResponse { + session_id: string; + agent_id: string | null; + annotated_messages: AnnotatedMessage[]; + projected_state: ProjectedStateSummary; +} + +// ──────────── Subagents ──────────── + +export interface SubagentNode { + agent_id: string; + agent_name: string | null; + subagent_type: string | null; + run_in_background: boolean; + parent_agent_id: string | null; + depth: number; + status: 'running' | 'completed' | 'failed' | 'killed' | 'lost' | 'unknown'; + success: boolean | null; + result_summary: string | null; + error: string | null; + spawn_seq: number; + spawn_time: number; + children: SubagentNode[]; +} + +export interface SubagentTreeResponse { + session_id: string; + tree: SubagentNode[]; +} + +export interface SubagentMetaResponse { + agent_id: string; + session_id: string; + meta_json: { + agent_id: string; + subagent_type: string; + status: string; + description: string; + parent_tool_call_id: string; + created_at: number; + updated_at: number; + } | null; + spawned_record: { + agent_name?: string; + parent_tool_call_id: string; + parent_agent_id?: string; + run_in_background: boolean; + seq: number; + time: number; + } | null; + completed_record: { + parent_tool_call_id: string; + result_summary: string; + usage?: { + input_tokens: number; + output_tokens: number; + cache_read_tokens?: number; + cache_write_tokens?: number; + }; + seq: number; + time: number; + } | null; + failed_record: { + parent_tool_call_id: string; + error: string; + seq: number; + time: number; + } | null; + depth: number; +} + +export interface ToolResultFileResponse { + tool_call_id: string; + session_id: string; + size_bytes: number; + content: string; +} + +export interface ApiError { + error: string; + code: + | 'NOT_FOUND' + | 'BAD_REQUEST' + | 'UNAUTHORIZED' + | 'READ_ERROR' + | 'PARSE_ERROR' + | 'DELETE_ERROR'; +} + +export interface DeleteSessionResponse { + session_id: string; + deleted: true; +} + +export interface ClearSessionsResponse { + deleted_count: number; + failed: Array<{ session_id: string; error: string }>; +} + +// ──────────── Category mapping ──────────── + +export const TYPE_CATEGORY: Record = { + metadata: 'meta', + session_initialized: 'config', + turn_begin: 'conversation', + turn_end: 'conversation', + user_message: 'conversation', + tool_result: 'conversation', + compaction: 'conversation', + system_prompt_changed: 'config', + tools_changed: 'config', + system_reminder: 'ephemeral', + notification: 'meta', + step_begin: 'conversation', + step_end: 'conversation', + content_part: 'conversation', + tool_call: 'tools', + tool_denied: 'approval', + skill_invoked: 'tools', + skill_completed: 'tools', + approval_request: 'approval', + approval_response: 'approval', + team_mail: 'meta', + subagent_spawned: 'subagent', + subagent_completed: 'subagent', + subagent_failed: 'subagent', + ownership_changed: 'lifecycle', + context_edit: 'lifecycle', + context_cleared: 'lifecycle', +}; + +// Context-effect marker — `true` if the record mutates the live context, +// `false` if it is telemetry-only, `'conditional'` for records that depend +// on per-record fields (notification targets, system_reminder delivery, …). +export const TYPE_CTX_EFFECT: Record = { + // File header — purely informational, not a context event. + metadata: false, + // Startup baseline — seeds system_prompt / model / active_tools that + // the projector uses as the context floor. + session_initialized: true, + turn_begin: false, + turn_end: false, + user_message: true, + tool_result: true, + compaction: true, + system_prompt_changed: true, + tools_changed: true, + system_reminder: 'conditional', + notification: 'conditional', + // Streaming atoms together rebuild an assistant message → context-affecting. + step_begin: true, + step_end: false, + content_part: true, + tool_call: true, + tool_denied: false, + skill_invoked: false, + skill_completed: false, + approval_request: false, + approval_response: false, + team_mail: false, + subagent_spawned: false, + subagent_completed: false, + subagent_failed: false, + ownership_changed: false, + context_edit: true, + context_cleared: true, +}; diff --git a/apps/vis/web/src/util/time.ts b/apps/vis/web/src/util/time.ts new file mode 100644 index 000000000..27246cbd6 --- /dev/null +++ b/apps/vis/web/src/util/time.ts @@ -0,0 +1,33 @@ +/** Format an epoch-ms timestamp as a short relative string ("2m ago", "3h ago"). */ +export function formatRelativeTime(epochMs: number): string { + if (!epochMs || !Number.isFinite(epochMs)) return '—'; + const diff = Date.now() - epochMs; + if (diff < 0) return 'just now'; + const s = Math.floor(diff / 1000); + if (s < 60) return `${s}s ago`; + const m = Math.floor(s / 60); + if (m < 60) return `${m}m ago`; + const h = Math.floor(m / 60); + if (h < 24) return `${h}h ago`; + const d = Math.floor(h / 24); + if (d < 30) return `${d}d ago`; + const mo = Math.floor(d / 30); + if (mo < 12) return `${mo}mo ago`; + return `${Math.floor(mo / 12)}y ago`; +} + +/** Format an epoch-ms timestamp as ISO-ish local time (YYYY-MM-DD HH:MM:SS). */ +export function formatAbsoluteTime(epochMs: number): string { + if (!epochMs || !Number.isFinite(epochMs)) return '—'; + const d = new Date(epochMs); + const pad = (n: number) => String(n).padStart(2, '0'); + return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`; +} + +/** Format an epoch-ms timestamp as HH:MM:SS (wall clock). */ +export function formatWallClock(epochMs: number): string { + if (!epochMs || !Number.isFinite(epochMs)) return '--:--:--'; + const d = new Date(epochMs); + const pad = (n: number) => String(n).padStart(2, '0'); + return `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`; +} diff --git a/apps/vis/web/test/api.test.ts b/apps/vis/web/test/api.test.ts new file mode 100644 index 000000000..ef71955f3 --- /dev/null +++ b/apps/vis/web/test/api.test.ts @@ -0,0 +1,39 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { api } from '../src/api'; + +describe('vis web api auth token handling', () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('scrubs token parameters from the browser URL after persisting the token', async () => { + const setItem = vi.fn(); + const getItem = vi.fn(); + const replaceState = vi.fn(); + const location = new URL('http://localhost:3001/?foo=bar&token=secret#token=secret&tab=wire'); + + vi.stubGlobal('window', { + history: { replaceState }, + localStorage: { getItem, setItem }, + location, + }); + const fetchMock = vi.fn( + async () => + new Response('[]', { + headers: { 'content-type': 'application/json' }, + status: 200, + }), + ); + vi.stubGlobal('fetch', fetchMock); + + await api.listSessions(); + + expect(setItem).toHaveBeenCalledWith('kimi-vis-auth-token', 'secret'); + expect(fetchMock).toHaveBeenCalledWith('/api/sessions', { + headers: { accept: 'application/json', authorization: 'Bearer secret' }, + method: 'GET', + }); + expect(replaceState).toHaveBeenCalledWith(null, '', 'http://localhost:3001/?foo=bar#tab=wire'); + }); +}); diff --git a/apps/vis/web/tsconfig.json b/apps/vis/web/tsconfig.json new file mode 100644 index 000000000..e1ecfcd8a --- /dev/null +++ b/apps/vis/web/tsconfig.json @@ -0,0 +1,24 @@ +{ + "compilerOptions": { + "target": "ES2022", + "useDefineForClassFields": true, + "lib": ["ES2023", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "moduleDetection": "force", + "noEmit": true, + "jsx": "react-jsx", + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedIndexedAccess": true, + "exactOptionalPropertyTypes": false, + "types": ["vite/client"] + }, + "include": ["src"] +} diff --git a/apps/vis/web/vite.config.ts b/apps/vis/web/vite.config.ts new file mode 100644 index 000000000..646a173cb --- /dev/null +++ b/apps/vis/web/vite.config.ts @@ -0,0 +1,22 @@ +import { defineConfig } from 'vite'; +import react from '@vitejs/plugin-react'; +import tailwindcss from '@tailwindcss/vite'; + +export default defineConfig({ + plugins: [react(), tailwindcss()], + server: { + port: 5174, + strictPort: true, + proxy: { + '/api': { + target: 'http://localhost:3001', + changeOrigin: true, + }, + }, + }, + build: { + outDir: 'dist', + emptyOutDir: true, + target: 'es2022', + }, +}); diff --git a/build/nix/update-pnpm-deps.sh b/build/nix/update-pnpm-deps.sh new file mode 100644 index 000000000..de2eb556a --- /dev/null +++ b/build/nix/update-pnpm-deps.sh @@ -0,0 +1,201 @@ +set -euo pipefail + +ROOT="$(git rev-parse --show-toplevel)" +cd "$ROOT" + +FLAKE="$ROOT/flake.nix" +FAKE_HASH="sha256-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=" +CACHE_VERSION="v1" +FETCHER_VERSION="3" +CACHE_FILE="$ROOT/.git/kimi-code/pnpm-deps-hashes-$CACHE_VERSION.json" +RESTORE_ORIG_HASH=0 + +ORIG_HASH="$(grep -E -o 'hash = "sha256-[A-Za-z0-9+/=]+"' "$FLAKE" \ + | head -n 1 \ + | sed -E 's/hash = "(.*)"/\1/')" +if [ -z "$ORIG_HASH" ]; then + echo "error: could not find pnpmDeps hash in flake.nix" >&2 + exit 1 +fi + +set_hash() { + sed -i.bak -E "s|hash = \"sha256-[A-Za-z0-9+/=]+\"|hash = \"$1\"|" "$FLAKE" + rm -f "$FLAKE.bak" +} + +cleanup() { + if [ "$RESTORE_ORIG_HASH" = "1" ]; then + set_hash "$ORIG_HASH" + fi +} +trap cleanup EXIT +trap 'exit 130' INT TERM + +hash_stream() { + if command -v sha256sum >/dev/null 2>&1; then + sha256sum | sed -E 's/[[:space:]].*$//' + else + shasum -a 256 | sed -E 's/[[:space:]].*$//' + fi +} + +hash_file() { + if command -v sha256sum >/dev/null 2>&1; then + sha256sum "$1" | sed -E 's/[[:space:]].*$//' + else + shasum -a 256 "$1" | sed -E 's/[[:space:]].*$//' + fi +} + +print_file_fingerprint() { + path="$1" + if [ -f "$path" ]; then + printf 'file:%s\n' "$path" + hash_file "$path" + printf '\n' + else + printf 'missing:%s\n' "$path" + fi +} + +input_fingerprint() { + { + printf 'cacheVersion=%s\n' "$CACHE_VERSION" + printf 'fetcherVersion=%s\n' "$FETCHER_VERSION" + + printf 'file:flake.nix(normalized-pnpmDeps-hash)\n' + sed -E 's|hash = "sha256-[A-Za-z0-9+/=]+"|hash = "sha256-"|' "$FLAKE" + printf '\n' + + for path in \ + .npmrc \ + flake.lock \ + package.json \ + pnpm-lock.yaml \ + pnpm-workspace.yaml + do + print_file_fingerprint "$path" + done + + git ls-files --cached --others --exclude-standard -- \ + '*/package.json' \ + '.pnpmfile.cjs' \ + 'patches/**' \ + | sort -u \ + | while IFS= read -r path; do + [ -n "$path" ] || continue + [ "$path" = "package.json" ] && continue + print_file_fingerprint "$path" + done + } | hash_stream +} + +read_cached_hash() { + [ -f "$CACHE_FILE" ] || return 0 + + # shellcheck disable=SC2016 + node -e ' +const fs = require("node:fs"); +const [file, key] = process.argv.slice(1); +const parsed = JSON.parse(fs.readFileSync(file, "utf8")); +const entry = parsed[key]; +if (entry && typeof entry.hash === "string") { + console.log(entry.hash); +} +' "$CACHE_FILE" "$INPUT_KEY" +} + +write_cached_hash() { + hash="$1" + mkdir -p "$(dirname "$CACHE_FILE")" + + # shellcheck disable=SC2016 + node -e ' +const fs = require("node:fs"); +const [file, key, hash, createdAt] = process.argv.slice(1); +let parsed = {}; +try { + if (fs.existsSync(file)) { + parsed = JSON.parse(fs.readFileSync(file, "utf8")); + } +} catch { + parsed = {}; +} +parsed[key] = { hash, createdAt }; +fs.writeFileSync(`${file}.tmp`, `${JSON.stringify(parsed, null, 2)}\n`); +fs.renameSync(`${file}.tmp`, file); +' "$CACHE_FILE" "$INPUT_KEY" "$hash" "$(date -u '+%Y-%m-%dT%H:%M:%SZ')" +} + +echo "==> current pnpmDeps hash: $ORIG_HASH" +INPUT_KEY="$(input_fingerprint)" + +if nix build --no-link '.#kimi-code-pnpm-deps' >/dev/null 2>&1; then + write_cached_hash "$ORIG_HASH" + echo "==> pnpmDeps hash still valid; cached input fingerprint $INPUT_KEY" + exit 0 +fi + +echo "==> current hash did not build; checking local pnpmDeps hash cache" +CACHED_HASH="" +if ! CACHED_HASH="$(read_cached_hash)"; then + echo "warning: ignoring unreadable pnpmDeps hash cache at $CACHE_FILE" >&2 + CACHED_HASH="" +fi + +if [ -n "$CACHED_HASH" ] && [ "$CACHED_HASH" != "$ORIG_HASH" ]; then + echo "==> cache hit for pnpmDeps input: $CACHED_HASH" + RESTORE_ORIG_HASH=1 + set_hash "$CACHED_HASH" + if nix build --no-link '.#kimi-code-pnpm-deps' >/dev/null 2>&1; then + RESTORE_ORIG_HASH=0 + write_cached_hash "$CACHED_HASH" + echo "==> done. pnpmDeps hash: $ORIG_HASH -> $CACHED_HASH" + exit 0 + fi + + echo "==> cached hash failed verification; falling back to hash discovery" + set_hash "$ORIG_HASH" + RESTORE_ORIG_HASH=0 +fi + +echo "==> patching flake.nix with fakeHash to provoke a mismatch" +RESTORE_ORIG_HASH=1 +set_hash "$FAKE_HASH" + +echo "==> running nix build to discover the real hash" +BUILD_OUT="$(nix build --no-link --print-build-logs '.#kimi-code-pnpm-deps' 2>&1 || true)" + +NEW_HASH="$(printf '%s\n' "$BUILD_OUT" \ + | grep -E -o 'got:[[:space:]]+sha256-[A-Za-z0-9+/=]+' \ + | head -n 1 \ + | sed -E 's/^got:[[:space:]]+//')" + +if [ -z "$NEW_HASH" ]; then + echo "error: could not extract a new hash from nix build output." >&2 + echo "----- nix build output -----" >&2 + printf '%s\n' "$BUILD_OUT" >&2 + echo "----- end output -----" >&2 + set_hash "$ORIG_HASH" + RESTORE_ORIG_HASH=0 + exit 1 +fi + +set_hash "$NEW_HASH" +RESTORE_ORIG_HASH=0 + +echo "==> verifying build with new hash" +if ! nix build --no-link '.#kimi-code-pnpm-deps'; then + echo "error: verification build failed after hash update." >&2 + echo " flake.nix was left pointing at $NEW_HASH for inspection." >&2 + exit 1 +fi + +write_cached_hash "$NEW_HASH" + +if [ "$NEW_HASH" = "$ORIG_HASH" ]; then + echo "==> hash unchanged ($ORIG_HASH); flake.nix already up to date" + exit 0 +fi + +echo "==> done. pnpmDeps hash: $ORIG_HASH -> $NEW_HASH" diff --git a/build/raw-text-loader.mjs b/build/raw-text-loader.mjs new file mode 100644 index 000000000..6946c97b8 --- /dev/null +++ b/build/raw-text-loader.mjs @@ -0,0 +1,23 @@ +import { readFile } from 'node:fs/promises'; +import { fileURLToPath } from 'node:url'; + +/** + * Node ESM `load` hook: import `.md` / `.yaml` files as raw-string modules. + * + * This is the runtime counterpart of `build/raw-text-plugin.mjs` (the bundler + * plugin). The plugin covers build (tsdown) and test (vitest); this loader + * covers source execution — e.g. `tsx`-run dev flows that import `kimi-core` + * straight from `src`, where no bundler is involved. + */ +export async function load(url, context, nextLoad) { + const filePath = url.split('?', 1)[0] ?? url; + if (filePath.endsWith('.md') || filePath.endsWith('.yaml')) { + const text = await readFile(fileURLToPath(filePath), 'utf-8'); + return { + format: 'module', + shortCircuit: true, + source: `export default ${JSON.stringify(text)};`, + }; + } + return nextLoad(url, context); +} diff --git a/build/raw-text-plugin.mjs b/build/raw-text-plugin.mjs new file mode 100644 index 000000000..fba6eef62 --- /dev/null +++ b/build/raw-text-plugin.mjs @@ -0,0 +1,23 @@ +import { readFileSync } from 'node:fs'; + +/** + * Bundler plugin that lets `.md` / `.yaml` files be imported as raw strings: + * + * import description from './grep.md'; + * + * The file content is inlined into the bundle at build time, so prompt + * source files never ship separately in `dist`. Shared by tsdown (build) + * and vitest (test) so both resolve these imports identically. + */ +export function rawTextPlugin() { + return { + name: 'raw-text', + enforce: 'pre', + load(id) { + const path = id.split('?', 1)[0] ?? id; + if (!path.endsWith('.md') && !path.endsWith('.yaml')) return null; + const text = readFileSync(path, 'utf-8'); + return { code: `export default ${JSON.stringify(text)};`, map: null }; + }, + }; +} diff --git a/build/register-raw-text-loader.mjs b/build/register-raw-text-loader.mjs new file mode 100644 index 000000000..0c178fc34 --- /dev/null +++ b/build/register-raw-text-loader.mjs @@ -0,0 +1,7 @@ +import { register } from 'node:module'; + +/** + * Registers the `.md` / `.yaml` raw-text loader. Pass to Node via `--import` + * (alongside tsx) so source-executed code can import these prompt files. + */ +register('./raw-text-loader.mjs', import.meta.url); diff --git a/docs/.gitignore b/docs/.gitignore new file mode 100644 index 000000000..57a09c39d --- /dev/null +++ b/docs/.gitignore @@ -0,0 +1,3 @@ +node_modules +.vitepress/dist +.vitepress/cache diff --git a/docs/.npmrc b/docs/.npmrc new file mode 100644 index 000000000..bf2e7648b --- /dev/null +++ b/docs/.npmrc @@ -0,0 +1 @@ +shamefully-hoist=true diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts new file mode 100644 index 000000000..ab968c7d0 --- /dev/null +++ b/docs/.vitepress/config.ts @@ -0,0 +1,184 @@ +import { defineConfig } from 'vitepress' +import { withMermaid } from 'vitepress-plugin-mermaid' +import llmstxt from 'vitepress-plugin-llms' + +const rawBase = process.env.VITEPRESS_BASE +const base = rawBase + ? rawBase.startsWith('/') + ? rawBase.endsWith('/') ? rawBase : `${rawBase}/` + : `/${rawBase}/` + : '/' + +export default withMermaid(defineConfig({ + base, + title: 'Kimi Code CLI Docs', + description: 'Kimi Code CLI Documentation', + + head: [ + ['link', { rel: 'icon', type: 'image/x-icon', href: `${base}favicon.ico` }], + ['meta', { name: 'theme-color', content: '#0a7aff' }], + ], + + srcExclude: ['AGENTS.md', 'superpowers/**'], + + locales: { + zh: { + label: '简体中文', + lang: 'zh-CN', + link: '/zh/', + title: 'Kimi Code CLI 文档', + description: 'Kimi Code CLI 用户文档', + themeConfig: { + nav: [ + { text: '指南', link: '/zh/guides/getting-started', activeMatch: '/zh/guides/' }, + { text: '定制化', link: '/zh/customization/mcp', activeMatch: '/zh/customization/' }, + { text: '配置', link: '/zh/configuration/config-files', activeMatch: '/zh/configuration/' }, + { text: '参考手册', link: '/zh/reference/kimi-command', activeMatch: '/zh/reference/' }, + { text: '常见问题', link: '/zh/faq' }, + { text: '发布说明', link: '/zh/release-notes/changelog', activeMatch: '/zh/release-notes/' }, + ], + sidebar: { + '/zh/guides/': [ + { + text: '指南', + items: [ + { text: '开始使用', link: '/zh/guides/getting-started' }, + { text: '从 kimi-cli 迁移', link: '/zh/guides/migration' }, + { text: '常见使用案例', link: '/zh/guides/use-cases' }, + { text: '交互与输入', link: '/zh/guides/interaction' }, + { text: '会话与上下文', link: '/zh/guides/sessions' }, + ], + }, + ], + '/zh/customization/': [ + { + text: '定制化', + items: [ + { text: 'Model Context Protocol', link: '/zh/customization/mcp' }, + { text: 'Agent Skills', link: '/zh/customization/skills' }, + { text: 'Agent 与子 Agent', link: '/zh/customization/agents' }, + { text: 'Hooks', link: '/zh/customization/hooks' }, + ], + }, + ], + '/zh/configuration/': [ + { + text: '配置', + items: [ + { text: '配置文件', link: '/zh/configuration/config-files' }, + { text: '平台与模型', link: '/zh/configuration/providers' }, + { text: '配置覆盖', link: '/zh/configuration/overrides' }, + { text: '环境变量', link: '/zh/configuration/env-vars' }, + { text: '数据路径', link: '/zh/configuration/data-locations' }, + ], + }, + ], + '/zh/reference/': [ + { + text: '参考手册', + items: [ + { text: 'kimi 命令', link: '/zh/reference/kimi-command' }, + { text: '内置工具', link: '/zh/reference/tools' }, + { text: '斜杠命令', link: '/zh/reference/slash-commands' }, + { text: '键盘快捷键', link: '/zh/reference/keyboard' }, + ], + }, + ], + '/zh/release-notes/': [ + { + text: '发布说明', + items: [ + { text: '变更记录', link: '/zh/release-notes/changelog' }, + ], + }, + ], + }, + }, + }, + en: { + label: 'English', + lang: 'en-US', + link: '/en/', + title: 'Kimi Code CLI Docs', + description: 'Kimi Code CLI User Documentation', + themeConfig: { + nav: [ + { text: 'Guides', link: '/en/guides/getting-started', activeMatch: '/en/guides/' }, + { text: 'Customization', link: '/en/customization/mcp', activeMatch: '/en/customization/' }, + { text: 'Configuration', link: '/en/configuration/config-files', activeMatch: '/en/configuration/' }, + { text: 'Reference', link: '/en/reference/kimi-command', activeMatch: '/en/reference/' }, + { text: 'FAQ', link: '/en/faq' }, + { text: 'Release Notes', link: '/en/release-notes/changelog', activeMatch: '/en/release-notes/' }, + ], + sidebar: { + '/en/guides/': [ + { + text: 'Guides', + items: [ + { text: 'Getting Started', link: '/en/guides/getting-started' }, + { text: 'Migrating from kimi-cli', link: '/en/guides/migration' }, + { text: 'Common Use Cases', link: '/en/guides/use-cases' }, + { text: 'Interaction and Input', link: '/en/guides/interaction' }, + { text: 'Sessions and Context', link: '/en/guides/sessions' }, + ], + }, + ], + '/en/customization/': [ + { + text: 'Customization', + items: [ + { text: 'Model Context Protocol', link: '/en/customization/mcp' }, + { text: 'Agent Skills', link: '/en/customization/skills' }, + { text: 'Agents and Subagents', link: '/en/customization/agents' }, + { text: 'Hooks', link: '/en/customization/hooks' }, + ], + }, + ], + '/en/configuration/': [ + { + text: 'Configuration', + items: [ + { text: 'Config Files', link: '/en/configuration/config-files' }, + { text: 'Providers and Models', link: '/en/configuration/providers' }, + { text: 'Config Overrides', link: '/en/configuration/overrides' }, + { text: 'Environment Variables', link: '/en/configuration/env-vars' }, + { text: 'Data Locations', link: '/en/configuration/data-locations' }, + ], + }, + ], + '/en/reference/': [ + { + text: 'Reference', + items: [ + { text: 'kimi Command', link: '/en/reference/kimi-command' }, + { text: 'Built-in Tools', link: '/en/reference/tools' }, + { text: 'Slash Commands', link: '/en/reference/slash-commands' }, + { text: 'Keyboard Shortcuts', link: '/en/reference/keyboard' }, + ], + }, + ], + '/en/release-notes/': [ + { + text: 'Release Notes', + items: [ + { text: 'Changelog', link: '/en/release-notes/changelog' }, + ], + }, + ], + }, + }, + }, + }, + + themeConfig: { + outline: [2, 3], + search: { provider: 'local' }, + socialLinks: [ + { icon: 'github', link: 'https://github.com/MoonshotAI/kimi-code' }, + ], + }, + + vite: { + plugins: [llmstxt()], + }, +})) diff --git a/docs/.vitepress/theme/Kimi.png b/docs/.vitepress/theme/Kimi.png new file mode 100644 index 000000000..5b41bb609 Binary files /dev/null and b/docs/.vitepress/theme/Kimi.png differ diff --git a/docs/.vitepress/theme/components/HomeFeatures.vue b/docs/.vitepress/theme/components/HomeFeatures.vue new file mode 100644 index 000000000..e98c01e5f --- /dev/null +++ b/docs/.vitepress/theme/components/HomeFeatures.vue @@ -0,0 +1,319 @@ + + + + + diff --git a/docs/.vitepress/theme/components/HomeHero.vue b/docs/.vitepress/theme/components/HomeHero.vue new file mode 100644 index 000000000..873d18b0f --- /dev/null +++ b/docs/.vitepress/theme/components/HomeHero.vue @@ -0,0 +1,147 @@ + + + + + diff --git a/docs/.vitepress/theme/components/HomeLayout.vue b/docs/.vitepress/theme/components/HomeLayout.vue new file mode 100644 index 000000000..1a1635c20 --- /dev/null +++ b/docs/.vitepress/theme/components/HomeLayout.vue @@ -0,0 +1,38 @@ + + + + + diff --git a/docs/.vitepress/theme/components/HomeQuickStart.vue b/docs/.vitepress/theme/components/HomeQuickStart.vue new file mode 100644 index 000000000..91c6c821a --- /dev/null +++ b/docs/.vitepress/theme/components/HomeQuickStart.vue @@ -0,0 +1,210 @@ + + + + + diff --git a/docs/.vitepress/theme/components/KimiLogo.vue b/docs/.vitepress/theme/components/KimiLogo.vue new file mode 100644 index 000000000..2afef84d6 --- /dev/null +++ b/docs/.vitepress/theme/components/KimiLogo.vue @@ -0,0 +1,22 @@ + + + + + diff --git a/docs/.vitepress/theme/index.ts b/docs/.vitepress/theme/index.ts new file mode 100644 index 000000000..ceb339acd --- /dev/null +++ b/docs/.vitepress/theme/index.ts @@ -0,0 +1,12 @@ +import type { Theme } from 'vitepress' +import DefaultTheme from 'vitepress/theme' +import HomeLayout from './components/HomeLayout.vue' + +import './styles/vars.css' +import './styles/base.css' +import './styles/home.css' + +export default { + extends: DefaultTheme, + Layout: HomeLayout, +} satisfies Theme diff --git a/docs/.vitepress/theme/styles/base.css b/docs/.vitepress/theme/styles/base.css new file mode 100644 index 000000000..7a860ca7f --- /dev/null +++ b/docs/.vitepress/theme/styles/base.css @@ -0,0 +1,231 @@ +/** + * Base overrides applied to all pages. + * Touches links, inline code, code blocks, custom blocks, blockquotes, navbar, sidebar. + */ + +html { + font-feature-settings: 'cv11', 'ss01', 'ss03'; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +body { + font-family: var(--vp-font-family-base); +} + +/* --- Top navbar: blur + remove the hard bottom line --- */ +.VPNav, +.VPNavBar { + background: rgba(255, 255, 255, 0.72) !important; + backdrop-filter: saturate(180%) blur(14px); + -webkit-backdrop-filter: saturate(180%) blur(14px); +} +.dark .VPNav, +.dark .VPNavBar { + background: rgba(13, 17, 23, 0.72) !important; +} +.VPNavBar.has-sidebar .content, +.VPNavBar:not(.home) { + border-bottom: 1px solid var(--vp-c-divider); +} + +/* --- Sidebar: brand-tinted active item, slim left accent bar --- */ +.VPSidebarItem.is-active > .item .link .text, +.VPSidebarItem.is-active > .item > .text { + color: var(--vp-c-brand-1); + font-weight: 600; +} +.VPSidebarItem.is-link.is-active > .item { + position: relative; +} +.VPSidebarItem.is-link.is-active > .item::before { + content: ''; + position: absolute; + left: -14px; + top: 50%; + transform: translateY(-50%); + width: 3px; + height: 16px; + border-radius: 2px; + background: var(--kimi-brand-gradient); +} + +/* --- Headings: tighter tracking, no underline on h2 --- */ +.vp-doc h1, +.vp-doc h2, +.vp-doc h3 { + letter-spacing: -0.02em; +} +.vp-doc h2 { + border-top: none; + padding-top: 24px; + margin-top: 48px; +} + +/* --- Links --- */ +.vp-doc a:not(.header-anchor) { + color: var(--vp-c-brand-1); + text-decoration: underline; + text-decoration-color: transparent; + text-underline-offset: 4px; + text-decoration-thickness: 2px; + transition: text-decoration-color var(--kimi-transition), color var(--kimi-transition); + font-weight: 500; +} +.vp-doc a:not(.header-anchor):hover { + color: var(--vp-c-brand-2); + text-decoration-color: currentColor; +} + +/* --- Inline code --- */ +.vp-doc :not(pre) > code { + background: var(--kimi-brand-soft); + color: var(--vp-c-brand-1); + padding: 2px 6px; + border-radius: 6px; + font-weight: 500; + font-size: 0.875em; + border: none; +} + +/* Inline code inside headings: drop the chip, keep just the brand-colored monospace word */ +.vp-doc :is(h1, h2, h3, h4, h5, h6) code { + background: transparent; + padding: 0; + border-radius: 0; + font-size: 0.9em; + font-weight: inherit; + color: var(--vp-c-brand-1); +} + +/* --- Code blocks --- */ +.vp-doc div[class*='language-'] { + border-radius: var(--kimi-radius-code); + background: var(--vp-c-bg-soft); + margin: 20px 0; + box-shadow: var(--vp-shadow-1); +} +.vp-doc div[class*='language-'] pre { + padding: 20px 24px; +} +.vp-doc div[class*='language-'] code { + font-family: var(--vp-font-family-mono); + font-size: 13.5px; + line-height: 1.7; +} +.vp-doc div[class*='language-'] .lang { + color: var(--vp-c-text-3); + font-size: 12px; +} +.vp-doc div[class*='language-'] button.copy { + border-radius: 8px; +} + +/* --- Blockquote --- */ +.vp-doc blockquote { + border-left: 3px solid var(--vp-c-brand-1); + background: var(--kimi-brand-soft); + padding: 14px 18px; + border-radius: 0 var(--kimi-radius-code) var(--kimi-radius-code) 0; + margin: 20px 0; +} +.vp-doc blockquote > p { + color: var(--vp-c-text-2); + margin: 0; +} + +/* --- Custom blocks (tip / warning / danger / info) --- */ +.vp-doc .custom-block { + border-radius: var(--kimi-radius-code); + border: none; + padding: 16px 20px; + margin: 20px 0; +} +.vp-doc .custom-block .custom-block-title { + font-weight: 600; + letter-spacing: -0.005em; +} +.vp-doc .custom-block.tip { + background: rgba(10, 122, 255, 0.08); + color: var(--vp-c-text-1); +} +.dark .vp-doc .custom-block.tip { + background: rgba(61, 149, 255, 0.12); +} +.vp-doc .custom-block.warning { + background: rgba(234, 179, 8, 0.10); + color: var(--vp-c-text-1); +} +.vp-doc .custom-block.danger { + background: rgba(239, 68, 68, 0.10); + color: var(--vp-c-text-1); +} +.vp-doc .custom-block.info { + background: rgba(148, 163, 184, 0.12); + color: var(--vp-c-text-1); +} +.vp-doc .custom-block.tip .custom-block-title { color: var(--vp-c-brand-1); } +.vp-doc .custom-block.warning .custom-block-title { color: #ca8a04; } +.vp-doc .custom-block.danger .custom-block-title { color: #dc2626; } +.dark .vp-doc .custom-block.warning .custom-block-title { color: #eab308; } +.dark .vp-doc .custom-block.danger .custom-block-title { color: #ef4444; } + +/* --- Tables --- */ +.vp-doc table { + border-radius: var(--kimi-radius-code); + overflow: hidden; + border-collapse: separate; + border-spacing: 0; + display: table; + width: 100%; +} +.vp-doc tr { + background: transparent !important; + border-top: 1px solid var(--vp-c-divider); +} +.vp-doc tr:first-child { border-top: none; } +.vp-doc th { + background: var(--vp-c-bg-soft); + font-weight: 600; + color: var(--vp-c-text-1); +} + +/* --- Outline / TOC --- */ +.VPDocAsideOutline .outline-link.active, +.VPDocAsideOutline .outline-link:hover { + color: var(--vp-c-brand-1); +} + +/* --- Buttons globally (e.g. hero CTAs) --- */ +.VPButton.brand { + background: var(--kimi-brand-gradient) !important; + border: none !important; + box-shadow: var(--vp-shadow-3); + transition: transform var(--kimi-transition), box-shadow var(--kimi-transition); +} +.VPButton.brand:hover { + transform: translateY(-2px); + box-shadow: var(--vp-shadow-4); +} +.VPButton.alt { + background: transparent !important; + border: 1px solid var(--vp-c-divider) !important; + color: var(--vp-c-text-1) !important; + transition: border-color var(--kimi-transition), transform var(--kimi-transition); +} +.VPButton.alt:hover { + border-color: var(--vp-c-brand-1) !important; + transform: translateY(-2px); +} + +/* --- Hero default frontmatter (used as fallback when custom Home not rendered) --- */ +.VPHero .name, +.VPHero .text { + letter-spacing: -0.03em; +} + +/* --- Footer --- */ +.VPFooter { + border-top: 1px solid var(--vp-c-divider); + background: transparent; +} diff --git a/docs/.vitepress/theme/styles/home.css b/docs/.vitepress/theme/styles/home.css new file mode 100644 index 000000000..5ee0125b4 --- /dev/null +++ b/docs/.vitepress/theme/styles/home.css @@ -0,0 +1,85 @@ +/** + * Home-only styles. Scoped CSS in components handles most rules; + * shared utilities and layout container live here. + */ + +.KimiHome { + --section-px: clamp(20px, 5vw, 64px); + --section-py: clamp(28px, 4vw, 56px); + position: relative; + padding: 0 var(--section-px); +} + +.KimiHome__section { + max-width: 1152px; + margin: 0 auto; + padding: var(--section-py) 0; + position: relative; +} + +.KimiHome__sectionTitle { + font-size: clamp(28px, 4vw, 40px); + font-weight: 700; + letter-spacing: -0.03em; + margin: 0 0 12px; + color: var(--vp-c-text-1); +} + +.KimiHome__sectionLede { + font-size: 17px; + color: var(--vp-c-text-2); + margin: 0 0 40px; + max-width: 640px; + line-height: 1.6; +} + +.KimiBtn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 8px; + height: 48px; + padding: 0 22px; + border-radius: var(--kimi-radius-button); + font-size: 15px; + font-weight: 600; + letter-spacing: -0.005em; + text-decoration: none; + transition: transform var(--kimi-transition), box-shadow var(--kimi-transition), + border-color var(--kimi-transition), background var(--kimi-transition); + white-space: nowrap; + cursor: pointer; + border: 1px solid transparent; +} +.KimiBtn--primary { + color: #ffffff; + background: var(--kimi-brand-gradient); + box-shadow: var(--vp-shadow-3); + border: 0; +} +.KimiBtn--primary:hover { + transform: translateY(-2px); + box-shadow: var(--vp-shadow-4); + color: #ffffff; +} +.KimiBtn--ghost { + color: var(--vp-c-text-1); + background: transparent; + border-color: var(--vp-c-divider); +} +.KimiBtn--ghost:hover { + border-color: var(--vp-c-brand-1); + transform: translateY(-2px); + color: var(--vp-c-text-1); +} +.KimiBtn--link { + color: var(--vp-c-brand-1); + height: auto; + padding: 0; + background: transparent; + border: none; +} +.KimiBtn--link:hover { + color: var(--vp-c-brand-2); + transform: translateX(2px); +} diff --git a/docs/.vitepress/theme/styles/vars.css b/docs/.vitepress/theme/styles/vars.css new file mode 100644 index 000000000..4a4114a9c --- /dev/null +++ b/docs/.vitepress/theme/styles/vars.css @@ -0,0 +1,120 @@ +/** + * Design tokens for the Kimi Code docs theme. + * Light + dark live side by side; VitePress toggles the .dark class on . + */ + +:root { + /* Brand palette — cool blue family from design board */ + --kimi-brand-1: #0a7aff; /* primary */ + --kimi-brand-2: #5baeff; /* mid */ + --kimi-brand-3: #81c4ff; /* soft sky */ + --kimi-brand-deep: #043153; /* deep navy (anchor for dark surfaces) */ + --kimi-brand-whisper: #eff8ff; /* near-white blue (light tints) */ + --kimi-brand-gradient: linear-gradient(135deg, #0a7aff 0%, #5baeff 60%, #81c4ff 100%); + --kimi-brand-gradient-soft: linear-gradient(135deg, rgba(10, 122, 255, 0.14) 0%, rgba(91, 174, 255, 0.12) 60%, rgba(129, 196, 255, 0.10) 100%); + --kimi-brand-soft: rgba(10, 122, 255, 0.10); + --kimi-brand-soft-strong: rgba(10, 122, 255, 0.16); + + /* Shape */ + --kimi-radius-card: 16px; + --kimi-radius-button: 10px; + --kimi-radius-chip: 999px; + --kimi-radius-code: 12px; + --kimi-transition: 200ms cubic-bezier(0.4, 0, 0.2, 1); + + /* Typography */ + --vp-font-family-base: + 'Inter', -apple-system, BlinkMacSystemFont, + 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', + 'Helvetica Neue', 'Segoe UI', Arial, sans-serif; + --vp-font-family-mono: + ui-monospace, SFMono-Regular, 'SF Mono', + Menlo, Consolas, 'Liberation Mono', 'Courier New', monospace; + + /* Surfaces (light) — clean white + subtle blue-tinted off-white */ + --vp-c-bg: #ffffff; + --vp-c-bg-alt: #f6f9fd; + --vp-c-bg-elv: #ffffff; + --vp-c-bg-soft: #eff5fc; + + /* Text (light) */ + --vp-c-text-1: #0b1a30; + --vp-c-text-2: #475569; + --vp-c-text-3: #94a3b8; + + /* Borders (light) — picked up from palette E1E3E6 */ + --vp-c-divider: #e1e3e6; + --vp-c-gutter: #e1e3e6; + --vp-c-border: #e1e3e6; + + /* Brand applied to VitePress vars (light) */ + --vp-c-brand-1: var(--kimi-brand-1); + --vp-c-brand-2: var(--kimi-brand-2); + --vp-c-brand-3: #006ae3; /* hover, slightly deeper than primary */ + --vp-c-brand-soft: var(--kimi-brand-soft); + + /* Shadows (light) — no negative spread, avoids "kink" at rounded corners */ + --vp-shadow-1: 0 1px 2px rgba(11, 26, 48, 0.04); + --vp-shadow-2: 0 6px 20px rgba(10, 122, 255, 0.15); + --vp-shadow-3: 0 10px 28px rgba(10, 122, 255, 0.22); + --vp-shadow-4: 0 18px 44px rgba(10, 122, 255, 0.30); + + /* Buttons (light) */ + --vp-button-brand-bg: var(--kimi-brand-1); + --vp-button-brand-hover-bg: var(--vp-c-brand-3); + --vp-button-brand-active-bg: var(--vp-c-brand-3); + --vp-button-brand-border: transparent; + --vp-button-brand-hover-border: transparent; + --vp-button-brand-text: #ffffff; + --vp-button-brand-hover-text: #ffffff; + --vp-button-brand-active-text: #ffffff; + + /* Custom blocks tinting (light) */ + --vp-custom-block-tip-border: transparent; + --vp-custom-block-tip-text: var(--vp-c-text-1); + --vp-custom-block-tip-bg: rgba(10, 122, 255, 0.07); + --vp-custom-block-tip-code-bg: rgba(10, 122, 255, 0.10); +} + +.dark { + /* Surfaces (dark) — neutral dark with subtle navy undertone */ + --vp-c-bg: #0a1422; + --vp-c-bg-alt: #0f1b2e; + --vp-c-bg-elv: #0f1b2e; + --vp-c-bg-soft: #15263f; + + /* Text (dark) — avoid pure white */ + --vp-c-text-1: #e2e8f0; + --vp-c-text-2: #94a3b8; + --vp-c-text-3: #64748b; + + /* Borders (dark) — navy-leaning to stay in family */ + --vp-c-divider: #1b2e47; + --vp-c-gutter: #1b2e47; + --vp-c-border: #1b2e47; + + /* Brand applied (dark) — keep the pure blue family, brightened */ + --vp-c-brand-1: #3d95ff; + --vp-c-brand-2: #81c4ff; + --vp-c-brand-3: #5baeff; + --vp-c-brand-soft: rgba(61, 149, 255, 0.16); + + /* Softs (dark) */ + --kimi-brand-soft: rgba(61, 149, 255, 0.16); + --kimi-brand-soft-strong: rgba(61, 149, 255, 0.22); + + /* Shadows (dark) — brand-tinted glow, no negative spread */ + --vp-shadow-1: 0 1px 2px rgba(0, 0, 0, 0.4); + --vp-shadow-2: 0 6px 24px rgba(61, 149, 255, 0.22); + --vp-shadow-3: 0 10px 32px rgba(61, 149, 255, 0.32); + --vp-shadow-4: 0 18px 48px rgba(61, 149, 255, 0.42); + + /* Buttons (dark) */ + --vp-button-brand-bg: var(--kimi-brand-1); + --vp-button-brand-hover-bg: #1f8cff; + --vp-button-brand-active-bg: #1f8cff; + + /* Custom blocks tinting (dark) */ + --vp-custom-block-tip-bg: rgba(61, 149, 255, 0.12); + --vp-custom-block-tip-code-bg: rgba(61, 149, 255, 0.16); +} diff --git a/docs/AGENTS.md b/docs/AGENTS.md new file mode 100644 index 000000000..14d574ea6 --- /dev/null +++ b/docs/AGENTS.md @@ -0,0 +1,179 @@ +# Documentation Agent Guide + +This repository uses VitePress for the documentation site. Most user-facing pages under `docs/en/` and `docs/zh/` are fully written; New or updated content should keep both locales in sync. + +## Structure + +- Locales live under `docs/en/` and `docs/zh/` with mirrored paths and filenames. +- Main sections (nav + sidebar) are: + - Guides: getting-started, migration, use-cases, interaction, sessions + - Customization: mcp, skills, agents, hooks + - Configuration: config-files, providers, overrides, env-vars, data-locations + - Reference: kimi-command, tools, slash-commands, keyboard + - FAQ + - Release notes: changelog +- Navigation and sidebar are defined in `docs/.vitepress/config.ts`. Any new or renamed page must be wired there for both locales. + +## Source of truth + +- **Changelog page**: The English version (`docs/en/release-notes/changelog.md`) is the source of truth; the Chinese changelog should be translated from it. The changelog is currently generated manually by a skill that syncs from the CLI package's `CHANGELOG.md` after each release. +- **All other pages**: `docs/en/` and `docs/zh/` are mirrored pairs with the same paths, headings, and section structure. Edit whichever locale you are working in, and update the other locale in the same change. + +Keep both locales in sync before release. Machine-assisted translation is fine; review the locale you changed and its mirror for accuracy, terminology, and broken links. + +## Authoring workflow + +- Each page should keep the section ordering established by surrounding pages. Changelog and FAQ are exceptions until their placeholder content is replaced. +- For other pages: edit either locale, then update its mirror in the same change. + +## Naming conventions + +- Filenames are kebab-case and mirror across locales (same slug in `docs/en/` and `docs/zh/`). +- Use consistent section labels that match the sidebar titles. +- Use backticks for flags, commands, subcommands, command arguments, file paths, code identifiers, type names, field names, field values, and keyboard shortcuts. + +## Wording conventions + +- Do not change H1 titles or nav/sidebar labels. +- English H2+ headings use sentence case (only the first word capitalized unless it is a proper noun). Treat "Wire", "Plan mode", "YOLO mode", and "Thinking mode" as proper nouns; do not treat "agent" as a proper noun. +- Chinese H2+ headings keep English words in sentence case; preserve proper nouns listed in the term table below. +- Use `API key` in English and `API 密钥` in Chinese; keep `JSON`, `JSONL`, `OAuth`, `macOS`, `Node.js`, `npm`, `pnpm`, and `TypeScript` as-is. +- Use straight double quotes with spaces for quoted content: `"被引内容"` (not curly quotes). Add a space before and after the quoted text when adjacent to CJK characters. Use corner brackets `「」` for special terms (e.g., `「工具」`, `「会话」`). +- Prefer "终端" over "命令行" in Chinese when both are applicable (e.g., "运行在终端中", "终端界面", "终端操作"). +- Use "工具调用" / "tool call", not "工具使用" / "tool use". +- Use inline code for tool names (e.g., `Read`, `Grep`, `Bash`). + +Term mapping (Chinese <-> English, and proper noun handling): + +| Chinese | English | Proper noun (zh) | Proper noun (en) | +| --- | --- | --- | --- | +| Agent | agent | yes | no | +| 主 Agent | main agent | yes (Agent) | no | +| 子 Agent | subagent | yes (Agent) | no | +| Shell | shell | yes | no | +| Plan 模式 | Plan mode | yes | yes (Plan mode) | +| YOLO 模式 | YOLO mode | yes | yes (YOLO mode) | +| Thinking 模式 | Thinking mode | yes | yes (Thinking mode) | +| MCP | MCP | yes | yes | +| Kimi Code CLI | Kimi Code CLI | yes | yes | +| Agent Skills | Agent Skills | yes | yes | +| Skill | skill | yes | no | +| 系统提示词 | system prompt | no | no | +| 提示词 | prompt | no | no | +| 会话 | session | no | no | +| 上下文 | context | no | no | +| API 密钥 | API key | yes | no | +| JSON | JSON | yes | yes | +| JSONL | JSONL | yes | yes | +| OAuth | OAuth | yes | yes | +| macOS | macOS | yes | yes | +| TypeScript | TypeScript | yes | yes | +| Node.js | Node.js | yes | yes | +| npm | npm | yes | yes | +| pnpm | pnpm | yes | yes | +| kimi | kimi | yes | yes | +| 审批请求 | approval request | no | no | +| 斜杠命令 | slash command | no | no | +| 工具调用 | tool call | no | no | +| Frontmatter | frontmatter | yes | no | +| User 消息 | user message | yes (User) | no | +| Assistant 消息 | assistant message | yes (Assistant) | no | +| Tool 消息 | tool message | yes (Tool) | no | +| 轮次 | turn | no | no | +| 供应商 | provider | no | no | +| Prompt Flow | Prompt Flow | yes | yes | +| Diff | diff | yes | no | + +## Typography + +- **Spacing around mixed content**: Add a space between Chinese characters and English words, numbers, inline code, or links. Exception: no space before full-width punctuation. + - ✓ 在 TypeScript 中使用 `class` 关键字 + - ✗ 在TypeScript中使用`class`关键字 + - ✓ 详见 [配置文件](./config.md)。 + - ✗ 详见[配置文件](./config.md)。 +- **Full-width punctuation**: Use full-width punctuation in Chinese text: `,。;:?!()` not `, . ; : ? ! ( )`. +- **Keyboard shortcuts**: Use hyphen between modifier and key (`Ctrl-C`, `Ctrl-D`, `Shift-Tab`, `Alt-V`), not plus sign. Exception: literal application output (e.g., the `Press Ctrl+C again to exit` hint produced by the product itself) keeps its exact rendering. +- **Code block language**: Always specify language for fenced code blocks (e.g., ` ```sh `, ` ```toml `, ` ```json `, ` ```ts `). Exception: natural language examples (user prompts) may omit the language. +- **Callout titles**: Use short category titles for callout blocks (`::: tip`, `::: warning`, `::: info`, `::: danger`). Put the detailed description in the block content, not the title. + - Chinese: use `提示` for tip, `注意` for warning, `说明` for info, `警告` for danger. + - English: use no title or short words like `Note` for warning. + - ✓ `::: tip 提示` + content starting with the key point + - ✓ `::: warning 注意` + content `\`KIMI_CODE_HOME\` 不影响 Skills 的搜索路径。...` + - ✗ `::: warning 不影响 Skills` (title too long, should be in content) + - ✗ `::: tip Skills 路径独立于 KIMI_CODE_HOME` (title too long) +- **Version info blocks**: For version change callouts, use `::: info` with a category title (Added/Changed/Removed in English; 新增/变更/移除 in Chinese). The content should be a complete sentence. + - ✓ `::: info 新增` + content `新增于 0.2.0。` + - ✗ `::: info 新增于 0.2.0` (title too long) + - ✓ `::: info Changed` + content `Renamed in 0.2.0. ...` + - ✗ `::: info Renamed in 0.2.0` (title too long) + +## Writing style + +- **Natural narrative**: Organize content like writing an article, guiding readers smoothly through the material. +- **Avoid fragmentation**: Don't turn every point into a subheading; use paragraph transitions instead. +- **Global perspective**: "Getting Started" introduces core concepts only; detailed usage belongs in later pages. +- **Progressive depth**: Guides → Customization → Configuration → Reference, information deepens gradually. +- **No "next steps"**: VitePress already provides prev/next navigation; don't add manual `::: tip 接下来` blocks at page end. + +### Example: good vs bad + +Outline prompt: + +``` +* Install and upgrade + * System requirements: Node.js 24.15.0+, recommend pnpm + * Install, upgrade, uninstall steps +``` + +**Bad** (mechanical conversion to headings): + +```markdown +## Install and upgrade + +### System requirements + +- Node.js 24.15.0+ +- Recommend pnpm + +### Install + +... + +### Upgrade + +... +``` + +**Good** (natural narrative): + +```markdown +## Install and upgrade + +Kimi Code CLI requires Node.js 24.15.0 or later. We recommend using pnpm for installation and management. + +If you haven't installed pnpm yet, please refer to the pnpm installation docs first. Install Kimi Code CLI: + +(code block) + +Verify the installation: + +(code block) + +Upgrade to the latest version: + +(code block) +``` + +## Build and preview + +- Docs are built with VitePress from `docs/`. +- Common commands (run inside `docs/`): + - `npm install` + - `npm run dev` + - `npm run build` + - `npm run preview` +- The build output is `docs/.vitepress/dist`. + +## Changelog syncing + +See `sync-changelog` skill for the changelog generation workflow. diff --git a/docs/en/configuration/config-files.md b/docs/en/configuration/config-files.md new file mode 100644 index 000000000..9100e9ab0 --- /dev/null +++ b/docs/en/configuration/config-files.md @@ -0,0 +1,230 @@ +# Config files + +Kimi Code CLI stores its global configuration in a single TOML file that covers API providers, model aliases, agent loop parameters, background tasks, external services, and more. This page describes the location of the config file, its top-level fields, each nested structure, and a complete example. + +## Config file location + +The default config file is located at `~/.kimi-code/config.toml`. The directory and file are created automatically on first run with restrictive permissions. + +If you want to place the data directory elsewhere, set the `KIMI_CODE_HOME` environment variable: + +```sh +export KIMI_CODE_HOME=/path/to/kimi-home +``` + +The config file path then becomes `$KIMI_CODE_HOME/config.toml`. Regardless of where the directory lives, the file name is always `config.toml`. + +::: tip +TOML field names always use snake_case (for example, `default_model`, `max_context_size`). If a key contains `.`, you must use a quoted TOML key; otherwise TOML will treat `.` as a nested table separator. +::: + +## Top-level fields + +| Field | Type | Default | Description | +| --- | --- | --- | --- | +| `default_model` | `string` | — | Default model alias; must be defined in `models` | +| `default_thinking` | `boolean` | `false` | Initial value of the Thinking toggle for new sessions; can be flipped from the model menu inside a session. Even when this is `true`, setting `[thinking].mode = "off"` will still force Thinking off. See [`thinking`](#thinking) below | +| `default_permission_mode` | `string` | `manual` | Default permission mode for new sessions; one of `manual`, `auto`, `yolo` | +| `default_plan_mode` | `boolean` | `false` | Whether new sessions start in Plan mode by default; omitting it is equivalent to `false` | +| `merge_all_available_skills` | `boolean` | `true` | Whether to merge Agent Skills from all available directories | +| `extra_skill_dirs` | `array` | — | Extra skill search directories, layered on top of the default directories | +| `telemetry` | `boolean` | `true` | Whether anonymous telemetry is enabled; only disabled when explicitly set to `false` | +| `providers` | `table` | `{}` | API provider table; see below | +| `models` | `table` | — | Model alias table; see below | +| `thinking` | `table` | — | Default parameters for Thinking mode | +| `loop_control` | `table` | — | Agent loop control parameters | +| `background` | `table` | — | Background task runtime parameters | +| `services` | `table` | — | Built-in external service configuration | +| `permission` | `table` | — | Permission rule configuration; see below | +| `hooks` | `array` | — | Lifecycle hook configuration. See [Hooks](../customization/hooks.md) | + +## Complete example + +```toml +default_model = "kimi-code/kimi-for-coding" +default_thinking = true +default_permission_mode = "manual" +default_plan_mode = false +merge_all_available_skills = true +telemetry = true + +[providers."managed:kimi-code"] +type = "kimi" +base_url = "https://api.kimi.com/coding/v1" +api_key = "" + +[models."kimi-code/kimi-for-coding"] +provider = "managed:kimi-code" +model = "kimi-for-coding" +max_context_size = 262144 + +[thinking] +mode = "auto" + +[loop_control] +max_steps_per_turn = 1000 +max_retries_per_step = 3 +reserved_context_size = 50000 + +[background] +max_running_tasks = 4 +keep_alive_on_exit = false +agent_task_timeout_s = 900 + +[[permission.rules]] +decision = "allow" +pattern = "Read" + +[[permission.rules]] +decision = "deny" +pattern = "Bash(rm -rf*)" + +[[hooks]] +event = "PreToolUse" +matcher = "Bash" +command = "node ~/.kimi-code/hooks/check-bash.mjs" +timeout = 5 +``` + +## `providers` + +Each entry in the `providers` table defines the connection info for one API provider, keyed by a unique name. + +| Field | Type | Required | Description | +| --- | --- | --- | --- | +| `type` | `string` | Yes | Provider type; one of `anthropic`, `openai`, `kimi`, `google-genai`, `openai_responses`, `vertexai` | +| `api_key` | `string` | No | API key | +| `base_url` | `string` | No | API base URL | +| `oauth` | `table` | No | OAuth credential reference; see below | +| `env` | `table` | No | A configuration sub-table keyed by provider-specific names (such as `KIMI_API_KEY`, `ANTHROPIC_API_KEY`, `GOOGLE_CLOUD_PROJECT`), used as fallback values for `api_key` / `base_url` and related fields. This is just a sub-table inside the config file — **it is not written into your shell environment** — and is consulted only when the corresponding field on `[providers.]` is unset | +| `custom_headers` | `table` | No | Custom HTTP headers attached to each request | + +OAuth credential reference structure: + +| Field | Type | Required | Description | +| --- | --- | --- | --- | +| `storage` | `string` | Yes | Credential storage location; currently only `file` is supported | +| `key` | `string` | Yes | Unique identifier of the credential entry | + +```toml +[providers.openai] +type = "openai" +base_url = "https://api.openai.com/v1" +api_key = "sk-xxx" +custom_headers = { "X-Custom-Header" = "value" } +``` + +## `models` + +Each entry in the `models` table defines a model alias, keyed by a unique name. + +| Field | Type | Required | Description | +| --- | --- | --- | --- | +| `provider` | `string` | Yes | Name of the provider to use; must be defined in `providers` | +| `model` | `string` | Yes | Model identifier used when calling the API | +| `max_context_size` | `integer` | Yes | Maximum context length in tokens; must be at least 1 | +| `max_output_size` | `integer` | No | Per-request output budget cap (`max_tokens` on the wire). Only the `anthropic` provider currently honors it. When the alias resolves to a known Claude family, the value is clamped to that model's documented ceiling to avoid exceeding the server-side limit. Omit to use the per-model default — see [`providers.md`](./providers.md#anthropic). | +| `capabilities` | `array` | No | Capability tags to add explicitly, for example `thinking`, `image_in`, `video_in`, `audio_in`, `tool_use` | +| `display_name` | `string` | No | Name shown in the UI; falls back to `model` when unset | + +`capabilities` is unioned with the capabilities that the provider capability registry matches by model-name prefix — entries can only be added, never removed. You usually do not need to set this by hand; reach for it only when the model is not covered by the registry, or when you want to force-enable a particular capability. + +If a model alias contains `.`, use a quoted key: + +```toml +[models."gpt-4.1"] +provider = "openai" +model = "gpt-4.1" +max_context_size = 1047576 +``` + +## `thinking` + +`thinking` controls the default behavior of Thinking mode. Even when the top-level `default_thinking = true`, setting `mode` to `"off"` will still force Thinking off. + +| Field | Type | Default | Description | +| --- | --- | --- | --- | +| `mode` | `string` | — | Trigger policy; one of `auto`, `on`, `off`. `"off"` forces Thinking off; any other value or omission does not disable it, and the effective behavior is decided together by the per-session Thinking toggle and `effort` | +| `effort` | `string` | `high` | Default effort level used when Thinking is on; one of `low`, `medium`, `high`, `xhigh`, `max`. The levels actually available depend on the provider | + +## `loop_control` + +`loop_control` governs the step count, retries, and context compaction threshold of the agent execution loop. + +| Field | Type | Default | Description | +| --- | --- | --- | --- | +| `max_steps_per_turn` | `integer` | `1000` | Maximum number of steps per turn | +| `max_retries_per_step` | `integer` | `3` | Maximum retries per step | +| `reserved_context_size` | `integer` | — | Number of tokens reserved for response generation; compaction is triggered when the context approaches this threshold | + + + +## `background` + +`background` controls the runtime limits for background tasks. Background tasks are launched through the `Bash` tool or the `Agent` tool's `run_in_background=true` parameter. + +| Field | Type | Default | Description | +| --- | --- | --- | --- | +| `max_running_tasks` | `integer` | — | Maximum number of background tasks running concurrently | +| `keep_alive_on_exit` | `boolean` | `true` | Whether to keep still-running background tasks when the session closes. Set to `false` to request stopping background tasks when `kimi -p` finishes and exits, when an SDK session closes, or when a harness closes | +| `agent_task_timeout_s` | `integer` | — | Maximum runtime in seconds for background agent tasks | + +`keep_alive_on_exit` can be overridden by the `KIMI_CODE_BACKGROUND_KEEP_ALIVE_ON_EXIT` environment variable; the environment variable has higher priority than `config.toml`. The schema also reserves `kill_grace_period_ms` and `print_wait_ceiling_s`; these fields currently pass schema validation only and are not read by the CLI runtime. + +## `services` + +`services` configures the built-in external services Kimi Code CLI calls. Only the two fixed keys `moonshot_search` (web search) and `moonshot_fetch` (web fetch) are recognized; other keys are ignored. Both entries share the same fields: + +| Field | Type | Required | Description | +| --- | --- | --- | --- | +| `base_url` | `string` | No | Service API URL | +| `api_key` | `string` | No | API key | +| `oauth` | `table` | No | OAuth credential reference, same structure as `providers.*.oauth` | +| `custom_headers` | `table` | No | Custom HTTP headers attached to each request | + +```toml +[services.moonshot_search] +base_url = "https://api.moonshot.cn/v1/search" +api_key = "sk-xxx" + +[services.moonshot_fetch] +base_url = "https://api.moonshot.cn/v1/fetch" +api_key = "sk-xxx" +``` + +## `permission` + +`permission` configures the initial permission rules loaded when a session starts, controlling the default approval behavior for tool calls. The default permission mode for new sessions is controlled by the top-level `default_permission_mode` field; an explicit startup permission mode, such as the CLI's `--yolo` flag, overrides that default. + +Rules are written as a `[[permission.rules]]` array of tables, where each rule contains the following fields: + +| Field | Type | Required | Description | +| --- | --- | --- | --- | +| `decision` | `string` | Yes | Decision result; one of `allow`, `deny`, `ask` | +| `scope` | `string` | No | Rule scope; one of `turn-override`, `session-runtime`, `project`, `user`; defaults to `user` | +| `pattern` | `string` | Yes | Match pattern in the form `ToolName` or `ToolName(arg-pattern)`. `ToolName` must match the runtime tool name exactly — built-in tools are `Read`, `Write`, `Edit`, `Bash`, `Grep`, and so on (see [Built-in tools](../reference/tools.md)) | +| `reason` | `string` | No | Rule description for debugging or auditing | + +Example: + +```toml +[[permission.rules]] +decision = "allow" +pattern = "Read" + +[[permission.rules]] +decision = "allow" +pattern = "Grep" + +[[permission.rules]] +decision = "deny" +pattern = "Bash(rm -rf*)" + +[[permission.rules]] +decision = "ask" +pattern = "Bash" +``` + +::: tip +MCP server declarations are configured in `~/.kimi-code/mcp.json` or the project-local `.kimi-code/mcp.json`, not in `config.toml`. The interactive configuration entry point is `/mcp-config`; see [Model Context Protocol](../customization/mcp.md). +::: diff --git a/docs/en/configuration/data-locations.md b/docs/en/configuration/data-locations.md new file mode 100644 index 000000000..2d081d6ac --- /dev/null +++ b/docs/en/configuration/data-locations.md @@ -0,0 +1,131 @@ +# Data Locations + +Kimi Code CLI stores its runtime data centrally under the `~/.kimi-code/` directory in the user's home folder. This page describes where each type of data lives, what it is for, and how to customize or clean it up. + +## Data root + +The default data root is `~/.kimi-code/`. The `~` is resolved by Node.js's `os.homedir()`, so the actual path differs slightly across platforms: on macOS it is `/Users//.kimi-code`, on Linux `/home//.kimi-code`, and on Windows `C:\Users\\.kimi-code`. + +You can override it to any path with the `KIMI_CODE_HOME` environment variable: + +```sh +export KIMI_CODE_HOME="$HOME/.config/kimi-code" +``` + +Once set, runtime data such as the config, sessions, logs, input history, update cache, and OAuth credentials lands under that path. For the full reference on `KIMI_CODE_HOME` and other environment variables, see [Environment variables](./env-vars.md). + +::: tip Exceptions +The **built-in tool cache** (such as the auto-downloaded ripgrep binary) does not follow `KIMI_CODE_HOME`. It uses `KIMI_CODE_CACHE_DIR`, falling back to a platform cache directory — `~/Library/Caches/kimi-code` on macOS, `$XDG_CACHE_HOME/kimi-code` (default `~/.cache/kimi-code`) on Linux, and `%LOCALAPPDATA%\kimi-code` on Windows. + +User-level Agent Skills search directories live at `~/.kimi-code/skills` and `~/.agents/skills`; project-level Skills live under the working directory at `.kimi-code/skills` and `.agents/skills`. See [Agent Skills](../customization/skills.md) for details. +::: + +## Directory layout + +A typical layout under the data root looks like: + +``` +$KIMI_CODE_HOME (default ~/.kimi-code) +├── config.toml # User config +├── mcp.json # User-level MCP server declarations (optional) +├── session_index.jsonl # Session index +├── credentials/ # OAuth credential root (directory 0o700, files 0o600) +│ ├── .json # Hosted Kimi / Open Platform provider OAuth credentials +│ └── mcp/ # MCP server OAuth credentials +│ └── -.json +├── sessions/ # Session data +│ └── / +│ └── / +│ ├── state.json +│ ├── logs/ +│ │ └── kimi-code.log +│ ├── tasks/ # Background task persistence +│ │ ├── .json +│ │ └── / +│ │ └── output.log +│ └── agents/ +│ ├── main/ +│ │ ├── wire.jsonl +│ │ └── plans/ # Plan mode plan files +│ └── agent-0/ +│ └── wire.jsonl +├── bin/ +│ └── rg # ripgrep cache (rg.exe on Windows) +├── logs/ # Global diagnostic logs +│ └── kimi-code.log +├── updates/ +│ └── latest.json # Update check status +└── user-history/ + └── .jsonl +``` + +::: tip +The tree above shows a typical layout under the default data root (`~/.kimi-code/`). The paths for Agent Skills and the built-in tool cache have some special cases — see the "Exceptions" note above. +::: + +## Config files + +`config.toml` is Kimi Code CLI's main config file, holding user-level settings such as providers, models, and loop control. See [Config files](./config-files.md) for details. + +`mcp.json` holds user-level MCP server declarations and is merged with the project-local `.kimi-code/mcp.json` at load time. The fields are the same as the project-level file; see [MCP](../customization/mcp.md) for details. + +OAuth credentials are stored as files under the `credentials/` subdirectory of the data root. The parent directory uses mode `0o700` and each credential file uses mode `0o600`, readable and writable only by the current user. There are two sub-locations: + +- **Hosted Kimi / Open Platform provider OAuth credentials** live at `credentials/.json`, for example `~/.kimi-code/credentials/managed:kimi-code.json`. +- **MCP server OAuth credentials** live under the `credentials/mcp/` subdirectory, with file names generated from the server key, for example `credentials/mcp/-.json`. + +Writes follow a `tmp → fsync → rename` atomic flow: strictly atomic on POSIX, best-effort on Windows. + +## Session data + +Session-related data is grouped under `sessions/`, with a top-level `session_index.jsonl` maintaining a JSONL index: one record per line containing the three fields `sessionId`, `sessionDir`, and `workDir`. Entries are appended when a session is created. When the index is loaded, each entry is validated to ensure `sessionDir` still lives under `sessions/` and that its last path component equals `sessionId`, preventing external tampering from pointing entries to illegal paths. + +Each session directory has a path like `sessions///`, where `workDirKey` is a bucket name encoded from the working directory in the format `wd__` (for example, `wd_myproject_a3f8c1d20e9b`), and `sessionId` is the session's unique identifier. The full path under `sessions/`, including each `/` bucket, is created with mode `0o700` and accessible only by the current user. + +The internal structure of a session directory includes: + +- `state.json`: session title, `lastPrompt`, `createdAt`, `updatedAt`, `isCustomTitle`, `forkedFrom`, and metadata for each agent. +- `agents/main/wire.jsonl`: the Wire event stream of the main agent, used for replay and resumption. `main` is the fixed id of the main agent. +- `agents/main/plans/`: plan files written by the main agent in Plan mode, named `.md` by plan id. +- `agents/agent-0/`, `agents/agent-1/`, etc.: subagent instance directories, each with its own `wire.jsonl`. Subagent ids are generated by a per-session incrementing counter (`agent-` followed by an integer starting from 0). +- `logs/kimi-code.log`: the diagnostic log for this session. It only appears after a recorded diagnostic event; an ordinary conversation may not create this file. +- `tasks/`: background task persistence directory. Each task stores its metadata (status, pid, exit code, etc.) in `tasks/.json`, with stdout and stderr written to `tasks//output.log`. Task ids use a `bash-` or `agent-` prefix followed by 8 random alphanumeric characters (for example, `bash-a1b2c3d4`). + +`sessionId` is restricted to `[A-Za-z0-9._-]+` and cannot be `.` or `..`, preventing path injection. The session list is sorted by `updatedAt` in descending order, where `updatedAt` is the maximum mtime of the directory and its key files. See [Sessions](../guides/sessions.md) for details. + +## Built-in tool cache + +The first time Kimi Code CLI needs ripgrep, it downloads and caches it automatically. During the download, the archive is written to the system temporary directory and verified by SHA-256 before extraction; the binary is then installed directly to `bin/rg` under the data root (or `bin/rg.exe` on Windows) and marked `0o755` so it can be executed. Subsequent runs under the same data root reuse it with no further download. If `rg` is already on the system `PATH`, the system version takes precedence; deleting `bin/` triggers a redownload the next time it is needed. + +## Logs and update state + +The top-level `logs/kimi-code.log` is the global diagnostic log. It mainly records issues that do not belong to a single session, such as startup, login, and export failures. A single session's diagnostic log lives at `/logs/kimi-code.log`. + +When filing a bug report, prefer `kimi export` for the relevant session (see [The kimi command](../reference/kimi-command.md) for details). If a session log exists, it is included in the export by default. The global diagnostic log is also bundled by default; because it may contain events from other sessions or projects, use `--no-include-global-log` when you do not want to share it. + +`updates/latest.json` records the version update status detected via npm and is maintained automatically by the CLI — there is usually no need to edit it by hand. + +## Input history + +Command input history from the terminal is saved per working directory. Each working directory corresponds to one file, at `user-history/.jsonl`, where the file name is the MD5 hash of the working directory string (UTF-8 encoded). The file format is JSONL, with one history record per line. + +Input history is used to browse and search previously entered prompts in the terminal interface. + +## Cleaning up data + +Deleting the data root directory (default `~/.kimi-code/`, or the path specified by `KIMI_CODE_HOME`) wipes all of Kimi Code CLI's runtime data, including config, sessions, logs, input history, and the built-in tool cache. + +To clean up only part of the data: + +| Goal | Action | +| --- | --- | +| Reset config | Delete `~/.kimi-code/config.toml` | +| Clear all sessions | Delete `~/.kimi-code/sessions/` and `~/.kimi-code/session_index.jsonl` | +| Clear diagnostic logs | Delete the `~/.kimi-code/logs/` directory | +| Clear input history | Delete the `~/.kimi-code/user-history/` directory | +| Reset update check state | Delete `~/.kimi-code/updates/latest.json` | +| Force a ripgrep redownload | Delete the `~/.kimi-code/bin/` directory | +| Clear hosted Kimi / Open Platform OAuth login state | Run `/logout` (clears only the current provider's OAuth), or delete the corresponding `~/.kimi-code/credentials/.json` | +| Clear MCP server OAuth login state | Delete the `~/.kimi-code/credentials/mcp/` directory; `/logout` **does not** clear MCP OAuth credentials | +| Remove user-level MCP declarations | Delete `~/.kimi-code/mcp.json` | +| Clear user-level Skills | Delete the `~/.kimi-code/skills/` directory | diff --git a/docs/en/configuration/env-vars.md b/docs/en/configuration/env-vars.md new file mode 100644 index 000000000..78a59591e --- /dev/null +++ b/docs/en/configuration/env-vars.md @@ -0,0 +1,124 @@ +# Environment variables + +Kimi Code CLI uses environment variables to override default paths, switch OAuth endpoints, and adjust runtime behavior. Most variables are read when the `kimi` process starts up; a few (such as the telemetry switch, the OAuth lock, and diagnostic logging) are read when the relevant subsystem initializes. Kimi's own variables use the `KIMI_*` prefix; in addition, the CLI also reads a number of standard system variables. + +::: warning Note +**Provider credentials are not in this list**: key variables such as `KIMI_API_KEY`, `ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, and `GOOGLE_API_KEY` are **not** read automatically from `process.env`. They must be written into the `[providers.]` section of `config.toml` (as `api_key` / `base_url`) or into the `[providers..env]` subtable; merely `export`ing them in your shell will not give a provider credentials automatically. See [Configuration overrides](./overrides.md#provider-credentials) and [Providers](./providers.md) for details. +::: + +## Core paths + +`KIMI_CODE_HOME` overrides Kimi Code CLI's data root directory, defaulting to `~/.kimi-code`. The CLI's own application data, `kimi-core`'s config, the ripgrep cache, and OAuth credentials all land under this directory. + +```sh +export KIMI_CODE_HOME="/path/to/custom/kimi-code" +``` + +For details on the data layout, see [Data locations](./data-locations.md). + +::: warning Note +Make sure the directory is writable once you set it. Multiple `kimi` instances that share the same `KIMI_CODE_HOME` will share both the config and credential files. +::: + +## Provider credential key names + +The following key names appear in the `[providers..env]` subtable of `config.toml`, where they serve as fallback sources for the provider's `api_key` / `base_url`. **The main `kimi` process does not read them directly from `process.env`**; only the values keyed under a `[providers..env]` subtable are recognized by the provider clients. See [Configuration overrides: provider credentials](./overrides.md#provider-credentials) for the full resolution order. + +| Key name | Applicable provider | Purpose | Default | +| --- | --- | --- | --- | +| `KIMI_API_KEY` | Kimi / Moonshot | API key | None | +| `KIMI_BASE_URL` | Kimi / Moonshot | API base URL | `https://api.moonshot.ai/v1` | +| `ANTHROPIC_API_KEY` | Anthropic | API key | None | +| `ANTHROPIC_BASE_URL` | Anthropic | API base URL | Follows the Anthropic SDK default | +| `OPENAI_API_KEY` | OpenAI (used by both `openai` and `openai_responses`) | API key | None | +| `OPENAI_BASE_URL` | OpenAI (used by both `openai` and `openai_responses`) | API base URL | `https://api.openai.com/v1` | +| `GOOGLE_API_KEY` | Google GenAI, Vertex AI (as a fallback for `VERTEXAI_API_KEY`) | API key | None | +| `VERTEXAI_API_KEY` | Vertex AI | API key (when not using ADC) | None | +| `GOOGLE_CLOUD_PROJECT` | Vertex AI | GCP project ID | None | +| `GOOGLE_CLOUD_LOCATION` | Vertex AI | GCP region | None | + +For example, to pre-populate Kimi credentials in `config.toml`: + +```toml +[providers.kimi.env] +KIMI_API_KEY = "sk-xxx" +KIMI_BASE_URL = "https://api.moonshot.ai/v1" +``` + +::: warning Note +`GOOGLE_APPLICATION_CREDENTIALS` (the path to a service-account JSON file) is read by the Google SDK itself from the shell environment, making it the **only** key in this group that goes through a system environment variable. It follows Google Cloud's standard ADC flow, and the CLI is not involved in resolving it. Every other key only takes effect when written into a `[providers..env]` subtable. +::: + +For the full description of provider types and fields, see [Providers](./providers.md). + +## OAuth and the hosted service + +The OAuth flow connects to Kimi's official authentication and hosted endpoints by default. The variables below can point them at a self-hosted or test environment. + +| Environment variable | Purpose | Default | +| --- | --- | --- | +| `KIMI_CODE_OAUTH_HOST` | OAuth authentication host; takes the highest precedence | — (falls back to `KIMI_OAUTH_HOST`, then to the hardcoded default below) | +| `KIMI_OAUTH_HOST` | OAuth authentication host; used as a fallback for `KIMI_CODE_OAUTH_HOST` | — (falls back to the hardcoded default below) | +| `KIMI_CODE_BASE_URL` | Base URL of the hosted Kimi API, used for API calls after OAuth login | `https://api.kimi.com/coding/v1` | + +When neither `KIMI_CODE_OAUTH_HOST` nor `KIMI_OAUTH_HOST` is set, the OAuth authentication host uses the hardcoded constant `https://auth.kimi.com`. + +::: warning Note +`KIMI_CODE_BASE_URL` and the `KIMI_BASE_URL` from the previous section are two different variables: the former targets the OAuth-logged-in hosted service and defaults to `kimi.com`; the latter targets providers that use a Kimi API key directly and defaults to `moonshot.ai`. Distinguish them by use case. +::: + +## Runtime switches + +| Environment variable | Purpose | Valid values / Default | +| --- | --- | --- | +| `KIMI_DISABLE_TELEMETRY` | Disable telemetry reporting | `1`, `true`, `t`, `yes`, `y` (case-insensitive) | +| `KIMI_CODE_BACKGROUND_KEEP_ALIVE_ON_EXIT` | Override `[background].keep_alive_on_exit`, controlling whether still-running background tasks are kept when the session closes | True values: `1`, `true`, `yes`, `on`; false values: `0`, `false`, `no`, `off`; when unset, reads `config.toml`, then falls back to `true` | +| `KIMI_SHELL_PATH` | Override the absolute path to Git Bash (`bash.exe`) on Windows; only needed when auto-detection fails on Windows | None | +| `KIMI_MODEL_MAX_COMPLETION_TOKENS` | Desired budget for `max_completion_tokens` in a single-step LLM request (the actual value is further clamped by the context window and input size); set to `0` or a negative value to disable clamping entirely. **Currently effective only for providers of type `kimi`**; for Anthropic and other providers, use `[models.].max_output_size` instead (see [Config files](./config-files.md#models)) | Defaults to 32000, influenced by `loop_control.reserved_context_size` | + +For example, to disable telemetry on a shared host: + +```sh +export KIMI_DISABLE_TELEMETRY="1" +``` + +`KIMI_CODE_BACKGROUND_KEEP_ALIVE_ON_EXIT` has higher priority than `config.toml`. For example, running `KIMI_CODE_BACKGROUND_KEEP_ALIVE_ON_EXIT=0 kimi -p "..."` temporarily requests stopping background tasks before this process exits, even if the config file sets `keep_alive_on_exit = true`. +## Diagnostic logging + +The variables below control `kimi`'s diagnostic logs. Logs are written to two locations: the global diagnostic log at `$KIMI_CODE_HOME/logs/kimi-code.log`, and each session's own diagnostic log at `/logs/kimi-code.log` (see [Data locations](./data-locations.md#logs-and-update-state) for path details). All of these variables are read only once at process startup. + +| Environment variable | Purpose | Default | +| --- | --- | --- | +| `KIMI_LOG_LEVEL` | Log level; one of `off`, `error`, `warn`, `info`, `debug` | `info` | +| `KIMI_LOG_GLOBAL_MAX_BYTES` | Maximum bytes per global log file | `6291456` (6 MB) | +| `KIMI_LOG_GLOBAL_FILES` | Number of global log files to retain | `5` | +| `KIMI_LOG_SESSION_MAX_BYTES` | Maximum bytes per session log file | `5242880` (5 MB) | +| `KIMI_LOG_SESSION_FILES` | Number of session log files to retain | `3` | + +When an integer variable fails to parse (non-positive integer or non-numeric), it silently falls back to the default value. + +## Clipboard bridge + +`KIMI_WSL_CLIPBOARD_IMAGE_PATH` is injected automatically by the CLI when it spawns the WSL clipboard helper subprocess, used to pass a temporary image path. The variable is written into the PowerShell subprocess's environment and read by the subprocess script internally; the main `kimi` process does not read this variable itself. Setting it in an external shell has **no effect** on the main `kimi` process — users do not need to manage this variable manually. + +## System environment variables + +Kimi Code CLI also reads a handful of standard system environment variables to detect the runtime environment and pick default behavior: + +- `HOME`: the user's home directory, used to resolve the default data path. +- `VISUAL`, `EDITOR`: the executable invoked as the external editor, with `VISUAL` taking precedence. +- `PATH`: used to locate external dependencies such as `rg` and `git`. +- `NO_COLOR`: when set and non-empty, forces color and theme detection off, falling back to the dark theme. Follows the [no-color.org](https://no-color.org) convention. +- `FORCE_COLOR`: when set to `"0"`, also disables color and theme detection, falling back to the dark theme. +- `CI`: when non-empty and not `"0"`, disables theme detection and falls back to the dark theme; the telemetry module also reads this variable to mark the CI environment. +- `LANG`: used to tag the locale in the telemetry context (purely as a tag; it does not change CLI behavior). +- `TERM_PROGRAM`: used to detect terminal support for OSC 9 notifications (iTerm2, WezTerm, ghostty, WarpTerminal, etc.); also written into the telemetry context. +- `TERM`: used to detect terminal support for OSC 9 notifications (xterm-kitty, xterm-ghostty, etc.). +- `TMUX`: detects whether the CLI is running inside tmux, used for the terminal notification path. +- `COLORFGBG`: detects the terminal color scheme (dark / light). +- `DISPLAY`, `WAYLAND_DISPLAY`, `XDG_SESSION_TYPE`: detect a Linux graphical session, used by clipboard and image-related features. A `XDG_SESSION_TYPE` value of `wayland` is also treated as a Wayland session. +- `WSL_DISTRO_NAME`, `WSLENV`: detect whether the CLI is running inside WSL, used for the PowerShell-bridged clipboard fallback. +- `TERMUX_VERSION`: detects whether the CLI is running inside Termux. +- `LOCALAPPDATA`: used on Windows when probing for the Git Bash installation path. + +These variables follow the usual conventions of each operating system; `kimi` only reads them and never modifies them. diff --git a/docs/en/configuration/overrides.md b/docs/en/configuration/overrides.md new file mode 100644 index 000000000..1567000dd --- /dev/null +++ b/docs/en/configuration/overrides.md @@ -0,0 +1,110 @@ +# Configuration overrides + +Kimi Code CLI's runtime parameters come from the user config file, command-line flags, and a handful of runtime paths, endpoints, and switches read from process-level environment variables. Each layer serves a different purpose — the config file captures long-term preferences, CLI flags are well suited for temporary tweaks at this launch, and environment variables mainly locate the data directory, switch OAuth endpoints, and toggle a small number of runtime switches. + +Environment variables are **not a universal fallback for configuration fields** in Kimi Code CLI. They fall into three categories with different scopes, and cannot be flattened into a single linear priority list. + +## Three roles of environment variables + +1. **Config file location**: `KIMI_CODE_HOME` determines the data root holding the config file, sessions, logs, and so on, making the config file path `$KIMI_CODE_HOME/config.toml` (otherwise `~/.kimi-code/`). This is a "where to find the config" step that runs before everything else; it is not a fallback source for ordinary parameters. There is also no `KIMI_CONFIG_PATH`-style variable for pointing at an arbitrary config file. +2. **Runtime switches**: a small number of switches such as `KIMI_DISABLE_TELEMETRY` directly turn off the corresponding subsystem. Even if `config.toml` has `telemetry = true`, telemetry is still disabled whenever this variable is set to a truthy value — its semantics are "additionally disable", not "ordinary override". +3. **Runtime endpoints and diagnostics**: `KIMI_CODE_OAUTH_HOST`, `KIMI_OAUTH_HOST`, `KIMI_CODE_BASE_URL`, `KIMI_LOG_LEVEL`, and friends are read during OAuth and diagnostic subsystem initialization. See [Environment variables](./env-vars.md) for the full list. + +## Priority of ordinary runtime parameters + +For other runtime parameters (model alias, Plan / yolo mode, Skills directories, and so on), resolution is: + +1. **Command-line flags**: parameters supplied at this launch; override every other source and apply only to the current launch. +2. **User config file**: `$KIMI_CODE_HOME/config.toml` (defaulting to `~/.kimi-code/config.toml`), used to capture long-term preferences. + +A few environment variables explicitly override related config fields. For example, `KIMI_CODE_BACKGROUND_KEEP_ALIVE_ON_EXIT` has higher priority than `[background].keep_alive_on_exit`. These exceptions are called out in [Environment variables](./env-vars.md) and in the corresponding [Config files](./config-files.md) field reference. + +::: warning Note +Ordinary runtime parameters **do not** fall back to shell environment variables. For example, provider `api_key` / `base_url` are read only from fields in `config.toml` (including the `[providers..env]` subtable); they do not fall back to shell exports like `export KIMI_API_KEY`. See [Provider credentials](#provider-credentials) below. +::: + +Kimi Code CLI currently reads only one user-level config file. There is no project-level (in-repo) config file mechanism. To isolate configuration between projects, point `KIMI_CODE_HOME` at a different data directory (see [Typical scenarios](#typical-scenarios) below) or temporarily override specific fields with CLI flags at launch. + +## Config file + +The config file location is controlled by the `KIMI_CODE_HOME` environment variable, falling back to `~/.kimi-code/` when unset. The file name is fixed as `config.toml`, and the directory is created with `0o700` permissions. The file can declare long-term preferences such as `default_model`, `providers`, `models`, `thinking`, and `loop_control`. See [Config files](./config-files.md) for the field reference. + +## Provider credentials + +Provider credentials (`api_key`, `base_url`) have their own resolution rules: Kimi Code CLI reads provider fields only from `config.toml` and **does not** fall back to shell environment variables. Running `export KIMI_API_KEY` in your terminal alone will not give a `[providers.]` entry credentials — you have to write them into the config file explicitly. + +For a single provider, credentials are resolved in this order: + +1. `[providers.].api_key` — the key written explicitly into the config file; highest priority. +2. The corresponding key in the `[providers..env]` subtable (such as `KIMI_API_KEY`, `ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, `GOOGLE_API_KEY`) — moving the environment-variable names you would normally write in your shell into a TOML subtable. Used only when `api_key` is left empty. This is just the form of a config sub-table; it does not actually modify your process environment. +3. If both are missing, startup fails with a message that the provider has no credentials configured. + +`base_url` resolves similarly to `api_key`: `[providers.].base_url` is checked first, then `*_BASE_URL` keys (such as `ANTHROPIC_BASE_URL`, `OPENAI_BASE_URL`, `KIMI_BASE_URL`) in `[providers..env]`. See [Providers](./providers.md) for the full reference of provider types and fields. + +## Process-level environment variables + +Variables in `process.env` are read at Kimi Code CLI startup and fall into the three roles described above in [Three roles of environment variables](#three-roles-of-environment-variables): + +- **Data root and log paths**: `KIMI_CODE_HOME` switches `~/.kimi-code/`; `KIMI_LOG_LEVEL` and friends control diagnostic logs. +- **Runtime switches**: `KIMI_DISABLE_TELEMETRY` disables telemetry (overriding `telemetry = true` in `config.toml`). +- **OAuth endpoints and diagnostics**: `KIMI_CODE_OAUTH_HOST`, `KIMI_OAUTH_HOST`, and `KIMI_CODE_BASE_URL` control the hosted Kimi login endpoints; `KIMI_LOG_LEVEL` and friends control diagnostic logs. +- **Background-task exit policy**: `KIMI_CODE_BACKGROUND_KEEP_ALIVE_ON_EXIT` overrides `[background].keep_alive_on_exit`, letting you decide for this process whether background tasks are kept on exit. + +See [Environment variables](./env-vars.md) for the full list of variables and their scopes. + +## Command-line flags + +Parameters supplied via CLI flags at launch have the highest priority and only apply to the current launch. Common flags: + +| Flag | Effect | +| --- | --- | +| `-S, --session [id]` | Resume the specified session; without an id, enters interactive selection | +| `-C, --continue` | Continue the most recent session for the current working directory | +| `-y, --yolo` | Auto-approve ordinary tool calls (aliases: `--yes`, `--auto-approve`) | +| `--plan` | Launch in Plan mode | +| `-m, --model ` | Specify the model alias to use for this launch | +| `-p, --prompt ` | Execute a single prompt in non-interactive mode and exit | +| `--output-format ` | Specify the output format for `-p` mode: `text` or `stream-json` | +| `--skills-dir ` | Replace the auto-discovered Skills directory (can be specified multiple times; applies to this launch only) | + +Mutually exclusive flag rules: + +- `--output-format` can only be used in prompt mode (`-p / --prompt`). +- `--prompt` cannot be combined with `--yolo`, nor with `--plan`. +- In prompt mode, `-S / --session` must be given an id; the interactive selector (bare `--session`) is not accepted. +- `--continue` and `--session` cannot be used together. +- Outside prompt mode, `--yolo` cannot be combined with `--continue` or `--session`; `--plan` cannot be combined with `--continue` or `--session`. +- `--yolo` and `--plan` can be used together. + +::: tip Tip +`--skills-dir` replaces the auto-discovered Skills directory for this launch and is suitable for one-off use. To persistently append search directories, set `extra_skill_dirs` at the top level of `config.toml` (see [Agent Skills](../customization/skills.md)). The two options have different semantics and can be chosen based on your needs. +::: + +## Typical scenarios + +**Switch the data directory for isolated testing.** `KIMI_CODE_HOME` simultaneously affects the config file, session archives, ripgrep cache, and every other data location: + +```sh +KIMI_CODE_HOME="$PWD/.kimi-sandbox" kimi +``` + +**Stage temporary credentials in the config file.** Since provider credentials are read only from `config.toml`, to use a different API key for a single launch, write it into the `[providers..env]` subtable in advance: + +```toml +[providers.kimi.env] +KIMI_API_KEY = "sk-test" +``` + +You can also set `api_key` directly on the provider; see [Provider credentials](#provider-credentials) above for the priority between the two. + +**Skip approvals for this launch.** Suitable for batch tasks you already know are safe: + +```sh +kimi --yolo +``` + +**Enter Plan mode for this launch.** If you want the same default behavior, set `default_plan_mode = true` in the config file: + +```sh +kimi --plan +``` diff --git a/docs/en/configuration/providers.md b/docs/en/configuration/providers.md new file mode 100644 index 000000000..a7319dd17 --- /dev/null +++ b/docs/en/configuration/providers.md @@ -0,0 +1,136 @@ +# Providers and models + +Kimi Code CLI integrates with multiple LLM platforms through a unified provider abstraction. Each provider handles one API protocol, and models are declared on top of a provider with their own name, context length, and capabilities. This page describes every provider type currently supported and how to configure them in `~/.kimi-code/config.toml`. + +## Overview + +The `type` field of each entry in the `providers` table determines which implementation is used. The currently supported types are: + +| Type | Protocol | Typical platforms | +| --- | --- | --- | +| `kimi` | OpenAI-compatible (chat completions style) | Kimi Code, Moonshot AI Open Platform | +| `anthropic` | Anthropic Messages | Claude API | +| `openai` | OpenAI Chat Completions | OpenAI and compatible services | +| `openai_responses` | OpenAI Responses API | OpenAI's newer Responses endpoint | +| `google-genai` | Google GenAI | Gemini API | +| `vertexai` | Google GenAI on Vertex | Google Cloud Vertex AI | + +All providers stream model interactions by default. Thinking, vision, and tool-call capabilities are matched automatically by model name prefix, so you do not need to spell them out in the config. + +API keys may be written into the `api_key` field, or supplied under the `[providers..env]` sub-table. The lookup order is `api_key` > sub-table key > missing error. **Kimi Code CLI does not automatically fall back to shell environment variables** — `export KIMI_API_KEY` in your terminal alone will not give a provider credentials; you must write them into `config.toml` (see [Configuration overrides: provider credentials](./overrides.md#provider-credentials) for details). `api_key` and `oauth` are mutually exclusive on the same provider; setting both causes an error when the model is resolved. OAuth credentials are handled by the built-in login flow and do not need to be configured manually. + +The `[providers..env]` sub-table lets you supply credentials or endpoint overrides directly inside `config.toml`. These values are scoped to the provider and do not leak into the global shell environment: + +```toml +[providers.my-anthropic.env] +ANTHROPIC_API_KEY = "sk-ant-xxxxx" +ANTHROPIC_BASE_URL = "https://my-proxy.example.com" +``` + +The most common ways to switch providers are: use the `/model` slash command inside the TUI to pick from already-configured models, or edit `config.toml` directly to adjust the `[providers.*]` and `[models.*]` tables. See [Config files](./config-files.md) for the full field reference. + +## `kimi` + +`kimi` connects to the Moonshot AI API using the OpenAI-compatible protocol. + +- Default `base_url`: `https://api.moonshot.ai/v1` +- Environment variables: `KIMI_API_KEY`, `KIMI_BASE_URL` +- Extra capability: video upload + +```toml +[providers.kimi] +type = "kimi" +base_url = "https://api.moonshot.ai/v1" +api_key = "sk-xxxxx" +``` + +The Kimi Code hosted service configures its `base_url` and credentials automatically after OAuth login; see [OAuth and credential injection](#oauth-and-credential-injection) and [Environment variables](./env-vars.md) for details. + +## `anthropic` + +`anthropic` integrates with the Claude API. Standard Claude models automatically enable vision, tool calls, and thinking where supported. For custom or unreleased models, declare `capabilities` explicitly under `[models.]`. + +Thinking can be controlled via `/model`, `/settings`, or configuration. + +- Default `base_url`: follows the Anthropic SDK default +- Environment variables: `ANTHROPIC_API_KEY`, `ANTHROPIC_BASE_URL` +- Default `max_tokens`: set automatically per model. To override it (for example for testing or for an unrecognized alias), set `max_output_size` on the model alias (see [`config-files.md`](./config-files.md#models)). Recognized aliases are capped at the documented server-side ceiling. + +```toml +[providers.anthropic] +type = "anthropic" +api_key = "sk-ant-xxxxx" + +[models."claude-opus-4-7"] +provider = "anthropic" +model = "claude-opus-4-7" +max_context_size = 200000 +# Optional: lower the output budget for testing, or set one for a model +# this CLI doesn't know about yet. Omit to use the per-model default +# above. +# max_output_size = 32000 +``` + +## `openai` + +`openai` corresponds to the OpenAI Chat Completions protocol and can also be used to connect to any third-party service that speaks the same protocol (simply override `base_url`). Thinking, vision, and tool-call capabilities are inferred automatically from the model name. + +- Default `base_url`: `https://api.openai.com/v1` +- Environment variables: `OPENAI_API_KEY`, `OPENAI_BASE_URL` + +```toml +[providers.openai] +type = "openai" +base_url = "https://api.openai.com/v1" +api_key = "sk-xxxxx" +``` + +## `openai_responses` + +`openai_responses` corresponds to OpenAI's newer Responses API. It always operates in streaming mode; capabilities are inferred automatically from the model name. + +- Default `base_url`: `https://api.openai.com/v1` +- Environment variables: `OPENAI_API_KEY`, `OPENAI_BASE_URL` + +```toml +[providers.openai-responses] +type = "openai_responses" +base_url = "https://api.openai.com/v1" +api_key = "sk-xxxxx" +``` + +## `google-genai` + +`google-genai` connects directly to the Google Gemini API. Thinking, vision, and multimodal capabilities are inferred automatically from the model name. + +- Environment variable: `GOOGLE_API_KEY` + +```toml +[providers.gemini] +type = "google-genai" +api_key = "xxxxx" +``` + +## `vertexai` + +`vertexai` shares the same implementation as `google-genai`; setting `type = "vertexai"` switches it to the Vertex AI access path. + +Authentication follows the standard Google Cloud flow: authenticate via `gcloud auth application-default login`, or set `GOOGLE_APPLICATION_CREDENTIALS` to a service-account JSON (this step is a generic Google SDK mechanism unrelated to Kimi Code configuration). **The project and region must be written into the `[providers.vertexai.env]` sub-table** — `export GOOGLE_CLOUD_PROJECT` or `export GOOGLE_CLOUD_LOCATION` in the shell will not be read by the CLI. When `GOOGLE_CLOUD_LOCATION` is missing, the CLI tries to infer it from `base_url`. API keys (`VERTEXAI_API_KEY` or `GOOGLE_API_KEY`) likewise go into the sub-table. + +```toml +[providers.vertexai] +type = "vertexai" + +[providers.vertexai.env] +GOOGLE_CLOUD_PROJECT = "my-gcp-project" +GOOGLE_CLOUD_LOCATION = "us-central1" +``` + +```sh +gcloud auth application-default login # one-time +kimi +``` + +## OAuth and credential injection + +Some platforms (such as the Kimi Code hosted service) use OAuth instead of static API keys. Credentials are injected at runtime by the built-in kimi-oauth toolchain, and the login flow takes care of writing and refreshing them automatically — there is nothing to configure by hand for this in your config file. diff --git a/docs/en/customization/agents.md b/docs/en/customization/agents.md new file mode 100644 index 000000000..411d15f2c --- /dev/null +++ b/docs/en/customization/agents.md @@ -0,0 +1,45 @@ +# Agents and subagents + +The agent in Kimi Code CLI is the core that drives a session. A session is always hosted by a single main agent, which may dispatch one or more subagents to handle more focused subtasks as the work progresses. + +The main agent is responsible for understanding the user's overall intent, planning the steps, conversing with the user, calling tools, and finally aggregating the results. Its context spans the entire session, and it is the entity the user interacts with directly in the terminal. + +A subagent is a temporary "assistant" spawned by the main agent. It takes a clearly defined task description, completes the work independently in its own context, and then returns the conclusion to the main agent. Subagents do not converse with the user directly, nor do they pollute the main agent's context history. This division of labor is particularly suited for work with clear boundaries that requires a lot of reading but produces a short output — such as "exploring a codebase", "reviewing a large implementation", or "planning a complex change". + +## Built-in subagents + +Kimi Code CLI ships with three built-in subagents, each targeting a different kind of task, ready to use out of the box: + +- **`coder`**: The default subagent, a general software engineering assistant that can read and write files, run shell commands, search code, and land concrete changes. +- **`explore`**: Dedicated to codebase exploration. It operates read-only and will not modify any files. Suitable for quickly searching, reading, and summarizing a repository without making changes. +- **`plan`**: For implementation planning and architectural design. Its toolset is narrowed further — not even shell commands are provided — focusing on "thinking through how to do it" rather than "doing it". + +## Invocation + +Subagents are dispatched automatically by the main agent. The main agent decides when to dispatch one based on task complexity, context usage, and the independence of the subtask — you don't need to manually specify the timing. + +Each dispatch surfaces as an approval request in the terminal (unless it matches an existing allow rule or you are in YOLO mode), so you can inspect the task description before it runs. You can also ask the main agent to prefer a particular type of subagent for a task — for example, "use `explore` first to map out the relevant files before making changes." + +Subagents can also run in the background; their results are synthesized back to the main agent automatically on a later turn, with no manual polling needed. You can also resume an existing subagent instance to continue the same task. + +## Context isolation and resource cost + +Each subagent has a completely independent context window. It cannot see the main agent's conversation history; it can only see the task description that the main agent explicitly passed. The subagent's own intermediate thoughts and tool call records do not flow back to the main agent — only the final result appears in the main agent's context. + +This isolation brings two direct benefits. First, the main agent's context stays concise and isn't flooded with exploratory logs over a long session. Second, multiple subagents can run in parallel without interfering with each other. + +The cost is that each subagent independently consumes model tokens, so it is unnecessary to dispatch a subagent for simple tasks — letting the main agent handle them directly is more economical. Subagents also cannot dispatch further subagents. + +## Permission inheritance + +A subagent's permission rules are inherited from the main agent: "always allow" rules that the main agent has accepted via `/permission` or during an approval automatically cover every subagent it dispatches, so the subagent does not need to re-approve the same kind of tool call. The `Agent` tool itself is allowed by default, so the main agent can complete multiple delegations without interrupting the user. + +If you want a particular kind of tool to be permanently unavailable inside subagents, you should tighten the main agent's permission rules. + +## Storage location in the session directory + +A subagent's runtime state is persisted to the session directory for later inspection and debugging. Each subagent instance corresponds to a separate subdirectory under `agents/`, which contains a `wire.jsonl` file recording prompts, message history, and final status in chronological order. Background subagents additionally expose their lifecycle through the `tasks/` subdirectory. + +::: warning Note +Session directories, wire files, and task records are local debugging material and may contain user prompts, command output, repository paths, tool results, or traces of credentials. Do not commit these files to public repositories, issues, or chat transcripts directly; redact them first if you need to share them. +::: diff --git a/docs/en/customization/hooks.md b/docs/en/customization/hooks.md new file mode 100644 index 000000000..404261b3a --- /dev/null +++ b/docs/en/customization/hooks.md @@ -0,0 +1,150 @@ +# Hooks + +Hooks let you run local commands at key lifecycle points in Kimi Code CLI. They are useful for lightweight policy checks, audit logging, desktop notifications, or wiring into local automation scripts — for example, intercepting a risky tool call before it runs, or firing a notification when a background subagent finishes. + +Hook commands run in the local shell, and Kimi Code CLI writes the event payload as JSON to the command's stdin. The command's stdout, stderr, and exit code determine the hook result. Except for explicit blocking cases, hook failures fail open and do not interrupt the main flow because of a misbehaving script. + +::: warning Note +Hooks are useful for local notifications and lightweight interception, but they should not be treated as the only security boundary. Script errors, timeouts, and ordinary non-zero exit codes fail open and continue to allow the operation; high-risk tool calls should still rely on permission approval and human review. +::: + +## Configuration + +Declare hooks in `~/.kimi-code/config.toml` with `[[hooks]]` array tables: + +```toml +[[hooks]] +event = "PreToolUse" +matcher = "Bash" +command = "node ~/.kimi-code/hooks/check-bash.mjs" +timeout = 5 + +[[hooks]] +event = "Notification" +matcher = "task\\.completed" +command = "terminal-notifier -title Kimi -message 'Background task finished'" +``` + +The fields are: + +| Field | Type | Required | Description | +| --- | --- | --- | --- | +| `event` | `string` | Yes | Event name. The value must be one of the entries in the "Events" table below; any other value causes the entire config to fail to load | +| `matcher` | `string` | No | Regular expression matched against the event target. Missing or empty means match everything | +| `command` | `string` | Yes | Shell command to run. Must be non-empty | +| `timeout` | `integer` | No | Timeout in seconds, range 1–600. Defaults to 30 seconds when unset | + +Each `[[hooks]]` table accepts only these four fields. Misspelled or extra fields cause the configuration file to fail to parse. + +When one event fires, all matching hooks run in parallel. If multiple entries share the exact same `command`, that command runs only once. `matcher` uses JavaScript regular expression semantics; invalid regular expressions are silently skipped and treated as no match. + +Hook commands are launched through the shell (equivalent to `sh -c `), and the child process's working directory is the current session's `cwd`. On non-Windows platforms, the child is placed in its own process group; on timeout or session interruption, Kimi Code CLI first sends `SIGTERM` and then `SIGKILL` 100 milliseconds later, so any grandchild processes forked inside the hook are cleaned up as well. + +JSON fields passed to hooks use snake_case. Every payload includes: + +```json +{ + "hook_event_name": "PreToolUse", + "session_id": "session_abc", + "cwd": "/path/to/project" +} +``` + +Additional fields depend on the event type, as shown in the event table below. + +## Return values + +Hook command exit codes and stdout are interpreted as follows: + +| Result | Behavior | +| --- | --- | +| Exit code `0` | Allow. If stdout is JSON, text may be read from `message` or `hookSpecificOutput.message` | +| Exit code `2` | Block. stderr is used as the blocking reason | +| Any other non-zero exit code | Fail open and allow | +| Timeout or process error | Fail open and allow | + +If stdout is JSON and `hookSpecificOutput.permissionDecision` is `deny`, the result is also treated as a block: + +```json +{ + "hookSpecificOutput": { + "permissionDecision": "deny", + "permissionDecisionReason": "Use rg instead" + } +} +``` + +Blocking only applies to events that participate in control flow. For example, `PreToolUse` can block a tool call, and `Stop` can append one continuation message to the current turn. Observer events (such as `PostToolUse`, `PostToolUseFailure`, `PostCompact`, `SubagentStop`, `StopFailure`, and `Notification`) are dispatched asynchronously in a fire-and-forget fashion; their return values are ignored and do not change the main flow. `PreCompact` is invoked with `trigger` (not `triggerBlock`); its return value is likewise completely ignored, and it is not a blockable event. + +When a block takes effect, if the script does not provide a reason through stderr or JSON output, the CLI falls back to `Blocked by hook` as a placeholder reason. A `PreToolUse` block is written back into context as a failed tool result, so the model can choose an alternative based on the reason. + +## Events + +The following events are triggered automatically today: + +| Event | Matcher | Main payload | Behavior | +| --- | --- | --- | --- | +| `UserPromptSubmit` | Text content submitted by the user | `prompt` (`ContentPart[]` array) | Fires only for real user messages. Text returned by the hook is wrapped as a hook result, written into session history for transcript/replay, shown to the user, and the current LLM turn continues without sending the hook result to the model; if the hook blocks, the block reason is returned to the user as an assistant message and no model call is made; if all hooks produce no output, the normal LLM turn continues | +| `PreToolUse` | Tool name | `tool_name`, `tool_input`, `tool_call_id` | Fires before permission checks. If blocked, the tool does not run | +| `PostToolUse` | Tool name | `tool_name`, `tool_input`, `tool_call_id`, `tool_output` | Fires after a successful tool call. `tool_output` is truncated to the first 2000 characters | +| `PostToolUseFailure` | Tool name | `tool_name`, `tool_input`, `tool_call_id`, `error` | Fires after a tool call fails or is blocked by a hook | +| `Stop` | Empty string | `stop_hook_active` | Fires when the model is about to stop. If blocked, the reason is appended directly to context as a system-triggered user message, and the turn may continue once | +| `StopFailure` | Error type | `error_type`, `error_message` | Fires after the current turn fails with a non-cancellation error | +| `SessionStart` | `startup` or `resume` | `source` | Fires after the main agent is created for a new session, or after a historical session is resumed | +| `SessionEnd` | `exit` | `reason` | Fires after the session is closed and its metadata is flushed | +| `SubagentStart` | Subagent name | `agent_name`, `prompt` | Fires after a subagent is configured and before it actually starts running. `prompt` is truncated to the first 500 characters | +| `SubagentStop` | Subagent name | `agent_name`, `response` | Fires asynchronously after a subagent completes successfully; does not fire on failure. `response` is truncated to the first 500 characters | +| `PreCompact` | `manual` or `auto` | `trigger`, `token_count` | Fires before context compaction actually starts. This event is invoked with `trigger` (not `triggerBlock`); its return value is completely ignored and blocking decisions are not read | +| `PostCompact` | `manual` or `auto` | `trigger`, `estimated_token_count` | Fires asynchronously after context compaction is successfully written. Blocking results do not change the main flow | +| `Notification` | Notification type | `sink`, `notification_type`, `title`, `body`, `severity`, `source_kind`, `source_id` | Currently fires when a background subagent result is written into context. `notification_type` is one of `task.completed`, `task.failed`, `task.killed`, or `task.lost`; the sink is `context` | + +`UserPromptSubmit` return text is wrapped as a hook result: + +```xml + +hook response + +``` + +If multiple `UserPromptSubmit` hooks return text, each result gets its own `` tag. This message keeps its hook-result origin for transcript/replay, but is not sent to the model. The model sees the original user prompt and the current turn continues. + +If a `UserPromptSubmit` hook blocks the request, the block reason uses the same format and is returned to the user, but the turn does not continue to a model call. + +`Stop` block reasons are appended directly as system-triggered user messages so the current turn can continue: + +```text +continue from hook +``` + +## Example: block risky shell commands + +The following hook reads `tool_input.command` from stdin before a `Bash` tool call. If the command contains `rm -rf`, the script exits with code `2` and writes the reason to stderr: + +::: warning Note +This example only demonstrates how a hook blocks a tool call; it is not a complete shell safety parser. Real policies are better implemented with an allowlist, or with dedicated shell parsing that handles quoting, variable expansion, aliases, and multi-part commands. +::: + +```toml +[[hooks]] +event = "PreToolUse" +matcher = "Bash" +command = "node ~/.kimi-code/hooks/block-dangerous-bash.mjs" +timeout = 5 +``` + +```js +let input = ''; +process.stdin.on('data', (chunk) => { + input += chunk; +}); +process.stdin.on('end', () => { + const payload = JSON.parse(input); + const command = payload.tool_input?.command ?? ''; + if (command.includes('rm -rf')) { + console.error('Blocked dangerous shell command'); + process.exit(2); + } +}); +``` + +When the hook blocks the tool call, Kimi Code CLI writes the blocking reason back into context as a failed tool result, so the model can choose a safer alternative. diff --git a/docs/en/customization/mcp.md b/docs/en/customization/mcp.md new file mode 100644 index 000000000..bbde8cc47 --- /dev/null +++ b/docs/en/customization/mcp.md @@ -0,0 +1,83 @@ +# Model Context Protocol + +[Model Context Protocol (MCP)](https://modelcontextprotocol.io/) is an open protocol that lets models safely call tools exposed by external processes or services. Kimi Code CLI acts as an MCP client to integrate these external tools, exposing them to the agent alongside built-in tools (`Read`, `Bash`, `Grep`, etc.). + +## Integration scope + +Kimi Code CLI connects to MCP servers via stdio (local subprocess) or HTTP. Once connected, MCP tools behave the same as built-in tools: they are available to the agent, subject to permission rules, and go through the approval flow. + +## Configuration and login + +MCP server configurations live in `mcp.json` in two layers: + +- User-level: `~/.kimi-code/mcp.json` (or `$KIMI_CODE_HOME/mcp.json`), shared across projects +- Project-level: `.kimi-code/mcp.json` in the current workspace + +Project entries override user-level entries with the same name. + +The easiest entry point is running `/mcp-config` in the TUI, which guides you through adding, editing, or removing servers. To check connection status, run `/mcp`. + +The top-level shape of `mcp.json` is: + +```json +{ + "mcpServers": { + "filesystem": { + "command": "npx", + "args": ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"] + }, + "linear": { + "url": "https://mcp.linear.app/mcp" + } + } +} +``` + +Entries with `command` are stdio servers; entries with `url` are HTTP servers, so you usually do not need to write a `transport` field. HTTP servers can provide static credentials through `headers` or `bearerTokenEnvVar`. When OAuth is required, run `/mcp-config login ` to complete browser authorization. + +Optional fields: + +| Field | Type | Applies to | Description | +| --- | --- | --- | --- | +| `env` | `Record` | stdio | Environment variables injected into the subprocess | +| `cwd` | `string` | stdio | Working directory for the subprocess | +| `headers` | `Record` | HTTP | Static headers appended to every request | +| `enabled` | `boolean` | both | Set to `false` to disable the server without removing the entry | +| `startupTimeoutMs` | `number` | both | Connection timeout in milliseconds, default `30000` | +| `toolTimeoutMs` | `number` | both | Per-tool-call timeout in milliseconds | +| `enabledTools` | `string[]` | both | Allowlist: only expose the tools in this list | +| `disabledTools` | `string[]` | both | Blocklist: exclude the tools in this list | + +::: warning Note +Stdio entries in a project-level `.kimi-code/mcp.json` execute local commands when the session starts. Only enable project-level MCP servers in repositories you trust. +::: + +## Tool naming and permissions + +MCP tools are exposed using the naming convention `mcp____`. Permission matching supports `*` and `**` wildcards, so `mcp__github__*` covers every tool from the `github` server. + +Calls that do not match any rule trigger an approval request. Choosing "Approve for this session" in the approval prompt auto-approves subsequent matching calls. + +You can also pre-load permanent rules in the `[[permission.rules]]` array of tables in `config.toml`: + +```toml +[[permission.rules]] +decision = "allow" +pattern = "mcp__github__*" + +[[permission.rules]] +decision = "deny" +pattern = "mcp__filesystem__write_file" +``` + +The full syntax of the `pattern` field and the accepted values of other fields such as `decision`, `scope`, and `reason` are documented in [Config files](../configuration/config-files.md#permission). + +## Security + +- Only integrate MCP servers from trusted sources +- Check that the tool name and arguments are reasonable in approval requests +- Keep manual approval for high-risk tools, and avoid broad `mcp__*` wildcard allowlisting + +::: warning Note +In YOLO mode, MCP tool calls are auto-approved like any other tool. Only use this mode when you fully trust the integrated MCP servers. +::: diff --git a/docs/en/customization/skills.md b/docs/en/customization/skills.md new file mode 100644 index 000000000..f0a5fef31 --- /dev/null +++ b/docs/en/customization/skills.md @@ -0,0 +1,127 @@ +# Agent Skills + +Agent Skills are the lightweight mechanism Kimi Code CLI uses to extend a model's capabilities. A skill is a Markdown document with YAML frontmatter that describes a piece of expertise or a workflow. Kimi Code CLI scans known directories on startup and injects the discovered skills into the system prompt, so the agent knows which skills are available in the current session. + +Compared to pasting the same guidance into the prompt every time, skills offer these advantages: the content is captured in a file, reusable across projects and teams, can be loaded with a one-shot slash command, and can also be invoked automatically by the model when needed. + +## Creating a skill + +Skill files must be placed in a [known scan directory](#skill-locations). A skill can use one of two file structures: + +- **Directory form (recommended)**: create a subdirectory with the main file named `SKILL.md`. You can also place scripts, references, and other auxiliary files in the same directory. If both `/SKILL.md` and a flat `.md` exist in the same directory, the subdirectory wins. +- **Flat form**: use a single `.md` file directly; the skill name defaults to the filename with `.md` removed. + +### File format + +`SKILL.md` consists of two parts: YAML frontmatter and a Markdown body. + +```markdown +--- +name: code-style +description: Project code style conventions, covering naming, indentation, comments, and file organization +type: prompt +whenToUse: When the user asks me to write, modify, or review the project's source code +disableModelInvocation: false +arguments: + - target + - mode +--- + +Please handle the code according to the following conventions: + +- Use 2-space indentation +- Use `camelCase` for variable names and `PascalCase` for type names +- Public functions must have TSDoc comments +- Lines must not exceed 100 characters +``` + +### Frontmatter fields + +| Field | Description | +| --- | --- | +| `name` | Skill name. Required in a directory-style `SKILL.md`; in a flat `.md` file the filename is used when omitted. Names are case-insensitive. | +| `description` | A one-line summary. The model uses it to decide when to use this skill. Required in a directory-style `SKILL.md`; in a flat `.md` file falls back to the first non-empty line of the body (truncated to 240 characters) when omitted. | +| `type` | Skill type. Supported values: `prompt` (default), `inline` (same semantics as `prompt`), or `flow` (manual invocation only, not auto-invoked by the model). Other values are skipped. | +| `whenToUse` | Description of the trigger scenario. Aliases `when-to-use` and `when_to_use` are also accepted. | +| `disableModelInvocation` | Set to `true` to forbid the model from invoking this skill automatically. Aliases `disable-model-invocation` and `disable_model_invocation` are also accepted. | +| `arguments` | Named arguments the skill accepts. Can be written as an array of strings, or as a single whitespace-separated string (e.g. `arguments: target mode`). Once declared, the body can read them with `$`. Purely numeric or empty entries are ignored. | + +::: warning Note +In a directory-style `SKILL.md`, both `name` and `description` **must** be filled in explicitly. Omitting either one causes the skill to fail parsing. +::: + +### Body placeholders + +The skill body expands a small set of placeholders before being sent to the model: + +- `$ARGUMENTS`: the complete raw argument string passed when the skill was invoked +- `$ARGUMENTS[0]`, `$ARGUMENTS[1]`, and shorthand `$0`, `$1`: positional arguments after whitespace splitting, starting at 0 +- `$`: a named argument declared in `arguments` +- `${KIMI_SKILL_DIR}`: the directory containing the current skill file + +Positional arguments respect single- and double-quoted text: in `/skill:commit "fix login" patch`, `$0` expands to `fix login`. If the body does not contain any argument placeholders, the appended text is added to the end of the body as `\n\nARGUMENTS: `. + +## Skill locations + +Kimi Code CLI scans skills in four scope tiers, with more specific scopes taking higher priority: + +**Project > User > Extra > Built-in** + +User-level: + +- `~/.kimi-code/skills/` +- `~/.agents/skills/` + +Project-level (project root = the nearest ancestor directory containing `.git`): + +- `.kimi-code/skills/` +- `.agents/skills/` + +Extra directories are declared via the top-level `extra_skill_dirs` field in `config.toml`: + +```toml +extra_skill_dirs = ["~/team-skills", ".agents/team-skills"] +``` + +Built-in skills ship with CLI and have the lowest priority. + +## Invoking a skill + +Users can invoke a skill explicitly with a slash command: + +``` +/skill:code-style +/skill:git-commits Fix the concurrency issue in the login API +``` + +The model can also decide to invoke a skill automatically based on `description` and `whenToUse`, unless `disableModelInvocation` is set to `true` or `type` is `flow`. When the model invokes a skill, body placeholders are expanded first, then the content is injected into the system prompt. Skill calls are allowed to nest up to 3 levels deep; deeper invocations are terminated. + +## Full example + +```markdown +--- +name: review-pr +description: Review a pull request against the team's standards and produce a structured review report +type: prompt +whenToUse: When the user asks me to review a PR, inspect code changes, or assess commit quality +arguments: + - pr_ref +--- + +Please review the PR specified by the user using the following process: $pr_ref + +1. Fetch and read the full diff of `$pr_ref`. +2. Check against the following items one by one: + - Whether corresponding test cases are included + - Whether public APIs have documentation updates + - Whether new dependencies are introduced; if so, explain the rationale + - Whether error handling covers edge cases +3. Refer to the checklist in the same directory: `references/checklist.md` +4. Produce a review report containing: + - Overall conclusion (approve / request changes / comment) + - Required changes (blocking) + - Suggested improvements (non-blocking) + - Things worth acknowledging +``` + +Save the above as `~/.kimi-code/skills/review-pr/SKILL.md`, and put the checklist at `references/checklist.md` in the same directory. Then restart the session, and you can invoke it via `/skill:review-pr #1234`; the appended `#1234` will expand into `$pr_ref` in the body. diff --git a/docs/en/faq.md b/docs/en/faq.md new file mode 100644 index 000000000..a421d16db --- /dev/null +++ b/docs/en/faq.md @@ -0,0 +1,3 @@ +# FAQ + +This page collects the questions new users hit most often, along with short answers. When a topic needs a fuller explanation, the answer links to the relevant doc at the end. diff --git a/docs/en/guides/getting-started.md b/docs/en/guides/getting-started.md new file mode 100644 index 000000000..120b74e6c --- /dev/null +++ b/docs/en/guides/getting-started.md @@ -0,0 +1,135 @@ +# Getting started + +## What is Kimi Code CLI + +Kimi Code CLI is an AI agent that runs in the terminal, helping you carry out software development tasks and day-to-day terminal operations. It can read and edit code, run shell commands, search files, and fetch web pages, autonomously planning and adjusting the next step based on feedback as it works. + +It fits scenarios such as: + +- **Writing and modifying code**: implementing new features, fixing bugs, completing refactors +- **Understanding a project**: exploring an unfamiliar codebase and answering questions about architecture and implementation +- **Automating tasks**: batch-processing files, running builds and tests, chaining multiple scripts together + +The entire CLI is written in TypeScript, distributed through npm, and runs on Node.js. + +## Installation + +### Install script (recommended) + +The quickest way to install Kimi Code CLI is with the install script; no pre-installed Node.js is required: + +- **macOS / Linux**: + +```sh +curl -fsSL https://code.kimi.com/kimi-code/install.sh | bash +``` + +- **Windows (PowerShell)**: + +```powershell +irm https://code.kimi.com/kimi-code/install.ps1 | iex +``` + +This downloads the latest release, verifies the checksum, and places the `kimi` executable on your `PATH`. + +### npm installation + +If you prefer to install via npm, you need Node.js 24.15.0 or later: + +```sh +node --version +``` + +The package name is `@moonshot-ai/kimi-code`: + +```sh +npm install -g @moonshot-ai/kimi-code +``` + +Or with pnpm: + +```sh +pnpm add -g @moonshot-ai/kimi-code +``` + +## Upgrade and uninstall + +After installation, verify that the executable is ready: + +```sh +kimi --version +``` + +**Upgrade**: if you installed via the script, re-run it. If you installed via npm: + +```sh +npm install -g @moonshot-ai/kimi-code@latest +``` + +**Uninstall**: if you installed via the script, remove the `kimi` executable. If you installed via npm: + +```sh +npm uninstall -g @moonshot-ai/kimi-code +``` + +## First launch + +Move into the project directory you want to work in and simply run `kimi` to start the interactive UI: + +```sh +cd your-project +kimi +``` + +If you only want to run a single instruction without entering the interactive UI, use the `-p` flag: + +```sh +kimi -p "Take a look at this project's directory structure" +``` + +To resume the previous session, add the `-C` flag: + +```sh +kimi -C +``` + +On the first launch, Kimi Code CLI has no credentials yet, and you need to configure an API source before you can start a conversation. In the interactive UI, enter the slash command `/login` to begin the login flow: + +``` +/login +``` + +`/login` opens a platform selector supporting: + +- **Kimi Code** — OAuth device code flow; open the URL on any device, sign in, and enter the code to authorize +- **Moonshot AI Open Platform** — log in directly with an API key + +To sign out, enter `/logout` to clear the current credentials. + +::: tip +If you want to use Anthropic, OpenAI, Google, or other providers, edit `config.toml` directly to configure the API key. See [Providers and models](../configuration/providers.md) for details. Runtime configuration such as the model and provider is also written to `config.toml`. See [Config files](../configuration/config-files.md), [Environment variables](../configuration/env-vars.md), and [Configuration overrides](../configuration/overrides.md) for details. +::: + +## Your first conversation + +Once login is complete, you can describe a task to Kimi Code CLI directly in natural language. For example, you can have it familiarize itself with the current project first: + +``` +Take a look at this project's directory structure and briefly describe what each directory is for. +``` + +Kimi Code CLI will automatically call file-reading, search, and web-fetching tools, browse the relevant content, and then give you an answer. Read-only operations such as reading files and searching the web are executed automatically by default without requiring confirmation. For operations that modify files or run shell commands, it asks for your confirmation before executing by default, and you can approve or reject as you see fit. + +You can also have it do something more concrete, such as: + +``` +Add a function in src/utils that converts any string to kebab-case, and add a unit test for it. +``` + +Kimi Code CLI plans the steps, modifies the code, runs the tests, and tells you what it did at each step. + +In the interactive UI, entering `/help` shows all available [slash commands](../reference/slash-commands.md) along with common keyboard shortcut hints. To exit Kimi Code CLI, enter `/exit`. You can also press `Ctrl-C` — the UI will first clear the current input and prompt you to press it again, and the second press exits. Or press `Ctrl-D` twice with the input box empty to exit. + +## Where data is stored + +Kimi Code CLI stores its local data under `~/.kimi-code/` by default, including config files, session records, logs, and the update cache. If you want to move it elsewhere, you can point to a new root directory via the `KIMI_CODE_HOME` environment variable. For the full directory layout and environment variable reference, see [Data locations](../configuration/data-locations.md) and [Environment variables](../configuration/env-vars.md). diff --git a/docs/en/guides/interaction.md b/docs/en/guides/interaction.md new file mode 100644 index 000000000..56279d570 --- /dev/null +++ b/docs/en/guides/interaction.md @@ -0,0 +1,67 @@ +# Interaction and Input + +Kimi Code CLI runs as an interactive TUI with an input box, conversation view, and status bar. This page covers basic operations, mode switches, the approval flow, and shortcuts. The full list is available via `/help`. + +## Input box basics + +The input box accepts free-form text; pressing `Enter` sends, and `Shift-Enter` inserts a line break. When the input box is empty, press `↑` / `↓` to browse history for the current directory. + +Press `Ctrl-D` twice to exit the CLI. `Ctrl-C` interrupts the current turn while streaming; press it twice in the idle state to exit. `Esc` closes dialogs and also interrupts a turn while streaming. + +## Slash commands and `@` mentions + +Anything starting with `/` is recognized as a slash command, covering session management, mode switching, configuration, and more — such as `/help`, `/new`, `/sessions`, and `/model`. See the [slash command reference](../reference/slash-commands.md) for the full list. + +Type `/` to open the command completion menu, which also includes commands from [Agent Skills](../customization/skills.md). If a skill name collides with a built-in command, use the full `/skill:` form. Press `Esc` to dismiss. + +Some commands are only available while the agent is idle; interrupt the current turn first if the agent is streaming. Mode-switch and query commands such as `/yolo`, `/plan`, and `/help` are always available. + +Type `@` to trigger file-path completion. Selecting an entry inserts the relative path, and the agent can read the file directly. Dot-prefixed directories are hidden by default; write `@.github/` to include them explicitly. + +## Plan mode + +In Plan mode, the agent spells out its plan before taking action. Press `Shift-Tab` or `/plan` to toggle; `/plan clear` clears the current plan file (available while idle only). + +The agent outputs a plan and waits for your confirmation instead of making changes directly. Once the plan is ready, you can reject it (staying in Plan mode) or request changes. Leaving Plan mode still requires your confirmation, even when YOLO mode is on. + +## YOLO mode + +YOLO mode automatically approves most tool calls, skipping the approval step. Enter `/yolo` (or `/yes`) to toggle; available both idle and streaming. + +::: warning Note +YOLO mode skips ordinary approvals, but not the approval required to leave Plan mode. +::: + +## Approval flow + +When the agent invokes a tool with side effects (such as modifying a file or running a command), an approval panel pops up for your confirmation. This is skipped in YOLO mode and for plan-file writes in Plan mode. + +Use arrow keys to select an option and `Enter` to confirm; press `1` / `2` / `3` to select by position directly. `Esc`, `Ctrl-C`, and `Ctrl-D` all reject the request. The panel usually also offers an "Approve for this session" option to auto-allow similar calls going forward. + +## Working while output is streaming + +While the agent is thinking or making tool calls ("streaming output"), the input box is still usable: + +- `Esc` — interrupt the current turn. +- `Ctrl-C` — also interrupts; press twice in the idle state to exit. +- `Ctrl-S` — insert the current input-box content as an additional message into the in-progress turn. +- `Ctrl-O` — globally toggle the collapsed state of all tool output. + +## External editor + +Press `Ctrl-G` to open the current input in an external editor; saved content is loaded back when the editor closes. + +Editor priority: `/editor` config > `$VISUAL` > `$EDITOR`. Run `/editor` first if none is configured. + +## Pasting images and videos + +Paste images or videos from the clipboard directly into the input box for multimodal models: + +- Unix (macOS / Linux): `Ctrl-V` +- Windows: `Alt-V` + +Placeholders appear in the input box and behave like ordinary text; they are replaced with the actual content when sent. Plain text falls back to a normal paste. Media attachments are kept in the current session only. + +## Viewing all shortcuts + +Enter `/help` to open a panel listing all shortcuts and slash commands. Scroll with `↑` / `↓`, page with `PageUp` / `PageDown`, and close with `Esc`, `Enter`, or `q`. diff --git a/docs/en/guides/migration.md b/docs/en/guides/migration.md new file mode 100644 index 000000000..d0938ffb4 --- /dev/null +++ b/docs/en/guides/migration.md @@ -0,0 +1,37 @@ +# Migrating from kimi-cli + +Kimi Code CLI is the next-generation terminal agent — and a fresh start. If you have been using the previous generation, kimi-cli, you don't need to start over: a single command brings your configuration, MCP servers, and session history across to the new version. + +## Why migrate + +kimi-code is rebuilt on Node.js and no longer depends on Python or the `uv` toolchain, making installation and upgrades simpler. It also ships native binaries out of the box. + +The terminal UI has been redesigned for a faster and lighter experience. + +kimi-cli is transitioning to kimi-code, and migrating lets you keep your existing configuration and session history going forward. + +## How to migrate + +There are two ways to migrate. + +The **first time you run `kimi`** after installing kimi-code, it automatically checks whether kimi-cli data exists under `~/.kimi/`. If it finds any, a migration prompt appears, and you can choose to migrate now, do it later, or never be asked again. + +You can also **run it manually at any time**: + +```sh +kimi migrate +``` + +You can choose whether to migrate chat sessions as well. If you don't need the history yet, pick **Config only**; otherwise pick **Config + N sessions** to bring everything across in one go. A summary is printed at the end. + +## What happens during migration + +**What gets migrated**: configuration (`config.toml`), MCP server configuration, input history, and whichever chat sessions you chose to migrate. + +**What does not get migrated**: OAuth login credentials and MCP service authorizations are not copied, so you will need to run `/login` again and re-authorize MCP servers after migrating. kimi-cli plugins are also out of scope. + +::: tip +Migration **never modifies or deletes** any of the old data under `~/.kimi/`. kimi-cli keeps working as before, and the two do not interfere with each other. Migration can also be run repeatedly — sessions that have already been migrated are not imported again. +::: + +After migration, sessions imported from kimi-cli are tagged with `[imported]` in the session picker so you can tell them apart from new ones. diff --git a/docs/en/guides/sessions.md b/docs/en/guides/sessions.md new file mode 100644 index 000000000..5619e8a2f --- /dev/null +++ b/docs/en/guides/sessions.md @@ -0,0 +1,110 @@ +# Sessions and context + +Kimi Code CLI persists every conversation as a "session", preserving message history and metadata so you can close the terminal and resume later. This page covers resuming sessions, context compaction, and managing sessions from inside the TUI. + +## Session storage + +All sessions are stored under `$KIMI_CODE_HOME/sessions/` (default `~/.kimi-code/sessions/`), bucketed by working directory: + +```text +~/.kimi-code/ +├── config.toml +├── session_index.jsonl +└── sessions/ + └── / + └── / + ├── state.json + └── agents/ + ├── main/ + │ └── wire.jsonl + └── / + └── wire.jsonl +``` + +- `state.json` — session title and metadata. +- `agents/*/wire.jsonl` — agent event stream. + +::: warning Note +Manually editing files under `sessions/` can leave a session unrecoverable due to ordering constraints in `state.json` and `wire.jsonl`. +::: + +## Starting and resuming sessions + +By default, `kimi` creates a new session each time. To continue where you left off: + +**Resume the most recent session in the current directory:** + +```sh +kimi --continue +``` + +**Resume a specific session:** + +```sh +kimi --session abc123 +``` + +`-r` / `--resume` are equivalent aliases. + +**Pick interactively:** + +```sh +kimi --session +``` + +::: warning Note +`--continue` and `--session` are mutually exclusive; `--yolo` and `--plan` also cannot be combined with them. +::: + +## Switching sessions inside the TUI + +- `/new` (`/clear`): switch to a new session. +- `/sessions` (`/resume`): browse and resume past sessions. +- `/fork`: fork the current session (see below). +- `/title ` (`/rename`): set a session title for easier recognition. Without an argument, shows the current title. + +`/sessions` works even while streaming, but switching requires interrupting the turn first with `Esc` or `Ctrl-C`. `/new`, `/fork`, and `/compact` are only available while idle. + +## Context compaction + +Kimi Code CLI automatically compresses message history when context approaches the window limit. You can also trigger it manually: + +```text +/compact +``` + +Pass a custom hint to tell the model what to preserve: + +```text +/compact Keep the discussion related to database migrations +``` + +## Forking sessions + +To try a new line of thinking without disrupting the current conversation, use `/fork`: + +```text +/fork +``` + +The forked session is fully independent; you can switch back to the original at any time. + +## Exporting sessions + +Package a session into a ZIP: + +```sh +kimi export +``` + +Without a `sessionId`, it exports the most recent session (interactively asks for confirmation; pass `-y` to skip). Use `-o` to set the output path: + +```sh +kimi export -o ~/Desktop/my-session.zip +``` + +The ZIP is written to the current working directory by default. The session's own diagnostic log is always bundled along with the session directory. The global diagnostic log at `$KIMI_CODE_HOME/logs/kimi-code.log` — which captures events that do not belong to a session, such as TUI startup and login — is also bundled by default; pass `--no-include-global-log` to skip it. + +::: tip Tip +Exported files may contain sensitive information. Review the contents before sharing. +::: diff --git a/docs/en/guides/use-cases.md b/docs/en/guides/use-cases.md new file mode 100644 index 000000000..d90cce51a --- /dev/null +++ b/docs/en/guides/use-cases.md @@ -0,0 +1,118 @@ +# Common use cases + +Below are typical scenarios and prompt samples for Kimi Code CLI. + +## Understanding an unfamiliar project + +Use `kimi --plan` or press `Shift-Tab` to enter Plan mode for large-scale research. Ask it to produce a plan first and approve before execution: + +``` +Walk me through the overall architecture of this repository. Focus on: +1. Where the entry point is and what happens at startup +2. The dependency relationships between major modules +3. How configuration and data are loaded +At the end, draw a simple module relationship diagram. +``` + +For focused questions, ask directly: + +``` +How does the event loop under src/runtime work? Where do events originate, and who consumes them? +``` + +``` +How is "permission approval" implemented in this project? Which files are involved, and what are the key types? +``` + +For large investigations, the main agent can spawn **subagents** to handle subtasks in parallel. See [Agents](../customization/agents.md). + +## Implementing a new feature + +State the requirement and acceptance criteria clearly. Use Plan mode for complex or risky changes. + +``` +Add a retry utility under src/utils: +- Function signature retry(fn: () => Promise, options): Promise +- Supports three options: maxAttempts, initialDelayMs, backoffFactor +- Throws the last error on failure +- Add a set of unit tests covering success, success after retry, and full failure +``` + +Tell it how to change the result — no need to edit by hand: + +``` +The backoff uses a fixed value. I want some jitter added to avoid thundering herd. Update the code and the tests. +``` + +## Fixing bugs + +State the symptom, reproduction conditions, and expected behavior: + +``` +Running npm test occasionally throws this error: + + TypeError: Cannot read properties of undefined (reading 'id') + at SessionStore.update (src/session/store.ts:142:18) + +It only appears in cases that trigger multiple updates concurrently. Locate the cause, fix it, and run the full test suite once at the end to confirm. +``` + +Not sure where the cause is? Have it investigate first: + +``` +User report: after a successful login, the first page refresh sends them back to the login page; refreshing again works fine. Investigate the possible causes first, list the most suspicious spots, and wait for me to confirm a direction before making any changes. +``` + +For mechanical tasks, just hand it off: + +``` +Run the tests, fix any failing cases, and run them again to confirm everything is green. +``` + +## Writing tests and refactoring + +Tasks with clear boundaries and acceptance criteria are a great fit: + +``` +src/parser/markdown.ts currently has almost no tests. Add a set of unit tests covering plain paragraphs, nested lists, code blocks, tables, blockquotes, and mixed scenarios. Follow the existing test style in this project. +``` + +``` +Extract the repeated "read body → validate → write log → return" logic under src/handlers into a middleware. Run the tests after the changes and make sure existing behavior is unchanged. +``` + +For multi-file refactors, use Plan mode to confirm the plan first. Use `/fork` to explore alternative approaches. + +## One-off scripts and automation tasks + +Bulk edits, statistics, and research can be done with a single prompt: + +``` +Change all var declarations in .js files under src/ to const or let, preferring const when possible. Run lint after the changes to confirm. +``` + +``` +Analyze the access logs under logs/ for the past 7 days. Group by endpoint path and report the number of calls plus p50 and p99 response times. Output the result as a markdown table. +``` + +``` +Research the mainstream dependency injection options in TypeScript (tsyringe, inversify, awilix). Compare them along three dimensions: API style, decorator dependencies, and runtime overhead. Give me a recommendation within one page. +``` + +Use `--yolo` or `/yolo` to skip approvals, or set an allow list under [permission configuration](../configuration/config-files.md#permission) for a safer middle ground. + +## Generating and maintaining documentation + +``` +I just changed the interface signature of src/auth/login.ts. Update the corresponding JSDoc, the example code in the README, and any paragraph that mentions this interface under docs/en/guides. +``` + +``` +For every public function under src/api that does not have a docstring, add one. Match the style of the existing comments. +``` + +``` +Based on the command implementations under src/cli, generate a draft command reference listing each subcommand, its arguments, and default values. Put it under docs/en/reference for me to review later. +``` + +Archive or share a session with `kimi export `. diff --git a/docs/en/index.md b/docs/en/index.md new file mode 100644 index 000000000..e82ce8cc9 --- /dev/null +++ b/docs/en/index.md @@ -0,0 +1,13 @@ +--- +layout: home +hero: + name: Kimi Code CLI + text: The Starting Point for Next-Gen Agents + actions: + - theme: brand + text: Get started + link: guides/getting-started + - theme: alt + text: GitHub + link: https://github.com/MoonshotAI/kimi-code +--- diff --git a/docs/en/reference/keyboard.md b/docs/en/reference/keyboard.md new file mode 100644 index 000000000..39939cd04 --- /dev/null +++ b/docs/en/reference/keyboard.md @@ -0,0 +1,97 @@ +# Keyboard shortcuts + +The TUI interactive mode of Kimi Code CLI supports the keyboard shortcuts listed below. These keys primarily take effect inside the input box; some have different behavior inside popups (such as the `/help` panel or the approval panel) or during streaming output. + +Type `/help` at any time inside the TUI to open the built-in shortcut list. + +## General shortcuts + +The following keys are always available in the input box: + +| Shortcut | Action | +| --- | --- | +| `Enter` | Submit the current input | +| `Shift-Enter` | Insert a newline in the input | +| `↑` / `↓` | Browse input history | +| `Esc` | Close popup / cancel completion / interrupt streaming output or an in-progress context compaction | +| `Ctrl-C` | Interrupt the current streaming output, or clear the input box | +| `Ctrl-D` | Exit Kimi Code CLI when the input box is empty | + +Pressing `Ctrl-C` **during streaming output** cancels immediately with no second confirmation needed. + +**Exiting the program** (pressing `Ctrl-C` when the input box is empty, or pressing `Ctrl-D`) uses a "double-press to confirm" mechanism: the first press shows a hint in the status bar (for example, `Press Ctrl+C again to exit`), and only a second press of the same key actually exits. Pressing any other key in between clears the confirmation state. + +## Mode switching + +| Shortcut | Action | +| --- | --- | +| `Shift-Tab` | Toggle Plan mode | + +Press `Shift-Tab` to turn Plan mode on or off. While Plan mode is on, the agent favors read-only tools for research and planning and can write the current plan file; when needed, it can also call `Bash`, which follows the current permission mode and ordinary rules without triggering an extra independent approval just because Plan mode is active. Toggling the mode alone does not create an empty plan file. Press `Shift-Tab` again to leave Plan mode. + +## Input and editing + +| Shortcut | Action | +| --- | --- | +| `Ctrl-G` | Edit the current input in an external editor | +| `Ctrl-V` | Paste an image or video from the clipboard (Unix / macOS) | +| `Alt-V` | Paste an image or video from the clipboard (Windows) | +| `Ctrl-E` | Expand or collapse the Plan card (when no Plan card is present, follows the system default behavior of moving the cursor to the end of the line) | +| `Ctrl--` | Undo | + +Pressing `Ctrl-G` opens an external editor to edit the current input. The editor is chosen in the following order of priority: + +1. The editor configured by the `/editor` command +2. The `$VISUAL` environment variable +3. The `$EDITOR` environment variable + +After saving and exiting the editor, the edited content replaces the input box contents; exiting without saving leaves the input unchanged. + +When pasting an image or video, it appears in the input box as a placeholder, and the actual media data is sent to the model together with the message on submit. The preferred source is an image or file path on the system clipboard; on Linux, both Wayland and X11 are tried, and on WSL the Windows clipboard is also read via PowerShell as a fallback. + +## During streaming output + +While output is streaming, the input box still accepts input and supports the following extra actions: + +| Shortcut | Action | +| --- | --- | +| `Ctrl-S` | Steer: inject the current input into the running turn immediately | +| `Esc` | Interrupt the current streaming output | +| `Ctrl-C` | Interrupt the current streaming output | + +When you press `Ctrl-S`, the model sees your message at the next interruptible point, without waiting for the current turn to finish. + +## Tool output + +| Shortcut | Action | +| --- | --- | +| `Ctrl-O` | Expand or collapse tool output | + +When collapsed tool call results are present in the history, press `Ctrl-O` to toggle between collapsed and expanded views to inspect the full tool output. + +## Approval panel + +When the agent issues a tool call that requires confirmation, the TUI shows an approval panel. For the full approval flow, see [Interaction and Input](../guides/interaction.md#approval-flow); the table below lists the keys available inside the panel: + +| Shortcut | Action | +| --- | --- | +| `↑` / `↓` | Move the cursor between options | +| `Enter` | Confirm the currently selected option | +| `1` ~ `9` | Directly select the option with the matching number | +| `Esc` / `Ctrl-C` / `Ctrl-D` | Reject the current request | +| `Ctrl-E` | Expand or collapse the full content when the panel includes a diff or file preview | +| `Ctrl-O` | Toggle the collapsed state of other tool output | + +Options that require additional feedback (such as "Reject" or "Revise") switch to a feedback input state after confirmation: type the feedback text directly and press `Enter` to submit, or press `Esc` to exit the feedback input and return to the option list. + +## Popup mode + +After typing `/help` to open the help panel, the following keys are available to navigate and close the panel: + +| Shortcut | Action | +| --- | --- | +| `↑` / `↓` | Scroll one line | +| `PageUp` / `PageDown` | Scroll 10 lines | +| `Esc` | Close the panel | +| `Enter` | Close the panel | +| `q` / `Q` | Close the panel | diff --git a/docs/en/reference/kimi-command.md b/docs/en/reference/kimi-command.md new file mode 100644 index 000000000..088e6c34e --- /dev/null +++ b/docs/en/reference/kimi-command.md @@ -0,0 +1,154 @@ +# kimi Command + +`kimi` is the main command of Kimi Code CLI, used to start an interactive session in the terminal. When run without any arguments, it opens a new session in the current working directory; with different flags, you can resume a previous session, skip approvals, start in Plan mode, or point at custom Skills directories. + +```sh +kimi [options] +kimi [options] +``` + +## Main command options + +The table below lists all options supported by the `kimi` main command. All flags are optional — running `kimi` on its own is enough to enter an interactive session. + +| Option | Short | Description | +| --- | --- | --- | +| `--version` | `-V` | Print the version number and exit. | +| `--help` | `-h` | Show help information and exit. | +| `--session [id]` | `-S` | Resume a session. With an ID, open the specified session directly; without an ID, enter the interactive picker to choose from historical sessions. | +| `--continue` | `-C` | Continue the most recent session in the current working directory, without manually specifying an ID. | +| `--model ` | `-m` | Use a model alias for this invocation. When omitted, new sessions use `default_model` from the config file, and resumed sessions use the session's current model. | +| `--prompt ` | `-p` | Run one prompt non-interactively and stream assistant output to stdout. This mode uses `auto` permission for tool calls and does not open the TUI. | +| `--output-format ` | | Set the non-interactive output format. Supported values are `text` and `stream-json`. Only valid with `--prompt`; defaults to `text`. | +| `--yolo` | `-y` | Auto-approve ordinary tool calls, skipping approval requests; Plan mode `Bash` approval and Plan mode exit approval are not skipped. | +| `--plan` | | Start a new session in Plan mode, where the AI favors read-only tools for exploration and planning and can write the current plan file; Plan mode `Bash` is handled separately according to the permission mode. | +| `--skills-dir ` | | Load Skills from the specified directory, replacing the auto-discovered user and project directories. Can be passed multiple times to stack several directories. See [Custom Skills directories](#custom-skills-directories) below. | + +`-r` / `--resume` is a hidden alias for `--session`; `--yes` and `--auto-approve` are hidden aliases for `--yolo`. They do not appear in the help output and behave identically to their official counterparts. + +::: warning Note +`--yolo` skips human confirmation for ordinary tool calls, including file writes and shell command execution. Use it only inside trusted working directories. Plan mode exit approval is not skipped by `--yolo`; in Plan mode, `Bash` also follows the same ordinary allow rules as `--yolo`. +::: + +### Flag conflict rules + +The following combinations are rejected at startup: + +- `--continue` and `--session` are mutually exclusive: both mean "resume a previous session" and overlap in meaning. +- `--yolo` cannot be combined with `--continue` or `--session`: when resuming a session, the original session's approval settings are preserved. This rule only applies to interactive mode; in `--prompt` mode, `--yolo` is rejected earlier because it is mutually exclusive with `--prompt`. +- `--plan` cannot be combined with `--continue` or `--session`: Plan mode only applies to new sessions. +- `--prompt` cannot be combined with `--yolo` or `--plan`: non-interactive mode always uses `auto` permission and does not enter Plan mode. +- `--prompt` can be combined with `--continue` or `--session ` with an ID; bare `--session` without an ID would open the interactive picker and therefore cannot be used in non-interactive mode. +- `--output-format` can only be used with `--prompt`; the interactive TUI does not support writing the full event stream as stdout JSONL. + +If you need to force YOLO or Plan mode while resuming a session, switch into them from inside the interactive session via slash commands instead. + +## Typical usage + +The most common entry point is to run `kimi` directly to start a fresh session in the current directory: + +```sh +kimi +``` + +If the previous session was interrupted (terminal closed, network disconnected, etc.) and you want to pick up where you left off, use `--continue`: + +```sh +kimi --continue +``` + +This automatically finds and resumes the most recent session under the current working directory. To pick a different historical session, run `kimi --session` to enter the interactive picker, or pass a known session ID directly: + +```sh +kimi --session 01HZ...XYZ +``` + +When the task is trivial and you don't want to be interrupted by frequent approval requests, add `--yolo`: + +```sh +kimi --yolo +``` + +If you want the AI to read the code and produce an implementation plan first, rather than immediately editing files, use `--plan` to enter Plan mode: + +```sh +kimi --plan +``` + +### Custom Skills directories + +To load custom Skills directories, you have two options: + +- **CLI flag `--skills-dir `**: Can be passed multiple times and **replaces** the auto-discovered user and project directories. Useful for temporary overrides or use in scripts. For example, to mount two directories at once: + + ```sh + kimi --skills-dir /path/to/team-skills --skills-dir ./local-skills + ``` + +- **`extra_skill_dirs` in `config.toml`**: Appends extra directories in the config file and **stacks** them with the auto-discovered ones. Suitable for persistent configuration of team-shared Skills (see [Agent Skills](../customization/skills.md)). + +## Non-interactive execution + +Use `-p` when a script or CI job needs to run one prompt: + +```sh +kimi -p "Summarize the current repository status" +``` + +Output uses transcript-style blocks: thinking and assistant text start with `• `, with continuation lines indented by two spaces. Assistant text is written to stdout; thinking, tool progress, and the `To resume this session: kimi -r ` hint are written to stderr. Prompt mode does not wait for manual approvals: ordinary tool calls, Plan approvals, and agent questions follow the `auto` permission policy. Static deny rules still block matching tool calls. + +To switch models for a single invocation, add `-m`: + +```sh +kimi -m kimi-code/kimi-for-coding -p "Explain the latest diff" +``` + +If a script needs structured output, use JSONL: + +```sh +kimi -p "List changed files" --output-format stream-json +``` + +In `stream-json` mode, each stdout line is one JSON object. Ordinary replies are emitted as assistant messages. If the model calls tools, the output first includes an assistant message with `tool_calls`, then the corresponding tool message, followed by later assistant messages. Thinking content is not written to JSONL; tool progress and the resume-session hint still go to stderr. + +## Subcommands + +### `kimi export` + +Bundle a session into a ZIP file for sharing, archival, or bug reports. The exported archive contains all files under the session directory, such as context records, state files, and the session diagnostic log if that session has already produced `logs/kimi-code.log`. + +```sh +kimi export [sessionId] [options] +``` + +| Argument / Option | Short | Description | +| --- | --- | --- | +| `sessionId` | | The ID of the session to export. When omitted, the most recent session under the current working directory is selected automatically and a confirmation is requested. | +| `--output ` | `-o` | Output path for the ZIP file. When omitted, writes to a default filename in the current directory. | +| `--yes` | `-y` | Skip the confirmation prompt for the default session and export directly. | +| `--no-include-global-log` | | Skip bundling the active global diagnostic log, `~/.kimi-code/logs/kimi-code.log`. It is included by default. | + +By default, export includes files inside the target session directory. If that directory contains `logs/kimi-code.log`, it appears in the ZIP as `logs/kimi-code.log`. The global diagnostic log at `~/.kimi-code/logs/kimi-code.log` is also bundled by default, because it may contain events from other sessions or projects. Add `--no-include-global-log` when you do not want to share it. When included, its ZIP path is `logs/global/kimi-code.log`; rotated files such as `kimi-code.log.1` are not bundled. + +When `sessionId` is omitted, the command first prints the session to be exported and asks for confirmation; `-y` skips this prompt, which is handy for scripts: + +```sh +# Export the most recent session under the current working directory, skipping confirmation +kimi export -y + +# Export a specific session to a custom path +kimi export 01HZ...XYZ -o ./bug-report.zip + +# Exclude the global diagnostic log to avoid sharing events from other sessions +kimi export 01HZ...XYZ -o ./bug-report.zip --no-include-global-log +``` + +### `kimi migrate` + +Migrate local data from an older version of kimi-cli to kimi-code. This command has no flags and runs fully interactively, guiding you through the entire migration process. + +```sh +kimi migrate +``` + +If you previously used an older version of kimi-cli, run this command to migrate historical sessions, configuration, and other data to kimi-code to avoid data loss. For the full migration flow, what gets migrated, and things to watch out for, see [Migrating from kimi-cli](../guides/migration.md). diff --git a/docs/en/reference/slash-commands.md b/docs/en/reference/slash-commands.md new file mode 100644 index 000000000..b8aed9047 --- /dev/null +++ b/docs/en/reference/slash-commands.md @@ -0,0 +1,86 @@ +# Slash Commands + +Slash commands are built-in control commands provided by Kimi Code CLI in the interactive TUI, used to switch modes, manage sessions, view status, and more. Type `/` in the input box to trigger command completion; the candidate list filters in real time as you continue typing, and command aliases participate in matching as well. + +After typing a full command name (such as `/help`), press `Enter` to execute it. If the `/`-prefixed input does not match any built-in or skill command, it is sent to the agent as an ordinary message. + +::: tip Tip +Some commands are only available in the idle state. Running them while the session is streaming a response or compacting the context will be blocked, with a hint to press `Esc` or `Ctrl-C` first to interrupt the current operation. The "Always available" column in the tables below marks commands that remain available during streaming or compacting. +::: + +## Account and configuration + +| Command | Alias | Description | Always available | +| --- | --- | --- | --- | +| `/login` | — | Pick an account or platform and sign in: Kimi Code uses the OAuth device code flow, while the Moonshot AI Open Platform signs in with an API key. | No | +| `/logout` | — | Clear the credentials of the currently selected account (Kimi Code OAuth credentials, or the corresponding open platform provider config). | No | +| `/model` | — | Switch the LLM model used by the current session. | Yes | +| `/settings` | `/config` | Open the settings panel inside the TUI. | Yes | +| `/permission` | — | Choose a permission mode. | Yes | +| `/editor` | — | Configure the external editor launched by `Ctrl-G`. | Yes | +| `/theme` | — | Switch the terminal UI color theme. | Yes | + +## Session management + +| Command | Alias | Description | Always available | +| --- | --- | --- | --- | +| `/new` | `/clear` | Start a brand-new session, discarding the current context. | No | +| `/sessions` | `/resume` | Browse historical sessions and switch to or resume one. | Yes | +| `/tasks` | `/task` | Browse the background task list. | Yes | +| `/fork` | — | Fork a new session from the current one, preserving the full conversation history. | No | +| `/title []` | `/rename` | Without arguments, show the current session title; with an argument, set it as the new title (up to 200 characters). | Yes | +| `/compact []` | — | Compact the current conversation context to free up token usage; optionally pass a custom instruction telling the model what to preserve during compaction. | No | +| `/init` | — | Analyze the current codebase and generate `AGENTS.md`. | No | + +## Mode and runtime control + +| Command | Alias | Description | Always available | +| --- | --- | --- | --- | +| `/yolo [on\|off]` | `/yes` | Toggle auto-approve mode. Without arguments, flip the current state; pass `on`/`off` explicitly to force the corresponding state. When enabled, ordinary tool call approvals are skipped; the Plan mode exit approval is not skipped. | Yes | +| `/plan [on\|off]` | — | Toggle Plan mode. Without arguments, flip the current state; pass `on`/`off` explicitly to force the corresponding state. Toggling alone does not create an empty plan file. | Yes | +| `/plan clear` | — | Clear the current plan. | No | + +::: warning Note +`/yolo` skips approval confirmation for ordinary tool calls. Make sure you understand the potential risks before enabling it. It does not skip the approval required to leave Plan mode; in Plan mode, `Bash` follows the same ordinary allow rules as `/yolo`. +::: + +## Information and status + +| Command | Alias | Description | Always available | +| --- | --- | --- | --- | +| `/help` | `/h`, `/?` | Show keyboard shortcuts and all available commands. | Yes | +| `/usage` | — | Show token usage, context consumption, and quota information. | Yes | +| `/status` | — | Show the current session runtime status, including version, model, working directory, and permission mode. | Yes | +| `/mcp` | — | List the MCP servers in the current session and their connection status. | Yes | +| `/version` | — | Show the Kimi Code CLI version number. | Yes | +| `/feedback` | — | Submit feedback to help improve Kimi Code CLI. | Yes | + +## Exit + +| Command | Alias | Description | Always available | +| --- | --- | --- | --- | +| `/exit` | `/quit`, `/q` | Exit Kimi Code CLI. | No | + +## Dynamic skill commands + +In addition to the built-in commands, user-activatable skills are automatically registered as slash commands under the `skill:` namespace: + +``` +/skill: [extra text] +``` + +For example, `/skill:code-style` loads the content of the `code-style` skill and sends it to the agent; any text after the command is appended to the skill prompt, as in `/skill:git-commits fix the login failure issue`. + +For convenience, skill commands also support a short form `/` that omits the `skill:` prefix, provided the name is not already taken by a built-in command. In other words, `/code-style` falls back to matching `/skill:code-style`. + +Kimi Code CLI ships with a built-in `mcp-config` skill for configuring MCP servers and handling MCP OAuth login. It still belongs to the skill namespace in completion and help (`/skill:mcp-config`), and it can also be invoked directly as `/mcp-config`. + +Skill types that can be exposed as slash commands include `prompt`, `inline`, `flow`, and skills without an explicitly declared type. For skill installation and authoring, see [Agent Skills](../customization/skills.md). + +::: info Note +All skill commands are only available while the agent is idle; during streaming or compacting, press `Esc` or `Ctrl-C` first to interrupt the current operation. +::: + +::: info Note +Flow-type skills are also exposed via `/skill:`; there is no separate `/flow:` namespace. +::: diff --git a/docs/en/reference/tools.md b/docs/en/reference/tools.md new file mode 100644 index 000000000..f113f2072 --- /dev/null +++ b/docs/en/reference/tools.md @@ -0,0 +1,104 @@ +# Built-in tools + +Built-in tools are the toolset that Kimi Code CLI ships with its core engine — no MCP server installation required. During each conversation, the agent automatically selects and invokes these tools based on the task at hand; users can also inspect every tool call in detail through the approval request interface. + +Compared to MCP tools, built-in tools are managed directly by the runtime, their lifecycle is bound to the session, and no external process is needed. Both follow a unified approval mechanism: **read-only tools** (such as `Read`, `Grep`, `Glob`, and `WebSearch`) are auto-approved by default, while **write and execute tools** (such as `Write`, `Edit`, `Bash`, and `TaskStop`) require user approval by default. In YOLO mode, approval for ordinary tool calls is skipped, but exit approval in Plan mode is not affected. + +## File tools + +File tools handle reading, writing, and searching the local filesystem, and are the foundational tools for code analysis and modification tasks. + +| Tool | Default approval | Description | +| --- | --- | --- | +| `Read` | Auto-approved | Read the contents of a text file | +| `Write` | Requires approval | Create or overwrite a file | +| `Edit` | Requires approval | Exact string replacement | +| `Grep` | Auto-approved | Full-text search powered by ripgrep | +| `Glob` | Auto-approved | Find files by glob pattern | +| `ReadMediaFile` | Auto-approved | Read an image or video file | + +**`Read`** accepts a file path (`path`) along with the optional `line_offset` (starting line number; negative values count from the end) and `n_lines` (maximum number of lines to read). At most 1000 lines or 100 KB are returned per call, with a truncation notice appended for anything beyond that limit. If the file is an image or video, the tool suggests using `ReadMediaFile` instead. + +**`Write`** accepts `path`, `content`, and an optional `mode` (`overwrite` or `append`; defaults to overwrite). The parent directory must already exist; `append` mode appends content to the end of the file without automatically inserting a newline. + +**`Edit`** accepts `path`, `old_string` (the exact text to replace), and `new_string` (the replacement text). By default it replaces only a single unique match; if the same content appears more than once in the file, the tool returns an error and suggests using `replace_all: true`. `old_string` and `new_string` must differ. + +**`Grep`** invokes ripgrep to search file contents. It supports regular expressions (`pattern`), a search path (`path`), file-type filtering (`type`, e.g. `ts`, `py`), glob filtering (`glob`), and an output mode (`output_mode`: `files_with_matches` / `content` / `count_matches`, defaulting to `files_with_matches`). `content` mode supports `-A`, `-B`, and `-C` context-line arguments along with `-i` (case-insensitive), `-n` (line numbers, default true), and `multiline` (cross-line matching). All modes support `offset` + `head_limit` pagination; `head_limit` defaults to 250, and passing 0 removes the limit. In `files_with_matches` mode, results are sorted by the most recent file modification time in descending order; other modes preserve ripgrep's original output order. Sensitive files such as `.env`, private keys, `.aws/credentials`, and `.gcp/credentials` are automatically filtered out; `include_ignored=true` also searches files ignored by `.gitignore` and the like, but sensitive files remain filtered. + +**`Glob`** matches files against a glob pattern (`pattern`) within a specified directory (`path`, defaulting to the working directory). Results are sorted by modification time in descending order, with a maximum of 1000 entries. The optional `include_dirs` (default true) controls whether directory entries are returned. Pure wildcard patterns (such as `**` or `**/*`) are rejected with a prompt to add a literal anchor; patterns containing brace expansion (`{a,b,c}`) are likewise rejected — the underlying glob engine treats `{` and `}` as literals, so such patterns silently match zero files. + +**`ReadMediaFile`** sends an image or video file to the model as multimodal content, accepting only `path`. The file size limit is 100 MB. Tool availability depends on the current model's vision capabilities (`image_in` / `video_in`). + +## Shell + +| Tool | Default approval | Description | +| --- | --- | --- | +| `Bash` | Requires approval | Execute a shell command | + +**`Bash`** is the most versatile and the most permission-sensitive tool. It accepts `command` (required) along with the optional `cwd` (working directory), `timeout` (milliseconds), `description` (background task description, required when `run_in_background=true`), `run_in_background` (whether to run as a background task), and `disable_timeout` (whether to disable the timeout for a background task). The foreground `timeout` defaults to 60 seconds and is capped at 5 minutes; the background `timeout` defaults to 10 minutes and is also capped at 10 minutes. + +In foreground mode `Bash` blocks the current turn until the command finishes or times out; in background mode it returns a task ID immediately. Background tasks time out after 10 minutes by default; if a task really needs to run without a timeout, set `disable_timeout=true`. When the task completes, fails, or is stopped, the agent is automatically notified to continue processing; during execution, the result can also be inspected explicitly via `TaskOutput`. stdin is always closed, so interactive commands receive EOF immediately. A two-phase termination strategy (SIGTERM → 5-second grace period → SIGKILL) ensures processes terminate reliably after a timeout. On Windows, Git Bash is used as the shell by default. + +## Network tools + +| Tool | Default approval | Description | +| --- | --- | --- | +| `WebSearch` | Auto-approved | Search the web | +| `FetchURL` | Auto-approved | Fetch the content of a given URL | + +**`WebSearch`** accepts `query` (search terms) and the optional `limit` (number of results to return, 1–20, default 5) and `include_content` (whether to return the page body; default false — enabling this consumes significantly more tokens). This tool requires the host to provide a search implementation; if no implementation is injected, it does not appear in the tool list. + +**`FetchURL`** accepts a single `url` parameter and returns the page content. For HTML pages, the host extracts the main article body (`extracted`) rather than returning the full HTML; plain-text or Markdown pages are passed through directly (`passthrough`). Likewise requires a host-injected implementation. + +## Plan mode + +| Tool | Default approval | Description | +| --- | --- | --- | +| `EnterPlanMode` | Auto-approved | Enter Plan mode | +| `ExitPlanMode` | Auto-approved (requires user plan confirmation) | Exit Plan mode and submit the plan | + +Plan mode is a constrained working state: once entered, `Write` and `Edit` are tightened — they may only write to the current plan file, and other paths are blocked; `TaskStop` is also blocked entirely. The remaining tools (including `Bash`) are still governed by the current permission rules, so a `Bash` command can in principle still modify files — whether it is allowed depends on the active approval policy. + +**`EnterPlanMode`** takes no parameters. On success it returns workflow instructions, including the plan file path if one was provided by the host. + +**`ExitPlanMode`** reads the current plan file contents, presents the plan to the user for approval, and then exits Plan mode. The optional `options` parameter lets the agent provide 1–3 alternative proposals (each with a `label` and `description`; the `label` is capped at 80 characters) for the user to choose from during approval. Labels must be unique and cannot use the reserved words `Approve`, `Reject`, `Reject and Exit`, or `Revise` (the system uses these to mark approval results). Once the user approves, all tools become available again; if the user requests changes, the agent remains in Plan mode. + +## State management + +| Tool | Default approval | Description | +| --- | --- | --- | +| `TodoList` | Auto-approved | Manage the task to-do list | + +**`TodoList`** maintains a visible subtask list across multi-step operations; state is stored within the agent session. The `todos` parameter accepts an array where each item has a `title` and a `status` (`pending` / `in_progress` / `done`). Omitting `todos` queries the current list; passing an empty array clears it. + +## Collaboration tools + +Collaboration tools handle inter-agent coordination, user interaction, and skill invocation. + +| Tool | Default approval | Description | +| --- | --- | --- | +| `Agent` | Auto-approved | Spawn a subagent to execute a subtask | +| `AskUserQuestion` | Auto-approved | Ask the user a question to obtain structured input | +| `Skill` | Auto-approved | Invoke a registered inline skill | + +**`Agent`** delegates a subtask to a subagent. Required parameters are `prompt` (the full task description) and `description` (a short 3–5 word summary for UI display). Optional parameters include `subagent_type` (agent type, default `coder`), `resume` (the ID of an existing agent to resume), `run_in_background` (whether to run in the background, default false), and `timeout` (timeout in seconds, 30–3600). `subagent_type` and `resume` are mutually exclusive: when resuming an existing agent, addressing is done solely by ID. When the foreground `timeout` is omitted, the subagent runs to completion with no time limit; when the background `timeout` is omitted, it falls back to `[background] agent_task_timeout_s` in `config.toml`, and if that field is also unset, there is no time limit. In foreground mode the parent agent waits for the subagent to complete before continuing; in background mode it returns a task ID immediately, and upon completion a synthetic user message automatically routes control back to the main agent, with no polling required. For details on the subagent system, see [Subagents](../customization/agents.md). + +**`AskUserQuestion`** presents the user with a structured multiple-choice question, suitable for disambiguation or option-selection scenarios. The `questions` parameter accepts 1–4 questions; each question requires a `question` (question text ending with `?`), `options` (2–4 choices, each with a `label` and `description`), and the optional `header` (a short category label of up to 12 characters, such as `Auth` or `Style`) and `multi_select` (whether multiple choices are allowed, default false). An "Other" option is automatically appended by the system, so there is no need to provide it manually in `options`. If the host does not implement interactive questioning, this tool returns a failure notice and the agent should instead ask the user directly in its text reply. + +**`Skill`** allows the agent to explicitly invoke a registered inline-type skill. It accepts `skill` (the skill name) and an optional `args` (additional argument text). Only skills with `type = "inline"` can be invoked through this tool; other types (such as `prompt` or `flow`) and skills that set `disableModelInvocation: true` in their frontmatter are rejected. To prevent recursive infinite loops, skill nesting depth is limited to 3 levels. For details on the skill system, see [Skills](../customization/skills.md). + +## Background tasks + +Background task tools manage background tasks started via `Bash` or `Agent`. When a background task reaches a terminal state such as completed, failed, stopped, or lost, its status and tail output are automatically sent back to the agent; use `TaskOutput` only when you want to inspect progress before that automatic notification arrives. + +| Tool | Default approval | Description | +| --- | --- | --- | +| `TaskList` | Auto-approved | List background tasks | +| `TaskOutput` | Auto-approved | View the output of a background task | +| `TaskStop` | Requires approval | Stop a running background task | + +**`TaskList`** returns a list of background tasks; each record includes the task ID, status, command, description, and PID. Optional parameters: `active_only` (default true, lists only running tasks) and `limit` (maximum number of entries to return, default 20, range 1–100). Tasks that have reached a terminal state also include `exit_code`; tasks explicitly terminated by `TaskStop` additionally include `reason`. + +**`TaskOutput`** returns the status and output of a specified task by `task_id`. The inline preview includes at most the most recent 32 KB of content; the full log is saved on disk, and the tool also returns `output_path` with a prompt to paginate it via `Read` (around 300 lines per page is recommended). Optional `block` (default false) and `timeout` (seconds to wait, default 30, range 0–3600) parameters can be used to wait for the task to finish before returning. In the response, `retrieval_status` is one of `success` / `timeout` / `not_ready`; tasks aborted by an external deadline timeout additionally include `timed_out: true` and `terminal_reason: timed_out`, and tasks explicitly terminated by `TaskStop` additionally include `stop_reason` and `terminal_reason: stopped`. + +**`TaskStop`** accepts `task_id` and an optional `reason` (reason for stopping, default `Stopped by TaskStop`). It is safe to call on a task that is already in a terminal state — it returns the current status without error. diff --git a/docs/en/release-notes/changelog.md b/docs/en/release-notes/changelog.md new file mode 100644 index 000000000..f1b68dbe0 --- /dev/null +++ b/docs/en/release-notes/changelog.md @@ -0,0 +1,5 @@ +# Changelog + +::: info Note +Detailed release notes will be published alongside the first official release. +::: diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 000000000..f992683cd --- /dev/null +++ b/docs/index.md @@ -0,0 +1,29 @@ +--- +layout: home +hero: + name: Kimi Code CLI + text: ' ' + actions: + - theme: brand + text: 简体中文 + link: zh/ + - theme: alt + text: English + link: en/ +--- + + diff --git a/docs/media/intro.gif b/docs/media/intro.gif new file mode 100644 index 000000000..1acb1d82b Binary files /dev/null and b/docs/media/intro.gif differ diff --git a/docs/package.json b/docs/package.json new file mode 100644 index 000000000..e8f933887 --- /dev/null +++ b/docs/package.json @@ -0,0 +1,19 @@ +{ + "name": "kimi-code-docs", + "private": true, + "license": "MIT", + "type": "module", + "scripts": { + "dev": "vitepress dev", + "build": "vitepress build", + "preview": "vitepress preview" + }, + "devDependencies": { + "vitepress": "^1.5.0" + }, + "dependencies": { + "mermaid": "^11.12.2", + "vitepress-plugin-llms": "^1.10.0", + "vitepress-plugin-mermaid": "^2.0.17" + } +} diff --git a/docs/pnpm-lock.yaml b/docs/pnpm-lock.yaml new file mode 100644 index 000000000..a6200346d --- /dev/null +++ b/docs/pnpm-lock.yaml @@ -0,0 +1,3179 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + mermaid: + specifier: ^11.12.2 + version: 11.15.0 + vitepress-plugin-llms: + specifier: ^1.10.0 + version: 1.12.2 + vitepress-plugin-mermaid: + specifier: ^2.0.17 + version: 2.0.17(mermaid@11.15.0)(vitepress@1.6.4(@algolia/client-search@5.52.1)(postcss@8.5.15)(search-insights@2.17.3)) + devDependencies: + vitepress: + specifier: ^1.5.0 + version: 1.6.4(@algolia/client-search@5.52.1)(postcss@8.5.15)(search-insights@2.17.3) + +packages: + + '@algolia/abtesting@1.18.1': + resolution: {integrity: sha512-aehCadlWOGvrT91KUIZpC0MbB8KBW9yUuvTJFd2xesR7le/IsT4nJUnjCCZ4ZqZCeTcPHPV5mo//fZ5oxcSVYw==} + engines: {node: '>= 14.0.0'} + + '@algolia/autocomplete-core@1.17.7': + resolution: {integrity: sha512-BjiPOW6ks90UKl7TwMv7oNQMnzU+t/wk9mgIDi6b1tXpUek7MW0lbNOUHpvam9pe3lVCf4xPFT+lK7s+e+fs7Q==} + + '@algolia/autocomplete-plugin-algolia-insights@1.17.7': + resolution: {integrity: sha512-Jca5Ude6yUOuyzjnz57og7Et3aXjbwCSDf/8onLHSQgw1qW3ALl9mrMWaXb5FmPVkV3EtkD2F/+NkT6VHyPu9A==} + peerDependencies: + search-insights: '>= 1 < 3' + + '@algolia/autocomplete-preset-algolia@1.17.7': + resolution: {integrity: sha512-ggOQ950+nwbWROq2MOCIL71RE0DdQZsceqrg32UqnhDz8FlO9rL8ONHNsI2R1MH0tkgVIDKI/D0sMiUchsFdWA==} + peerDependencies: + '@algolia/client-search': '>= 4.9.1 < 6' + algoliasearch: '>= 4.9.1 < 6' + + '@algolia/autocomplete-shared@1.17.7': + resolution: {integrity: sha512-o/1Vurr42U/qskRSuhBH+VKxMvkkUVTLU6WZQr+L5lGZZLYWyhdzWjW0iGXY7EkwRTjBqvN2EsR81yCTGV/kmg==} + peerDependencies: + '@algolia/client-search': '>= 4.9.1 < 6' + algoliasearch: '>= 4.9.1 < 6' + + '@algolia/client-abtesting@5.52.1': + resolution: {integrity: sha512-HmXOGBOAOJPounpBzBpuY0zDYeiCpxgHnQmuA7JO6ScukcBdGp3/XM9zJk5pJx/xNGD68mbPGXWpDxGtl6BwDQ==} + engines: {node: '>= 14.0.0'} + + '@algolia/client-analytics@5.52.1': + resolution: {integrity: sha512-5oo4+I8iixie9vXhCyNFCzeIr8pqA3FQ//VsLHTDvZAV4ttYOPGvYHGQq5NSalrLx5Jc3dRro/5uDOlnUMcBJg==} + engines: {node: '>= 14.0.0'} + + '@algolia/client-common@5.52.1': + resolution: {integrity: sha512-qCDoZfx5MpX7XQzvQ3bC4tSEMkQWQMaF/ABtLuoze03Y/flR563CCSws02qIJ23oX7lxl92LsilZjINVyTdtLw==} + engines: {node: '>= 14.0.0'} + + '@algolia/client-insights@5.52.1': + resolution: {integrity: sha512-hnGs0/lsFJ2PWDxNBz7pxreXo/Xz7gxYRcfePBUjsH26ad0kU/sgnVZd9LwWBpsQv65z2jlb5dkyaB9WE9M9FQ==} + engines: {node: '>= 14.0.0'} + + '@algolia/client-personalization@5.52.1': + resolution: {integrity: sha512-2VxxNc/uBysyKvGeBdSM5n9eIDKH8kWD7wd9/yqbJAiVwU4Yv6tU1LSJusHKrXV/aCu1KW7t9Gug9QyeEmtn/Q==} + engines: {node: '>= 14.0.0'} + + '@algolia/client-query-suggestions@5.52.1': + resolution: {integrity: sha512-O6mPtsw3xEfNOe6gWFpYLeAZAIljNa4Hgna3bq15PwyN7nbjTY0wXJFRbzs/0YVf75Br+SbOQUmjKxXYjDiSiQ==} + engines: {node: '>= 14.0.0'} + + '@algolia/client-search@5.52.1': + resolution: {integrity: sha512-gA8oJOV1LnQQkDf91iebNnFInHuW0gRPEgLSOQ7EfipCEjYTHm5swm1DlH9H5RaRw4RrHuzHBegnlzc0MAstcg==} + engines: {node: '>= 14.0.0'} + + '@algolia/ingestion@1.52.1': + resolution: {integrity: sha512-U9zZfc5xIu9wRxZkt+HceJUAD4VKHKbAyLSloJdEyMRmphXeibfrY9cxqIXBcmPeZzGhn3Imb35Dq8l19PkJhw==} + engines: {node: '>= 14.0.0'} + + '@algolia/monitoring@1.52.1': + resolution: {integrity: sha512-a3SGNceHmkQfq77iG8Ka+w1pvwfZa/0lzEIgse30fL0kD+yKnd/dg0dQvSfFPAEt2f21DMcGkDSSeJlO3KdQjQ==} + engines: {node: '>= 14.0.0'} + + '@algolia/recommend@5.52.1': + resolution: {integrity: sha512-z98QEguCFDpxb4S/PyrUK1igqF8tPsdbqOUUO6ON91vJ58w+Gwa6ncrI0oNXSFcrkxA5EqPKPQ2A1PBCn08TYQ==} + engines: {node: '>= 14.0.0'} + + '@algolia/requester-browser-xhr@5.52.1': + resolution: {integrity: sha512-CI7+/0I11QeZM59Uc8whd2or0kqzFVjpaPn9Qpwll/krHcBAxk24WkAQ6WX+IwDVMfpont4YGbKwAmCre3vE8Q==} + engines: {node: '>= 14.0.0'} + + '@algolia/requester-fetch@5.52.1': + resolution: {integrity: sha512-S6bDuw9byfOvm3T71cgdoZgrgnZq6hpdMLkx52Louh57nUAmvGQESz2aojOynQHjbTiV55smvAFbgn0qT4tJrg==} + engines: {node: '>= 14.0.0'} + + '@algolia/requester-node-http@5.52.1': + resolution: {integrity: sha512-tqZXM+54rWo4mk5jL5Z/flE11nPmNEdXwFBM5py9DkOmbjeCNemfVd45FyM97XdzfZ0dl9uOJC6PYn1FpkeyQg==} + engines: {node: '>= 14.0.0'} + + '@antfu/install-pkg@1.1.0': + resolution: {integrity: sha512-MGQsmw10ZyI+EJo45CdSER4zEb+p31LpDAFp2Z3gkSd1yqVZGi0Ebx++YTEMonJy4oChEMLsxZ64j8FH6sSqtQ==} + + '@babel/helper-string-parser@7.27.1': + resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.28.5': + resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==} + engines: {node: '>=6.9.0'} + + '@babel/parser@7.29.3': + resolution: {integrity: sha512-b3ctpQwp+PROvU/cttc4OYl4MzfJUWy6FZg+PMXfzmt/+39iHVF0sDfqay8TQM3JA2EUOyKcFZt75jWriQijsA==} + engines: {node: '>=6.0.0'} + hasBin: true + + '@babel/types@7.29.0': + resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==} + engines: {node: '>=6.9.0'} + + '@braintree/sanitize-url@6.0.4': + resolution: {integrity: sha512-s3jaWicZd0pkP0jf5ysyHUI/RE7MHos6qlToFcGWXVp+ykHOy77OUMrfbgJ9it2C5bow7OIQwYYaHjk9XlBQ2A==} + + '@braintree/sanitize-url@7.1.2': + resolution: {integrity: sha512-jigsZK+sMF/cuiB7sERuo9V7N9jx+dhmHHnQyDSVdpZwVutaBu7WvNYqMDLSgFgfB30n452TP3vjDAvFC973mA==} + + '@chevrotain/types@11.1.2': + resolution: {integrity: sha512-U+HFai5+zmJCkK86QsaJtoITlboZHBqrVketcO2ROv865xfCMSFpELQoz1GkX5GzME8pTa+3kbKrZHQtI0gdbw==} + + '@docsearch/css@3.8.2': + resolution: {integrity: sha512-y05ayQFyUmCXze79+56v/4HpycYF3uFqB78pLPrSV5ZKAlDuIAAJNhaRi8tTdRNXh05yxX/TyNnzD6LwSM89vQ==} + + '@docsearch/js@3.8.2': + resolution: {integrity: sha512-Q5wY66qHn0SwA7Taa0aDbHiJvaFJLOJyHmooQ7y8hlwwQLQ/5WwCcoX0g7ii04Qi2DJlHsd0XXzJ8Ypw9+9YmQ==} + + '@docsearch/react@3.8.2': + resolution: {integrity: sha512-xCRrJQlTt8N9GU0DG4ptwHRkfnSnD/YpdeaXe02iKfqs97TkZJv60yE+1eq/tjPcVnTW8dP5qLP7itifFVV5eg==} + peerDependencies: + '@types/react': '>= 16.8.0 < 19.0.0' + react: '>= 16.8.0 < 19.0.0' + react-dom: '>= 16.8.0 < 19.0.0' + search-insights: '>= 1 < 3' + peerDependenciesMeta: + '@types/react': + optional: true + react: + optional: true + react-dom: + optional: true + search-insights: + optional: true + + '@esbuild/aix-ppc64@0.21.5': + resolution: {integrity: sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.21.5': + resolution: {integrity: sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==} + engines: {node: '>=12'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.21.5': + resolution: {integrity: sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==} + engines: {node: '>=12'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.21.5': + resolution: {integrity: sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==} + engines: {node: '>=12'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.21.5': + resolution: {integrity: sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==} + engines: {node: '>=12'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.21.5': + resolution: {integrity: sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==} + engines: {node: '>=12'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.21.5': + resolution: {integrity: sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==} + engines: {node: '>=12'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.21.5': + resolution: {integrity: sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.21.5': + resolution: {integrity: sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==} + engines: {node: '>=12'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.21.5': + resolution: {integrity: sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==} + engines: {node: '>=12'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.21.5': + resolution: {integrity: sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==} + engines: {node: '>=12'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.21.5': + resolution: {integrity: sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==} + engines: {node: '>=12'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.21.5': + resolution: {integrity: sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==} + engines: {node: '>=12'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.21.5': + resolution: {integrity: sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.21.5': + resolution: {integrity: sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==} + engines: {node: '>=12'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.21.5': + resolution: {integrity: sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==} + engines: {node: '>=12'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.21.5': + resolution: {integrity: sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-x64@0.21.5': + resolution: {integrity: sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==} + engines: {node: '>=12'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-x64@0.21.5': + resolution: {integrity: sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==} + engines: {node: '>=12'} + cpu: [x64] + os: [openbsd] + + '@esbuild/sunos-x64@0.21.5': + resolution: {integrity: sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==} + engines: {node: '>=12'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.21.5': + resolution: {integrity: sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==} + engines: {node: '>=12'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.21.5': + resolution: {integrity: sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==} + engines: {node: '>=12'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.21.5': + resolution: {integrity: sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==} + engines: {node: '>=12'} + cpu: [x64] + os: [win32] + + '@iconify-json/simple-icons@1.2.83': + resolution: {integrity: sha512-6Pp9V++XisT9RKH7FB4RLPqUDzcmLtSma0ovOEIoEWGrXtHwBFsH7oN1z8vvCVCb95fb87QgR46/zRLyN9Y3kg==} + + '@iconify/types@2.0.0': + resolution: {integrity: sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==} + + '@iconify/utils@3.1.3': + resolution: {integrity: sha512-LPKOXPn/zV+zis1oOfGWogaXVpqUybF3ZS6SCZIsz8vg0ivVp9+fVqyYB7xq0aiST/VhUQYGO1qo6uoYSiEJqw==} + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@mermaid-js/mermaid-mindmap@9.3.0': + resolution: {integrity: sha512-IhtYSVBBRYviH1Ehu8gk69pMDF8DSRqXBRDMWrEfHoaMruHeaP2DXA3PBnuwsMaCdPQhlUUcy/7DBLAEIXvCAw==} + + '@mermaid-js/parser@1.1.1': + resolution: {integrity: sha512-VuHdsYMK1bT6X2JbcAaWAhugTRvRBRyuZgd+c22swUeI9g/ntaxF7CY7dYarhZovofCbUNO0G7JesfmNtjYOCw==} + + '@rollup/rollup-android-arm-eabi@4.60.4': + resolution: {integrity: sha512-F5QXMSiFebS9hKZj02XhWLLnRpJ3B3AROP0tWbFBSj+6kCbg5m9j5JoHKd4mmSVy5mS/IMQloYgYxCuJC0fxEQ==} + cpu: [arm] + os: [android] + + '@rollup/rollup-android-arm64@4.60.4': + resolution: {integrity: sha512-GxxTKApUpzRhof7poWvCJHRF51C67u1R7D6DiluBE8wKU1u5GWE8t+v81JvJYtbawoBFX1hLv5Ei4eVjkWokaw==} + cpu: [arm64] + os: [android] + + '@rollup/rollup-darwin-arm64@4.60.4': + resolution: {integrity: sha512-tua0TaJxMOB1R0V0RS1jFZ/RpURFDJIOR2A6jWwQeawuFyS4gBW+rntLRaQd0EQ4bd6Vp44Z2rXW+YYDBsj6IA==} + cpu: [arm64] + os: [darwin] + + '@rollup/rollup-darwin-x64@4.60.4': + resolution: {integrity: sha512-CSKq7MsP+5PFIcydhAiR1K0UhEI1A2jWXVKHPCBZ151yOutENwvnPocgVHkivu2kviURtCEB6zUQw0vs8RrhMg==} + cpu: [x64] + os: [darwin] + + '@rollup/rollup-freebsd-arm64@4.60.4': + resolution: {integrity: sha512-+O8OkVdyvXMtJEciu2wS/pzm1IxntEEQx3z5TAVy4l32G0etZn+RsA48ARRrFm6Ri8fvqPQfgrvNxSjKAbnd3g==} + cpu: [arm64] + os: [freebsd] + + '@rollup/rollup-freebsd-x64@4.60.4': + resolution: {integrity: sha512-Iw3oMskH3AfNuhU0MSN7vNbdi4me/NiYo2azqPz/Le16zHSa+3RRmliCMWWQmh4lcndccU40xcJuTYJZxNo/lw==} + cpu: [x64] + os: [freebsd] + + '@rollup/rollup-linux-arm-gnueabihf@4.60.4': + resolution: {integrity: sha512-EIPRXTVQpHyF8WOo219AD2yEltPehLTcTMz2fn6JsatLYSzQf00hj3rulF+yauOlF9/FtM2WpkT/hJh/KJFGhA==} + cpu: [arm] + os: [linux] + + '@rollup/rollup-linux-arm-musleabihf@4.60.4': + resolution: {integrity: sha512-J3Yh9PzzF1Ovah2At+lHiGQdsYgArxBbXv/zHfSyaiFQEqvNv7DcW98pCrmdjCZBrqBiKrKKe2V+aaSGWuBe/w==} + cpu: [arm] + os: [linux] + + '@rollup/rollup-linux-arm64-gnu@4.60.4': + resolution: {integrity: sha512-BFDEZMYfUvLn37ONE1yMBojPxnMlTFsdyNoqncT0qFq1mAfllL+ATMMJd8TeuVMiX84s1KbcxcZbXInmcO2mRg==} + cpu: [arm64] + os: [linux] + + '@rollup/rollup-linux-arm64-musl@4.60.4': + resolution: {integrity: sha512-pc9EYOSlOgdQ2uPl1o9PF6/kLSgaUosia7gOuS8mB69IxJvlclko1MECXysjs5ryez1/5zjYqx3+xYU0TU6R1A==} + cpu: [arm64] + os: [linux] + + '@rollup/rollup-linux-loong64-gnu@4.60.4': + resolution: {integrity: sha512-NxnomyxYerDh5n4iLrNa+sH+Z+U4BMEE46V2PgQ/hoB909i8gV1M5wPojWg9fk1jWpO3IQnOs20K4wyZuFLEFQ==} + cpu: [loong64] + os: [linux] + + '@rollup/rollup-linux-loong64-musl@4.60.4': + resolution: {integrity: sha512-nbJnQ8a3z1mtmrwImCYhc6BGpThAyYVRQxw9uKSKG4wR6aAYno9sVjJ0zaZcW9BPJX1GbrDPf+SvdWjgTuDmnw==} + cpu: [loong64] + os: [linux] + + '@rollup/rollup-linux-ppc64-gnu@4.60.4': + resolution: {integrity: sha512-2EU6acNrQLd8tYvo/LXW535wupT3m6fo7HKo6lr7ktQoItxTyOL1ZCR/GfGCuXl2vR+zmfI6eRXkSemafv+iVg==} + cpu: [ppc64] + os: [linux] + + '@rollup/rollup-linux-ppc64-musl@4.60.4': + resolution: {integrity: sha512-WeBtoMuaMxiiIrO2IYP3xs6GMWkJP2C0EoT8beTLkUPmzV1i/UcOSVw1d5r9KBODtHKilG5yFxsGRnBbK3wJ4A==} + cpu: [ppc64] + os: [linux] + + '@rollup/rollup-linux-riscv64-gnu@4.60.4': + resolution: {integrity: sha512-FJHFfqpKUI3A10WrWKiFbBZ7yVbGT4q4B5o1qKFFojqpaYoh9LrQgqWCmmcxQzVSXYtyB5bzkXrYzlHTs21MYA==} + cpu: [riscv64] + os: [linux] + + '@rollup/rollup-linux-riscv64-musl@4.60.4': + resolution: {integrity: sha512-mcEl6CUT5IAUmQf1m9FYSmVqCJlpQ8r8eyftFUHG8i9OhY7BkBXSUdnLH5DOf0wCOjcP9v/QO93zpmF1SptCCw==} + cpu: [riscv64] + os: [linux] + + '@rollup/rollup-linux-s390x-gnu@4.60.4': + resolution: {integrity: sha512-ynt3JxVd2w2buzoKDWIyiV1pJW93xlQic1THVLXilz429oijRpSHivZAgp65KBu+cMcgf1eVVjdnTLvPxgCuoQ==} + cpu: [s390x] + os: [linux] + + '@rollup/rollup-linux-x64-gnu@4.60.4': + resolution: {integrity: sha512-Boiz5+MsaROEWDf+GGEwF8VMHGhlUoQMtIPjOgA5fv4osupqTVnJteQNKJwUcnUog2G55jYXH7KZFFiJe0TEzQ==} + cpu: [x64] + os: [linux] + + '@rollup/rollup-linux-x64-musl@4.60.4': + resolution: {integrity: sha512-+qfSY27qIrFfI/Hom04KYFw3GKZSGU4lXus51wsb5EuySfFlWRwjkKWoE9emgRw/ukoT4Udsj4W/+xxG8VbPKg==} + cpu: [x64] + os: [linux] + + '@rollup/rollup-openbsd-x64@4.60.4': + resolution: {integrity: sha512-VpTfOPHgVXEBeeR8hZ2O0F3aSso+JDWqTWmTmzcQKted54IAdUVbxE+j/MVxUsKa8L20HJhv3vUezVPoquqWjA==} + cpu: [x64] + os: [openbsd] + + '@rollup/rollup-openharmony-arm64@4.60.4': + resolution: {integrity: sha512-IPOsh5aRYuLv/nkU51X10Bf75Bsf6+gZdx1X+QP5QM6lIJFHHqbHLG0uJn/hWthzo13UAc2umiUorqZy3axoZg==} + cpu: [arm64] + os: [openharmony] + + '@rollup/rollup-win32-arm64-msvc@4.60.4': + resolution: {integrity: sha512-4QzE9E81OohJ/HKzHhsqU+zcYYojVOXlFMs1DdyMT6qXl/niOH7AVElmmEdUNHHS/oRkc++d5k6Vy85zFs0DEw==} + cpu: [arm64] + os: [win32] + + '@rollup/rollup-win32-ia32-msvc@4.60.4': + resolution: {integrity: sha512-zTPgT1YuHHcd+Tmx7h8aml0FWFVelV5N54oHow9SLj+GfoDy/huQ+UV396N/C7KpMDMiPspRktzM1/0r1usYEA==} + cpu: [ia32] + os: [win32] + + '@rollup/rollup-win32-x64-gnu@4.60.4': + resolution: {integrity: sha512-DRS4G7mi9lJxqEDezIkKCaUIKCrLUUDCUaCsTPCi/rtqaC6D/jjwslMQyiDU50Ka0JKpeXeRBFBAXwArY52vBw==} + cpu: [x64] + os: [win32] + + '@rollup/rollup-win32-x64-msvc@4.60.4': + resolution: {integrity: sha512-QVTUovf40zgTqlFVrKA1uXMVvU2QWEFWfAH8Wdc48IxLvrJMQVMBRjuQyUpzZCDkakImib9eVazbWlC6ksWtJw==} + cpu: [x64] + os: [win32] + + '@shikijs/core@2.5.0': + resolution: {integrity: sha512-uu/8RExTKtavlpH7XqnVYBrfBkUc20ngXiX9NSrBhOVZYv/7XQRKUyhtkeflY5QsxC0GbJThCerruZfsUaSldg==} + + '@shikijs/engine-javascript@2.5.0': + resolution: {integrity: sha512-VjnOpnQf8WuCEZtNUdjjwGUbtAVKuZkVQ/5cHy/tojVVRIRtlWMYVjyWhxOmIq05AlSOv72z7hRNRGVBgQOl0w==} + + '@shikijs/engine-oniguruma@2.5.0': + resolution: {integrity: sha512-pGd1wRATzbo/uatrCIILlAdFVKdxImWJGQ5rFiB5VZi2ve5xj3Ax9jny8QvkaV93btQEwR/rSz5ERFpC5mKNIw==} + + '@shikijs/langs@2.5.0': + resolution: {integrity: sha512-Qfrrt5OsNH5R+5tJ/3uYBBZv3SuGmnRPejV9IlIbFH3HTGLDlkqgHymAlzklVmKBjAaVmkPkyikAV/sQ1wSL+w==} + + '@shikijs/themes@2.5.0': + resolution: {integrity: sha512-wGrk+R8tJnO0VMzmUExHR+QdSaPUl/NKs+a4cQQRWyoc3YFbUzuLEi/KWK1hj+8BfHRKm2jNhhJck1dfstJpiw==} + + '@shikijs/transformers@2.5.0': + resolution: {integrity: sha512-SI494W5X60CaUwgi8u4q4m4s3YAFSxln3tzNjOSYqq54wlVgz0/NbbXEb3mdLbqMBztcmS7bVTaEd2w0qMmfeg==} + + '@shikijs/types@2.5.0': + resolution: {integrity: sha512-ygl5yhxki9ZLNuNpPitBWvcy9fsSKKaRuO4BAlMyagszQidxcpLAr0qiW/q43DtSIDxO6hEbtYLiFZNXO/hdGw==} + + '@shikijs/vscode-textmate@10.0.2': + resolution: {integrity: sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==} + + '@types/d3-array@3.2.2': + resolution: {integrity: sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==} + + '@types/d3-axis@3.0.6': + resolution: {integrity: sha512-pYeijfZuBd87T0hGn0FO1vQ/cgLk6E1ALJjfkC0oJ8cbwkZl3TpgS8bVBLZN+2jjGgg38epgxb2zmoGtSfvgMw==} + + '@types/d3-brush@3.0.6': + resolution: {integrity: sha512-nH60IZNNxEcrh6L1ZSMNA28rj27ut/2ZmI3r96Zd+1jrZD++zD3LsMIjWlvg4AYrHn/Pqz4CF3veCxGjtbqt7A==} + + '@types/d3-chord@3.0.6': + resolution: {integrity: sha512-LFYWWd8nwfwEmTZG9PfQxd17HbNPksHBiJHaKuY1XeqscXacsS2tyoo6OdRsjf+NQYeB6XrNL3a25E3gH69lcg==} + + '@types/d3-color@3.1.3': + resolution: {integrity: sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==} + + '@types/d3-contour@3.0.6': + resolution: {integrity: sha512-BjzLgXGnCWjUSYGfH1cpdo41/hgdWETu4YxpezoztawmqsvCeep+8QGfiY6YbDvfgHz/DkjeIkkZVJavB4a3rg==} + + '@types/d3-delaunay@6.0.4': + resolution: {integrity: sha512-ZMaSKu4THYCU6sV64Lhg6qjf1orxBthaC161plr5KuPHo3CNm8DTHiLw/5Eq2b6TsNP0W0iJrUOFscY6Q450Hw==} + + '@types/d3-dispatch@3.0.7': + resolution: {integrity: sha512-5o9OIAdKkhN1QItV2oqaE5KMIiXAvDWBDPrD85e58Qlz1c1kI/J0NcqbEG88CoTwJrYe7ntUCVfeUl2UJKbWgA==} + + '@types/d3-drag@3.0.7': + resolution: {integrity: sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==} + + '@types/d3-dsv@3.0.7': + resolution: {integrity: sha512-n6QBF9/+XASqcKK6waudgL0pf/S5XHPPI8APyMLLUHd8NqouBGLsU8MgtO7NINGtPBtk9Kko/W4ea0oAspwh9g==} + + '@types/d3-ease@3.0.2': + resolution: {integrity: sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==} + + '@types/d3-fetch@3.0.7': + resolution: {integrity: sha512-fTAfNmxSb9SOWNB9IoG5c8Hg6R+AzUHDRlsXsDZsNp6sxAEOP0tkP3gKkNSO/qmHPoBFTxNrjDprVHDQDvo5aA==} + + '@types/d3-force@3.0.10': + resolution: {integrity: sha512-ZYeSaCF3p73RdOKcjj+swRlZfnYpK1EbaDiYICEEp5Q6sUiqFaFQ9qgoshp5CzIyyb/yD09kD9o2zEltCexlgw==} + + '@types/d3-format@3.0.4': + resolution: {integrity: sha512-fALi2aI6shfg7vM5KiR1wNJnZ7r6UuggVqtDA+xiEdPZQwy/trcQaHnwShLuLdta2rTymCNpxYTiMZX/e09F4g==} + + '@types/d3-geo@3.1.0': + resolution: {integrity: sha512-856sckF0oP/diXtS4jNsiQw/UuK5fQG8l/a9VVLeSouf1/PPbBE1i1W852zVwKwYCBkFJJB7nCFTbk6UMEXBOQ==} + + '@types/d3-hierarchy@3.1.7': + resolution: {integrity: sha512-tJFtNoYBtRtkNysX1Xq4sxtjK8YgoWUNpIiUee0/jHGRwqvzYxkq0hGVbbOGSz+JgFxxRu4K8nb3YpG3CMARtg==} + + '@types/d3-interpolate@3.0.4': + resolution: {integrity: sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==} + + '@types/d3-path@3.1.1': + resolution: {integrity: sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==} + + '@types/d3-polygon@3.0.2': + resolution: {integrity: sha512-ZuWOtMaHCkN9xoeEMr1ubW2nGWsp4nIql+OPQRstu4ypeZ+zk3YKqQT0CXVe/PYqrKpZAi+J9mTs05TKwjXSRA==} + + '@types/d3-quadtree@3.0.6': + resolution: {integrity: sha512-oUzyO1/Zm6rsxKRHA1vH0NEDG58HrT5icx/azi9MF1TWdtttWl0UIUsjEQBBh+SIkrpd21ZjEv7ptxWys1ncsg==} + + '@types/d3-random@3.0.3': + resolution: {integrity: sha512-Imagg1vJ3y76Y2ea0871wpabqp613+8/r0mCLEBfdtqC7xMSfj9idOnmBYyMoULfHePJyxMAw3nWhJxzc+LFwQ==} + + '@types/d3-scale-chromatic@3.1.0': + resolution: {integrity: sha512-iWMJgwkK7yTRmWqRB5plb1kadXyQ5Sj8V/zYlFGMUBbIPKQScw+Dku9cAAMgJG+z5GYDoMjWGLVOvjghDEFnKQ==} + + '@types/d3-scale@4.0.9': + resolution: {integrity: sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==} + + '@types/d3-selection@3.0.11': + resolution: {integrity: sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==} + + '@types/d3-shape@3.1.8': + resolution: {integrity: sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==} + + '@types/d3-time-format@4.0.3': + resolution: {integrity: sha512-5xg9rC+wWL8kdDj153qZcsJ0FWiFt0J5RB6LYUNZjwSnesfblqrI/bJ1wBdJ8OQfncgbJG5+2F+qfqnqyzYxyg==} + + '@types/d3-time@3.0.4': + resolution: {integrity: sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==} + + '@types/d3-timer@3.0.2': + resolution: {integrity: sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==} + + '@types/d3-transition@3.0.9': + resolution: {integrity: sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==} + + '@types/d3-zoom@3.0.8': + resolution: {integrity: sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==} + + '@types/d3@7.4.3': + resolution: {integrity: sha512-lZXZ9ckh5R8uiFVt8ogUNf+pIrK4EsWrx2Np75WvF/eTpJ0FMHNhjXk8CKEx/+gpHbNQyJWehbFaTvqmHWB3ww==} + + '@types/debug@4.1.13': + resolution: {integrity: sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==} + + '@types/estree@1.0.8': + resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} + + '@types/geojson@7946.0.16': + resolution: {integrity: sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==} + + '@types/hast@3.0.4': + resolution: {integrity: sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==} + + '@types/linkify-it@5.0.0': + resolution: {integrity: sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==} + + '@types/markdown-it@14.1.2': + resolution: {integrity: sha512-promo4eFwuiW+TfGxhi+0x3czqTYJkG8qB17ZUJiVF10Xm7NLVRSLUsfRTU/6h1e24VvRnXCx+hG7li58lkzog==} + + '@types/mdast@4.0.4': + resolution: {integrity: sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==} + + '@types/mdurl@2.0.0': + resolution: {integrity: sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==} + + '@types/ms@2.1.0': + resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==} + + '@types/trusted-types@2.0.7': + resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==} + + '@types/unist@3.0.3': + resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==} + + '@types/web-bluetooth@0.0.21': + resolution: {integrity: sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA==} + + '@ungap/structured-clone@1.3.1': + resolution: {integrity: sha512-mUFwbeTqrVgDQxFveS+df2yfap6iuP20NAKAsBt5jDEoOTDew+zwLAOilHCeQJOVSvmgCX4ogqIrA0mnyr08yQ==} + + '@upsetjs/venn.js@2.0.0': + resolution: {integrity: sha512-WbBhLrooyePuQ1VZxrJjtLvTc4NVfpOyKx0sKqioq9bX1C1m7Jgykkn8gLrtwumBioXIqam8DLxp88Adbue6Hw==} + + '@vitejs/plugin-vue@5.2.4': + resolution: {integrity: sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA==} + engines: {node: ^18.0.0 || >=20.0.0} + peerDependencies: + vite: ^5.0.0 || ^6.0.0 + vue: ^3.2.25 + + '@vue/compiler-core@3.5.34': + resolution: {integrity: sha512-s9cLyK5mLcvZ4Agva5QgRsQyLKvts9WbU9DB6NqiZkkGEdwmcEiylj5Jbwkp680drF/NNCV8OlAJSe+yMLxaJw==} + + '@vue/compiler-dom@3.5.34': + resolution: {integrity: sha512-EbF/T++k0e2MMZlJsBhzK8Sgwt0HcIPOhzn1CTB/lv6sQcyk+OWf8YeiLxZp3ro7MbbLcAfAJ6sEvjFWuNgUCw==} + + '@vue/compiler-sfc@3.5.34': + resolution: {integrity: sha512-D/ihr6uZeIt6r+pVZf46RWT1fAsLFMbUP7k8G1VkiiWexriED9GrX3echHd4Abbt17zjlfiFJ8z7a3BxZOPNjg==} + + '@vue/compiler-ssr@3.5.34': + resolution: {integrity: sha512-cDtTHKibkThKGHH1SP+WdccquNRYQDFH6rRjQCqT9G2ltFAfoR5pUftpab/z+aM5mW9HLLVQW7hfKKQe/1GBeQ==} + + '@vue/devtools-api@7.7.9': + resolution: {integrity: sha512-kIE8wvwlcZ6TJTbNeU2HQNtaxLx3a84aotTITUuL/4bzfPxzajGBOoqjMhwZJ8L9qFYDU/lAYMEEm11dnZOD6g==} + + '@vue/devtools-kit@7.7.9': + resolution: {integrity: sha512-PyQ6odHSgiDVd4hnTP+aDk2X4gl2HmLDfiyEnn3/oV+ckFDuswRs4IbBT7vacMuGdwY/XemxBoh302ctbsptuA==} + + '@vue/devtools-shared@7.7.9': + resolution: {integrity: sha512-iWAb0v2WYf0QWmxCGy0seZNDPdO3Sp5+u78ORnyeonS6MT4PC7VPrryX2BpMJrwlDeaZ6BD4vP4XKjK0SZqaeA==} + + '@vue/reactivity@3.5.34': + resolution: {integrity: sha512-y9XDjCEuBp+98k+UL5dbYkh57AHU4o6cxZedOPXw3bmrZZYLQsVHguGurq7hVrPCSrQtrnz1f9dssyFr+dMXfQ==} + + '@vue/runtime-core@3.5.34': + resolution: {integrity: sha512-mKeBYvu8tcMSLhypAHBmriUFfWXKTCF/23Z4jiCoYK3UtWepkliViNLuR90V9XOyD62mUxs9p1jsrpK3CCGIzw==} + + '@vue/runtime-dom@3.5.34': + resolution: {integrity: sha512-e8kZzERmCwUnBRVsgSQlAfrfU2rGoy0FFKPBXSlfEjc/O3KfA7QP0t1/2ZylrbchjmIKB4dPTd07A6WPr0eOrg==} + + '@vue/server-renderer@3.5.34': + resolution: {integrity: sha512-nHxmJoTrKsmrkbILRhkC9gY1G3moZbJTqCzDd7DOOzG5KH9oeJ0Unqrff5f9v0pW//jES05ZkJcNtfE8JjOIew==} + peerDependencies: + vue: 3.5.34 + + '@vue/shared@3.5.34': + resolution: {integrity: sha512-24uqU4OIiX29ryC3MeWid/Xf2fa2EFRUVLb77nRhk+UrTVrh/XiGtFAFmJBAtBRbjwNdsPRP+jj/OL27Eg1NDA==} + + '@vueuse/core@12.8.2': + resolution: {integrity: sha512-HbvCmZdzAu3VGi/pWYm5Ut+Kd9mn1ZHnn4L5G8kOQTPs/IwIAmJoBrmYk2ckLArgMXZj0AW3n5CAejLUO+PhdQ==} + + '@vueuse/integrations@12.8.2': + resolution: {integrity: sha512-fbGYivgK5uBTRt7p5F3zy6VrETlV9RtZjBqd1/HxGdjdckBgBM4ugP8LHpjolqTj14TXTxSK1ZfgPbHYyGuH7g==} + peerDependencies: + async-validator: ^4 + axios: ^1 + change-case: ^5 + drauu: ^0.4 + focus-trap: ^7 + fuse.js: ^7 + idb-keyval: ^6 + jwt-decode: ^4 + nprogress: ^0.2 + qrcode: ^1.5 + sortablejs: ^1 + universal-cookie: ^7 + peerDependenciesMeta: + async-validator: + optional: true + axios: + optional: true + change-case: + optional: true + drauu: + optional: true + focus-trap: + optional: true + fuse.js: + optional: true + idb-keyval: + optional: true + jwt-decode: + optional: true + nprogress: + optional: true + qrcode: + optional: true + sortablejs: + optional: true + universal-cookie: + optional: true + + '@vueuse/metadata@12.8.2': + resolution: {integrity: sha512-rAyLGEuoBJ/Il5AmFHiziCPdQzRt88VxR+Y/A/QhJ1EWtWqPBBAxTAFaSkviwEuOEZNtW8pvkPgoCZQ+HxqW1A==} + + '@vueuse/shared@12.8.2': + resolution: {integrity: sha512-dznP38YzxZoNloI0qpEfpkms8knDtaoQ6Y/sfS0L7Yki4zh40LFHEhur0odJC6xTHG5dxWVPiUWBXn+wCG2s5w==} + + algoliasearch@5.52.1: + resolution: {integrity: sha512-fHA8+kXTbjagw3jkLiaS7KKrH8qe2DyOsiUhGlN4cdT77PEsfqXZl7ewDk1hsg+pJnPlnE50XtLxjR91iJOpmg==} + engines: {node: '>= 14.0.0'} + + ansi-regex@5.0.1: + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} + engines: {node: '>=8'} + + ansi-styles@4.3.0: + resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} + engines: {node: '>=8'} + + argparse@1.0.10: + resolution: {integrity: sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==} + + argparse@2.0.1: + resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + + bail@2.0.2: + resolution: {integrity: sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==} + + balanced-match@4.0.4: + resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} + engines: {node: 18 || 20 || >=22} + + birpc@2.9.0: + resolution: {integrity: sha512-KrayHS5pBi69Xi9JmvoqrIgYGDkD6mcSe/i6YKi3w5kekCLzrX4+nawcXqrj2tIp50Kw/mT/s3p+GVK0A0sKxw==} + + brace-expansion@5.0.6: + resolution: {integrity: sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==} + engines: {node: 18 || 20 || >=22} + + ccount@2.0.1: + resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==} + + character-entities-html4@2.1.0: + resolution: {integrity: sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==} + + character-entities-legacy@3.0.0: + resolution: {integrity: sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==} + + character-entities@2.0.2: + resolution: {integrity: sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==} + + cliui@8.0.1: + resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} + engines: {node: '>=12'} + + color-convert@2.0.1: + resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} + engines: {node: '>=7.0.0'} + + color-name@1.1.4: + resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + + comma-separated-tokens@2.0.3: + resolution: {integrity: sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==} + + commander@7.2.0: + resolution: {integrity: sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==} + engines: {node: '>= 10'} + + commander@8.3.0: + resolution: {integrity: sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==} + engines: {node: '>= 12'} + + copy-anything@4.0.5: + resolution: {integrity: sha512-7Vv6asjS4gMOuILabD3l739tsaxFQmC+a7pLZm02zyvs8p977bL3zEgq3yDk5rn9B0PbYgIv++jmHcuUab4RhA==} + engines: {node: '>=18'} + + cose-base@1.0.3: + resolution: {integrity: sha512-s9whTXInMSgAp/NVXVNuVxVKzGH2qck3aQlVHxDCdAEPgtMKwc4Wq6/QKhgdEdgbLSi9rBTAcPoRa6JpiG4ksg==} + + cose-base@2.2.0: + resolution: {integrity: sha512-AzlgcsCbUMymkADOJtQm3wO9S3ltPfYOFD5033keQn9NJzIbtnZj+UdBJe7DYml/8TdbtHJW3j58SOnKhWY/5g==} + + csstype@3.2.3: + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + + cytoscape-cose-bilkent@4.1.0: + resolution: {integrity: sha512-wgQlVIUJF13Quxiv5e1gstZ08rnZj2XaLHGoFMYXz7SkNfCDOOteKBE6SYRfA9WxxI/iBc3ajfDoc6hb/MRAHQ==} + peerDependencies: + cytoscape: ^3.2.0 + + cytoscape-fcose@2.2.0: + resolution: {integrity: sha512-ki1/VuRIHFCzxWNrsshHYPs6L7TvLu3DL+TyIGEsRcvVERmxokbf5Gdk7mFxZnTdiGtnA4cfSmjZJMviqSuZrQ==} + peerDependencies: + cytoscape: ^3.2.0 + + cytoscape@3.33.4: + resolution: {integrity: sha512-HIN5Pmd9MrX9BkV7tDwnOcEJCSFvCpc8X97h3f508J6I5FsqAY65wKOCvgH2CuP42CaahWaz4tuh32SOOIH7ww==} + engines: {node: '>=0.10'} + + d3-array@2.12.1: + resolution: {integrity: sha512-B0ErZK/66mHtEsR1TkPEEkwdy+WDesimkM5gpZr5Dsg54BiTA5RXtYW5qTLIAcekaS9xfZrzBLF/OAkB3Qn1YQ==} + + d3-array@3.2.4: + resolution: {integrity: sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==} + engines: {node: '>=12'} + + d3-axis@3.0.0: + resolution: {integrity: sha512-IH5tgjV4jE/GhHkRV0HiVYPDtvfjHQlQfJHs0usq7M30XcSBvOotpmH1IgkcXsO/5gEQZD43B//fc7SRT5S+xw==} + engines: {node: '>=12'} + + d3-brush@3.0.0: + resolution: {integrity: sha512-ALnjWlVYkXsVIGlOsuWH1+3udkYFI48Ljihfnh8FZPF2QS9o+PzGLBslO0PjzVoHLZ2KCVgAM8NVkXPJB2aNnQ==} + engines: {node: '>=12'} + + d3-chord@3.0.1: + resolution: {integrity: sha512-VE5S6TNa+j8msksl7HwjxMHDM2yNK3XCkusIlpX5kwauBfXuyLAtNg9jCp/iHH61tgI4sb6R/EIMWCqEIdjT/g==} + engines: {node: '>=12'} + + d3-color@3.1.0: + resolution: {integrity: sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==} + engines: {node: '>=12'} + + d3-contour@4.0.2: + resolution: {integrity: sha512-4EzFTRIikzs47RGmdxbeUvLWtGedDUNkTcmzoeyg4sP/dvCexO47AaQL7VKy/gul85TOxw+IBgA8US2xwbToNA==} + engines: {node: '>=12'} + + d3-delaunay@6.0.4: + resolution: {integrity: sha512-mdjtIZ1XLAM8bm/hx3WwjfHt6Sggek7qH043O8KEjDXN40xi3vx/6pYSVTwLjEgiXQTbvaouWKynLBiUZ6SK6A==} + engines: {node: '>=12'} + + d3-dispatch@3.0.1: + resolution: {integrity: sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==} + engines: {node: '>=12'} + + d3-drag@3.0.0: + resolution: {integrity: sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==} + engines: {node: '>=12'} + + d3-dsv@3.0.1: + resolution: {integrity: sha512-UG6OvdI5afDIFP9w4G0mNq50dSOsXHJaRE8arAS5o9ApWnIElp8GZw1Dun8vP8OyHOZ/QJUKUJwxiiCCnUwm+Q==} + engines: {node: '>=12'} + hasBin: true + + d3-ease@3.0.1: + resolution: {integrity: sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==} + engines: {node: '>=12'} + + d3-fetch@3.0.1: + resolution: {integrity: sha512-kpkQIM20n3oLVBKGg6oHrUchHM3xODkTzjMoj7aWQFq5QEM+R6E4WkzT5+tojDY7yjez8KgCBRoj4aEr99Fdqw==} + engines: {node: '>=12'} + + d3-force@3.0.0: + resolution: {integrity: sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg==} + engines: {node: '>=12'} + + d3-format@3.1.2: + resolution: {integrity: sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==} + engines: {node: '>=12'} + + d3-geo@3.1.1: + resolution: {integrity: sha512-637ln3gXKXOwhalDzinUgY83KzNWZRKbYubaG+fGVuc/dxO64RRljtCTnf5ecMyE1RIdtqpkVcq0IbtU2S8j2Q==} + engines: {node: '>=12'} + + d3-hierarchy@3.1.2: + resolution: {integrity: sha512-FX/9frcub54beBdugHjDCdikxThEqjnR93Qt7PvQTOHxyiNCAlvMrHhclk3cD5VeAaq9fxmfRp+CnWw9rEMBuA==} + engines: {node: '>=12'} + + d3-interpolate@3.0.1: + resolution: {integrity: sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==} + engines: {node: '>=12'} + + d3-path@1.0.9: + resolution: {integrity: sha512-VLaYcn81dtHVTjEHd8B+pbe9yHWpXKZUC87PzoFmsFrJqgFwDe/qxfp5MlfsfM1V5E/iVt0MmEbWQ7FVIXh/bg==} + + d3-path@3.1.0: + resolution: {integrity: sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==} + engines: {node: '>=12'} + + d3-polygon@3.0.1: + resolution: {integrity: sha512-3vbA7vXYwfe1SYhED++fPUQlWSYTTGmFmQiany/gdbiWgU/iEyQzyymwL9SkJjFFuCS4902BSzewVGsHHmHtXg==} + engines: {node: '>=12'} + + d3-quadtree@3.0.1: + resolution: {integrity: sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw==} + engines: {node: '>=12'} + + d3-random@3.0.1: + resolution: {integrity: sha512-FXMe9GfxTxqd5D6jFsQ+DJ8BJS4E/fT5mqqdjovykEB2oFbTMDVdg1MGFxfQW+FBOGoB++k8swBrgwSHT1cUXQ==} + engines: {node: '>=12'} + + d3-sankey@0.12.3: + resolution: {integrity: sha512-nQhsBRmM19Ax5xEIPLMY9ZmJ/cDvd1BG3UVvt5h3WRxKg5zGRbvnteTyWAbzeSvlh3tW7ZEmq4VwR5mB3tutmQ==} + + d3-scale-chromatic@3.1.0: + resolution: {integrity: sha512-A3s5PWiZ9YCXFye1o246KoscMWqf8BsD9eRiJ3He7C9OBaxKhAd5TFCdEx/7VbKtxxTsu//1mMJFrEt572cEyQ==} + engines: {node: '>=12'} + + d3-scale@4.0.2: + resolution: {integrity: sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==} + engines: {node: '>=12'} + + d3-selection@3.0.0: + resolution: {integrity: sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==} + engines: {node: '>=12'} + + d3-shape@1.3.7: + resolution: {integrity: sha512-EUkvKjqPFUAZyOlhY5gzCxCeI0Aep04LwIRpsZ/mLFelJiUfnK56jo5JMDSE7yyP2kLSb6LtF+S5chMk7uqPqw==} + + d3-shape@3.2.0: + resolution: {integrity: sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==} + engines: {node: '>=12'} + + d3-time-format@4.1.0: + resolution: {integrity: sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==} + engines: {node: '>=12'} + + d3-time@3.1.0: + resolution: {integrity: sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==} + engines: {node: '>=12'} + + d3-timer@3.0.1: + resolution: {integrity: sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==} + engines: {node: '>=12'} + + d3-transition@3.0.1: + resolution: {integrity: sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==} + engines: {node: '>=12'} + peerDependencies: + d3-selection: 2 - 3 + + d3-zoom@3.0.0: + resolution: {integrity: sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==} + engines: {node: '>=12'} + + d3@7.9.0: + resolution: {integrity: sha512-e1U46jVP+w7Iut8Jt8ri1YsPOvFpg46k+K8TpCb0P+zjCkjkPnV7WzfDJzMHy1LnA+wj5pLT1wjO901gLXeEhA==} + engines: {node: '>=12'} + + dagre-d3-es@7.0.14: + resolution: {integrity: sha512-P4rFMVq9ESWqmOgK+dlXvOtLwYg0i7u0HBGJER0LZDJT2VHIPAMZ/riPxqJceWMStH5+E61QxFra9kIS3AqdMg==} + + dayjs@1.11.20: + resolution: {integrity: sha512-YbwwqR/uYpeoP4pu043q+LTDLFBLApUP6VxRihdfNTqu4ubqMlGDLd6ErXhEgsyvY0K6nCs7nggYumAN+9uEuQ==} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + decode-named-character-reference@1.3.0: + resolution: {integrity: sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==} + + delaunator@5.1.0: + resolution: {integrity: sha512-AGrQ4QSgssa1NGmWmLPqN5NY2KajF5MqxetNEO+o0n3ZwZZeTmt7bBnvzHWrmkZFxGgr4HdyFgelzgi06otLuQ==} + + dequal@2.0.3: + resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} + engines: {node: '>=6'} + + devlop@1.1.0: + resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==} + + dompurify@3.4.5: + resolution: {integrity: sha512-OrwIBKsdNSVEeubdJ1HBv/wNENRM9ytAVCv7YXt//A3vPdVMNuACRqK9mXCGCBW2ln7BT/A4X0jXHo2Gu89miA==} + + emoji-regex-xs@1.0.0: + resolution: {integrity: sha512-LRlerrMYoIDrT6jgpeZ2YYl/L8EulRTt5hQcYjy5AInh7HWXKimpqx68aknBFpGL2+/IcogTcaydJEgaTmOpDg==} + + emoji-regex@8.0.0: + resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} + + entities@4.5.0: + resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==} + engines: {node: '>=0.12'} + + entities@7.0.1: + resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==} + engines: {node: '>=0.12'} + + es-toolkit@1.46.1: + resolution: {integrity: sha512-5eNtXOs3tbfxXOj04tjjseeWkRWaoCjdEI+96DgwzZoe6c9juL49pXlzAFTI72aWC9Y8p7168g6XIKjh7k6pyQ==} + + esbuild@0.21.5: + resolution: {integrity: sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==} + engines: {node: '>=12'} + hasBin: true + + escalade@3.2.0: + resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} + engines: {node: '>=6'} + + escape-string-regexp@5.0.0: + resolution: {integrity: sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==} + engines: {node: '>=12'} + + esprima@4.0.1: + resolution: {integrity: sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==} + engines: {node: '>=4'} + hasBin: true + + estree-walker@2.0.2: + resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==} + + extend-shallow@2.0.1: + resolution: {integrity: sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug==} + engines: {node: '>=0.10.0'} + + extend@3.0.2: + resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==} + + fault@2.0.1: + resolution: {integrity: sha512-WtySTkS4OKev5JtpHXnib4Gxiurzh5NCGvWrFaZ34m6JehfTUhKZvn9njTfw48t6JumVQOmrKqpmGcdwxnhqBQ==} + + focus-trap@7.8.0: + resolution: {integrity: sha512-/yNdlIkpWbM0ptxno3ONTuf+2g318kh2ez3KSeZN5dZ8YC6AAmgeWz+GasYYiBJPFaYcSAPeu4GfhUaChzIJXA==} + + format@0.2.2: + resolution: {integrity: sha512-wzsgA6WOq+09wrU1tsJ09udeR/YZRaeArL9e1wPbFg3GG2yDnC2ldKpxs4xunpFF9DgqCqOIra3bc1HWrJ37Ww==} + engines: {node: '>=0.4.x'} + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + get-caller-file@2.0.5: + resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} + engines: {node: 6.* || 8.* || >= 10.*} + + gray-matter@4.0.3: + resolution: {integrity: sha512-5v6yZd4JK3eMI3FqqCouswVqwugaA9r4dNZB1wwcmrD02QkV5H0y7XBQW8QwQqEaZY1pM9aqORSORhJRdNK44Q==} + engines: {node: '>=6.0'} + + hachure-fill@0.5.2: + resolution: {integrity: sha512-3GKBOn+m2LX9iq+JC1064cSFprJY4jL1jCXTcpnfER5HYE2l/4EfWSGzkPa/ZDBmYI0ZOEj5VHV/eKnPGkHuOg==} + + hast-util-to-html@9.0.5: + resolution: {integrity: sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw==} + + hast-util-whitespace@3.0.0: + resolution: {integrity: sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==} + + hookable@5.5.3: + resolution: {integrity: sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==} + + html-void-elements@3.0.0: + resolution: {integrity: sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==} + + iconv-lite@0.6.3: + resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} + engines: {node: '>=0.10.0'} + + import-meta-resolve@4.2.0: + resolution: {integrity: sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg==} + + internmap@1.0.1: + resolution: {integrity: sha512-lDB5YccMydFBtasVtxnZ3MRBHuaoE8GKsppq+EchKL2U4nK/DmEpPHNH8MZe5HkMtpSiTSOZwfN0tzYjO/lJEw==} + + internmap@2.0.3: + resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==} + engines: {node: '>=12'} + + is-extendable@0.1.1: + resolution: {integrity: sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw==} + engines: {node: '>=0.10.0'} + + is-fullwidth-code-point@3.0.0: + resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} + engines: {node: '>=8'} + + is-plain-obj@4.1.0: + resolution: {integrity: sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==} + engines: {node: '>=12'} + + is-what@5.5.0: + resolution: {integrity: sha512-oG7cgbmg5kLYae2N5IVd3jm2s+vldjxJzK1pcu9LfpGuQ93MQSzo0okvRna+7y5ifrD+20FE8FvjusyGaz14fw==} + engines: {node: '>=18'} + + js-yaml@3.14.2: + resolution: {integrity: sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==} + hasBin: true + + katex@0.16.47: + resolution: {integrity: sha512-Eeo8Ys1doU1z+x8AZsPpQu+p/QcZBI5PeOo7QGQdy2x2m0MU/hYagBbGOmXwr5KVbEfVuWv9LpnQWeehogurjg==} + hasBin: true + + khroma@2.1.0: + resolution: {integrity: sha512-Ls993zuzfayK269Svk9hzpeGUKob/sIgZzyHYdjQoAdQetRKpOLj+k/QQQ/6Qi0Yz65mlROrfd+Ev+1+7dz9Kw==} + + kind-of@6.0.3: + resolution: {integrity: sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==} + engines: {node: '>=0.10.0'} + + layout-base@1.0.2: + resolution: {integrity: sha512-8h2oVEZNktL4BH2JCOI90iD1yXwL6iNW7KcCKT2QZgQJR2vbqDsldCTPRU9NifTCqHZci57XvQQ15YTu+sTYPg==} + + layout-base@2.0.1: + resolution: {integrity: sha512-dp3s92+uNI1hWIpPGH3jK2kxE2lMjdXdr+DH8ynZHpd6PUlH6x6cbuXnoMmiNumznqaNO31xu9e79F0uuZ0JFg==} + + linkify-it@5.0.0: + resolution: {integrity: sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==} + + lodash-es@4.18.1: + resolution: {integrity: sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==} + + longest-streak@3.1.0: + resolution: {integrity: sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==} + + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + + mark.js@8.11.1: + resolution: {integrity: sha512-1I+1qpDt4idfgLQG+BNWmrqku+7/2bi5nLf4YwF8y8zXvmfiTBY3PV3ZibfrjBueCByROpuBjLLFCajqkgYoLQ==} + + markdown-it@14.1.1: + resolution: {integrity: sha512-BuU2qnTti9YKgK5N+IeMubp14ZUKUUw7yeJbkjtosvHiP0AZ5c8IAgEMk79D0eC8F23r4Ac/q8cAIFdm2FtyoA==} + hasBin: true + + markdown-title@1.0.2: + resolution: {integrity: sha512-MqIQVVkz+uGEHi3TsHx/czcxxCbRIL7sv5K5DnYw/tI+apY54IbPefV/cmgxp6LoJSEx/TqcHdLs/298afG5QQ==} + engines: {node: '>=6'} + + marked@16.4.2: + resolution: {integrity: sha512-TI3V8YYWvkVf3KJe1dRkpnjs68JUPyEa5vjKrp1XEEJUAOaQc+Qj+L1qWbPd0SJuAdQkFU0h73sXXqwDYxsiDA==} + engines: {node: '>= 20'} + hasBin: true + + mdast-util-from-markdown@2.0.3: + resolution: {integrity: sha512-W4mAWTvSlKvf8L6J+VN9yLSqQ9AOAAvHuoDAmPkz4dHf553m5gVj2ejadHJhoJmcmxEnOv6Pa8XJhpxE93kb8Q==} + + mdast-util-frontmatter@2.0.1: + resolution: {integrity: sha512-LRqI9+wdgC25P0URIJY9vwocIzCcksduHQ9OF2joxQoyTNVduwLAFUzjoopuRJbJAReaKrNQKAZKL3uCMugWJA==} + + mdast-util-phrasing@4.1.0: + resolution: {integrity: sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==} + + mdast-util-to-hast@13.2.1: + resolution: {integrity: sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==} + + mdast-util-to-markdown@2.1.2: + resolution: {integrity: sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA==} + + mdast-util-to-string@4.0.0: + resolution: {integrity: sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==} + + mdurl@2.0.0: + resolution: {integrity: sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==} + + mermaid@11.15.0: + resolution: {integrity: sha512-pTMbcf3rWdtLiYGpmoTjHEpeY8seiy6sR+9nD7LOs8KfUbHE4lOUAprTRqRAcWSQ6MQpdX+YEsxShtGsINtPtw==} + + micromark-core-commonmark@2.0.3: + resolution: {integrity: sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==} + + micromark-extension-frontmatter@2.0.0: + resolution: {integrity: sha512-C4AkuM3dA58cgZha7zVnuVxBhDsbttIMiytjgsM2XbHAB2faRVaHRle40558FBN+DJcrLNCoqG5mlrpdU4cRtg==} + + micromark-factory-destination@2.0.1: + resolution: {integrity: sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==} + + micromark-factory-label@2.0.1: + resolution: {integrity: sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg==} + + micromark-factory-space@2.0.1: + resolution: {integrity: sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg==} + + micromark-factory-title@2.0.1: + resolution: {integrity: sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw==} + + micromark-factory-whitespace@2.0.1: + resolution: {integrity: sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ==} + + micromark-util-character@2.1.1: + resolution: {integrity: sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==} + + micromark-util-chunked@2.0.1: + resolution: {integrity: sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA==} + + micromark-util-classify-character@2.0.1: + resolution: {integrity: sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q==} + + micromark-util-combine-extensions@2.0.1: + resolution: {integrity: sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg==} + + micromark-util-decode-numeric-character-reference@2.0.2: + resolution: {integrity: sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw==} + + micromark-util-decode-string@2.0.1: + resolution: {integrity: sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ==} + + micromark-util-encode@2.0.1: + resolution: {integrity: sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==} + + micromark-util-html-tag-name@2.0.1: + resolution: {integrity: sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA==} + + micromark-util-normalize-identifier@2.0.1: + resolution: {integrity: sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q==} + + micromark-util-resolve-all@2.0.1: + resolution: {integrity: sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg==} + + micromark-util-sanitize-uri@2.0.1: + resolution: {integrity: sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==} + + micromark-util-subtokenize@2.1.0: + resolution: {integrity: sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA==} + + micromark-util-symbol@2.0.1: + resolution: {integrity: sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==} + + micromark-util-types@2.0.2: + resolution: {integrity: sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==} + + micromark@4.0.2: + resolution: {integrity: sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA==} + + millify@6.1.0: + resolution: {integrity: sha512-H/E3J6t+DQs/F2YgfDhxUVZz/dF8JXPPKTLHL/yHCcLZLtCXJDUaqvhJXQwqOVBvbyNn4T0WjLpIHd7PAw7fBA==} + hasBin: true + + minimatch@10.2.5: + resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==} + engines: {node: 18 || 20 || >=22} + + minisearch@7.2.0: + resolution: {integrity: sha512-dqT2XBYUOZOiC5t2HRnwADjhNS2cecp9u+TJRiJ1Qp/f5qjkeT5APcGPjHw+bz89Ms8Jp+cG4AlE+QZ/QnDglg==} + + mitt@3.0.1: + resolution: {integrity: sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + nanoid@3.3.12: + resolution: {integrity: sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + non-layered-tidy-tree-layout@2.0.2: + resolution: {integrity: sha512-gkXMxRzUH+PB0ax9dUN0yYF0S25BqeAYqhgMaLUFmpXLEk7Fcu8f4emJuOAY0V8kjDICxROIKsTAKsV/v355xw==} + + oniguruma-to-es@3.1.1: + resolution: {integrity: sha512-bUH8SDvPkH3ho3dvwJwfonjlQ4R80vjyvrU8YpxuROddv55vAEJrTuCuCVUhhsHbtlD9tGGbaNApGQckXhS8iQ==} + + package-manager-detector@1.6.0: + resolution: {integrity: sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA==} + + path-data-parser@0.1.0: + resolution: {integrity: sha512-NOnmBpt5Y2RWbuv0LMzsayp3lVylAHLPUTut412ZA3l+C4uw4ZVkQbjShYCQ8TCpUMdPapr4YjUqLYD6v68j+w==} + + path-to-regexp@6.3.0: + resolution: {integrity: sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==} + + perfect-debounce@1.0.0: + resolution: {integrity: sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + points-on-curve@0.2.0: + resolution: {integrity: sha512-0mYKnYYe9ZcqMCWhUjItv/oHjvgEsfKvnUTg8sAtnHr3GVy7rGkXCb6d5cSyqrWqL4k81b9CPg3urd+T7aop3A==} + + points-on-path@0.2.1: + resolution: {integrity: sha512-25ClnWWuw7JbWZcgqY/gJ4FQWadKxGWk+3kR/7kD0tCaDtPPMj7oHu2ToLaVhfpnHrZzYby2w6tUA0eOIuUg8g==} + + postcss@8.5.15: + resolution: {integrity: sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==} + engines: {node: ^10 || ^12 || >=14} + + preact@10.29.2: + resolution: {integrity: sha512-7tNmwg/7mzzAoB/8kSg6Hl37JraAZw3Z3A0JSY7VXlZwo82Xn0G7wKbNNs2qoF4ZEEsQGTwDAroNdqKs1ofJxQ==} + + pretty-bytes@7.1.0: + resolution: {integrity: sha512-nODzvTiYVRGRqAOvE84Vk5JDPyyxsVk0/fbA/bq7RqlnhksGpset09XTxbpvLTIjoaF7K8Z8DG8yHtKGTPSYRw==} + engines: {node: '>=20'} + + property-information@7.1.0: + resolution: {integrity: sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==} + + punycode.js@2.3.1: + resolution: {integrity: sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==} + engines: {node: '>=6'} + + regex-recursion@6.0.2: + resolution: {integrity: sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg==} + + regex-utilities@2.3.0: + resolution: {integrity: sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng==} + + regex@6.1.0: + resolution: {integrity: sha512-6VwtthbV4o/7+OaAF9I5L5V3llLEsoPyq9P1JVXkedTP33c7MfCG0/5NOPcSJn0TzXcG9YUrR0gQSWioew3LDg==} + + remark-frontmatter@5.0.0: + resolution: {integrity: sha512-XTFYvNASMe5iPN0719nPrdItC9aU0ssC4v14mH1BCi1u0n1gAocqcujWUrByftZTbLhRtiKRyjYTSIOcr69UVQ==} + + remark-parse@11.0.0: + resolution: {integrity: sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA==} + + remark-stringify@11.0.0: + resolution: {integrity: sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==} + + remark@15.0.1: + resolution: {integrity: sha512-Eht5w30ruCXgFmxVUSlNWQ9iiimq07URKeFS3hNc8cUWy1llX4KDWfyEDZRycMc+znsN9Ux5/tJ/BFdgdOwA3A==} + + require-directory@2.1.1: + resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} + engines: {node: '>=0.10.0'} + + rfdc@1.4.1: + resolution: {integrity: sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==} + + robust-predicates@3.0.3: + resolution: {integrity: sha512-NS3levdsRIUOmiJ8FZWCP7LG3QpJyrs/TE0Zpf1yvZu8cAJJ6QMW92H1c7kWpdIHo8RvmLxN/o2JXTKHp74lUA==} + + rollup@4.60.4: + resolution: {integrity: sha512-WHeFSbZYsPu3+bLoNRUuAO+wavNlocOPf3wSHTP7hcFKVnJeWsYlCDbr3mTS14FCizf9ccIxXA8sGL8zKeQN3g==} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} + hasBin: true + + roughjs@4.6.6: + resolution: {integrity: sha512-ZUz/69+SYpFN/g/lUlo2FXcIjRkSu3nDarreVdGGndHEBJ6cXPdKguS8JGxwj5HA5xIbVKSmLgr5b3AWxtRfvQ==} + + rw@1.3.3: + resolution: {integrity: sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==} + + safer-buffer@2.1.2: + resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + + search-insights@2.17.3: + resolution: {integrity: sha512-RQPdCYTa8A68uM2jwxoY842xDhvx3E5LFL1LxvxCNMev4o5mLuokczhzjAgGwUZBAmOKZknArSxLKmXtIi2AxQ==} + + section-matter@1.0.0: + resolution: {integrity: sha512-vfD3pmTzGpufjScBh50YHKzEu2lxBWhVEHsNGoEXmCmn2hKGfeNLYMzCJpe8cD7gqX7TJluOVpBkAequ6dgMmA==} + engines: {node: '>=4'} + + shiki@2.5.0: + resolution: {integrity: sha512-mI//trrsaiCIPsja5CNfsyNOqgAZUb6VpJA+340toL42UpzQlXpwRV9nch69X6gaUxrr9kaOOa6e3y3uAkGFxQ==} + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + space-separated-tokens@2.0.2: + resolution: {integrity: sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==} + + speakingurl@14.0.1: + resolution: {integrity: sha512-1POYv7uv2gXoyGFpBCmpDVSNV74IfsWlDW216UPjbWufNf+bSU6GdbDsxdcxtfwb4xlI3yxzOTKClUosxARYrQ==} + engines: {node: '>=0.10.0'} + + sprintf-js@1.0.3: + resolution: {integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==} + + string-width@4.2.3: + resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} + engines: {node: '>=8'} + + stringify-entities@4.0.4: + resolution: {integrity: sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==} + + strip-ansi@6.0.1: + resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} + engines: {node: '>=8'} + + strip-bom-string@1.0.0: + resolution: {integrity: sha512-uCC2VHvQRYu+lMh4My/sFNmF2klFymLX1wHJeXnbEJERpV/ZsVuonzerjfrGpIGF7LBVa1O7i9kjiWvJiFck8g==} + engines: {node: '>=0.10.0'} + + stylis@4.4.0: + resolution: {integrity: sha512-5Z9ZpRzfuH6l/UAvCPAPUo3665Nk2wLaZU3x+TLHKVzIz33+sbJqbtrYoC3KD4/uVOr2Zp+L0LySezP9OHV9yA==} + + superjson@2.2.6: + resolution: {integrity: sha512-H+ue8Zo4vJmV2nRjpx86P35lzwDT3nItnIsocgumgr0hHMQ+ZGq5vrERg9kJBo5AWGmxZDhzDo+WVIJqkB0cGA==} + engines: {node: '>=16'} + + tabbable@6.4.0: + resolution: {integrity: sha512-05PUHKSNE8ou2dwIxTngl4EzcnsCDZGJ/iCLtDflR/SHB/ny14rXc+qU5P4mG9JkusiV7EivzY9Mhm55AzAvCg==} + + tinyexec@1.1.2: + resolution: {integrity: sha512-dAqSqE/RabpBKI8+h26GfLq6Vb3JVXs30XYQjdMjaj/c2tS8IYYMbIzP599KtRj7c57/wYApb3QjgRgXmrCukA==} + engines: {node: '>=18'} + + tokenx@1.3.0: + resolution: {integrity: sha512-NLdXTEZkKiO0gZuLtMoZKjCXTREXeZZt8nnnNeyoXtNZAfG/GKGSbQtLU5STspc0rMSwcA+UJfWZkbNU01iKmQ==} + + trim-lines@3.0.1: + resolution: {integrity: sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==} + + trough@2.2.0: + resolution: {integrity: sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==} + + ts-dedent@2.2.0: + resolution: {integrity: sha512-q5W7tVM71e2xjHZTlgfTDoPF/SmqKG5hddq9SzR49CH2hayqRKJtQ4mtRlSxKaJlR/+9rEM+mnBHf7I2/BQcpQ==} + engines: {node: '>=6.10'} + + uc.micro@2.1.0: + resolution: {integrity: sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==} + + unified@11.0.5: + resolution: {integrity: sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==} + + unist-util-is@6.0.1: + resolution: {integrity: sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==} + + unist-util-position@5.0.0: + resolution: {integrity: sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==} + + unist-util-remove@4.0.0: + resolution: {integrity: sha512-b4gokeGId57UVRX/eVKej5gXqGlc9+trkORhFJpu9raqZkZhU0zm8Doi05+HaiBsMEIJowL+2WtQ5ItjsngPXg==} + + unist-util-stringify-position@4.0.0: + resolution: {integrity: sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==} + + unist-util-visit-parents@6.0.2: + resolution: {integrity: sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==} + + unist-util-visit@5.1.0: + resolution: {integrity: sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==} + + uuid@14.0.0: + resolution: {integrity: sha512-Qo+uWgilfSmAhXCMav1uYFynlQO7fMFiMVZsQqZRMIXp0O7rR7qjkj+cPvBHLgBqi960QCoo/PH2/6ZtVqKvrg==} + hasBin: true + + vfile-message@4.0.3: + resolution: {integrity: sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==} + + vfile@6.0.3: + resolution: {integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==} + + vite@5.4.21: + resolution: {integrity: sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==} + engines: {node: ^18.0.0 || >=20.0.0} + hasBin: true + peerDependencies: + '@types/node': ^18.0.0 || >=20.0.0 + less: '*' + lightningcss: ^1.21.0 + sass: '*' + sass-embedded: '*' + stylus: '*' + sugarss: '*' + terser: ^5.4.0 + peerDependenciesMeta: + '@types/node': + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + + vitepress-plugin-llms@1.12.2: + resolution: {integrity: sha512-hdklo7di6E2/MwlYstH7R5QgdPHX+f5G/fp8QBq6MCiw4DWi3IOKMaous0S839FIGsPjK3QHwK6KcFwCf/XzjA==} + engines: {node: '>=18.0.0'} + + vitepress-plugin-mermaid@2.0.17: + resolution: {integrity: sha512-IUzYpwf61GC6k0XzfmAmNrLvMi9TRrVRMsUyCA8KNXhg/mQ1VqWnO0/tBVPiX5UoKF1mDUwqn5QV4qAJl6JnUg==} + peerDependencies: + mermaid: 10 || 11 + vitepress: ^1.0.0 || ^1.0.0-alpha + + vitepress@1.6.4: + resolution: {integrity: sha512-+2ym1/+0VVrbhNyRoFFesVvBvHAVMZMK0rw60E3X/5349M1GuVdKeazuksqopEdvkKwKGs21Q729jX81/bkBJg==} + hasBin: true + peerDependencies: + markdown-it-mathjax3: ^4 + postcss: ^8 + peerDependenciesMeta: + markdown-it-mathjax3: + optional: true + postcss: + optional: true + + vue@3.5.34: + resolution: {integrity: sha512-WdLBG9gm02OgJIG9axd5Hpx0TFLdzVgfG2evFFu8Rur5O/IoGc5cMjnjh3tPL6GnRGsYvUhBSKVPYVcxRKpMCA==} + peerDependencies: + typescript: '*' + peerDependenciesMeta: + typescript: + optional: true + + wrap-ansi@7.0.0: + resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} + engines: {node: '>=10'} + + y18n@5.0.8: + resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} + engines: {node: '>=10'} + + yargs-parser@21.1.1: + resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} + engines: {node: '>=12'} + + yargs@17.7.2: + resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==} + engines: {node: '>=12'} + + zwitch@2.0.4: + resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==} + +snapshots: + + '@algolia/abtesting@1.18.1': + dependencies: + '@algolia/client-common': 5.52.1 + '@algolia/requester-browser-xhr': 5.52.1 + '@algolia/requester-fetch': 5.52.1 + '@algolia/requester-node-http': 5.52.1 + + '@algolia/autocomplete-core@1.17.7(@algolia/client-search@5.52.1)(algoliasearch@5.52.1)(search-insights@2.17.3)': + dependencies: + '@algolia/autocomplete-plugin-algolia-insights': 1.17.7(@algolia/client-search@5.52.1)(algoliasearch@5.52.1)(search-insights@2.17.3) + '@algolia/autocomplete-shared': 1.17.7(@algolia/client-search@5.52.1)(algoliasearch@5.52.1) + transitivePeerDependencies: + - '@algolia/client-search' + - algoliasearch + - search-insights + + '@algolia/autocomplete-plugin-algolia-insights@1.17.7(@algolia/client-search@5.52.1)(algoliasearch@5.52.1)(search-insights@2.17.3)': + dependencies: + '@algolia/autocomplete-shared': 1.17.7(@algolia/client-search@5.52.1)(algoliasearch@5.52.1) + search-insights: 2.17.3 + transitivePeerDependencies: + - '@algolia/client-search' + - algoliasearch + + '@algolia/autocomplete-preset-algolia@1.17.7(@algolia/client-search@5.52.1)(algoliasearch@5.52.1)': + dependencies: + '@algolia/autocomplete-shared': 1.17.7(@algolia/client-search@5.52.1)(algoliasearch@5.52.1) + '@algolia/client-search': 5.52.1 + algoliasearch: 5.52.1 + + '@algolia/autocomplete-shared@1.17.7(@algolia/client-search@5.52.1)(algoliasearch@5.52.1)': + dependencies: + '@algolia/client-search': 5.52.1 + algoliasearch: 5.52.1 + + '@algolia/client-abtesting@5.52.1': + dependencies: + '@algolia/client-common': 5.52.1 + '@algolia/requester-browser-xhr': 5.52.1 + '@algolia/requester-fetch': 5.52.1 + '@algolia/requester-node-http': 5.52.1 + + '@algolia/client-analytics@5.52.1': + dependencies: + '@algolia/client-common': 5.52.1 + '@algolia/requester-browser-xhr': 5.52.1 + '@algolia/requester-fetch': 5.52.1 + '@algolia/requester-node-http': 5.52.1 + + '@algolia/client-common@5.52.1': {} + + '@algolia/client-insights@5.52.1': + dependencies: + '@algolia/client-common': 5.52.1 + '@algolia/requester-browser-xhr': 5.52.1 + '@algolia/requester-fetch': 5.52.1 + '@algolia/requester-node-http': 5.52.1 + + '@algolia/client-personalization@5.52.1': + dependencies: + '@algolia/client-common': 5.52.1 + '@algolia/requester-browser-xhr': 5.52.1 + '@algolia/requester-fetch': 5.52.1 + '@algolia/requester-node-http': 5.52.1 + + '@algolia/client-query-suggestions@5.52.1': + dependencies: + '@algolia/client-common': 5.52.1 + '@algolia/requester-browser-xhr': 5.52.1 + '@algolia/requester-fetch': 5.52.1 + '@algolia/requester-node-http': 5.52.1 + + '@algolia/client-search@5.52.1': + dependencies: + '@algolia/client-common': 5.52.1 + '@algolia/requester-browser-xhr': 5.52.1 + '@algolia/requester-fetch': 5.52.1 + '@algolia/requester-node-http': 5.52.1 + + '@algolia/ingestion@1.52.1': + dependencies: + '@algolia/client-common': 5.52.1 + '@algolia/requester-browser-xhr': 5.52.1 + '@algolia/requester-fetch': 5.52.1 + '@algolia/requester-node-http': 5.52.1 + + '@algolia/monitoring@1.52.1': + dependencies: + '@algolia/client-common': 5.52.1 + '@algolia/requester-browser-xhr': 5.52.1 + '@algolia/requester-fetch': 5.52.1 + '@algolia/requester-node-http': 5.52.1 + + '@algolia/recommend@5.52.1': + dependencies: + '@algolia/client-common': 5.52.1 + '@algolia/requester-browser-xhr': 5.52.1 + '@algolia/requester-fetch': 5.52.1 + '@algolia/requester-node-http': 5.52.1 + + '@algolia/requester-browser-xhr@5.52.1': + dependencies: + '@algolia/client-common': 5.52.1 + + '@algolia/requester-fetch@5.52.1': + dependencies: + '@algolia/client-common': 5.52.1 + + '@algolia/requester-node-http@5.52.1': + dependencies: + '@algolia/client-common': 5.52.1 + + '@antfu/install-pkg@1.1.0': + dependencies: + package-manager-detector: 1.6.0 + tinyexec: 1.1.2 + + '@babel/helper-string-parser@7.27.1': {} + + '@babel/helper-validator-identifier@7.28.5': {} + + '@babel/parser@7.29.3': + dependencies: + '@babel/types': 7.29.0 + + '@babel/types@7.29.0': + dependencies: + '@babel/helper-string-parser': 7.27.1 + '@babel/helper-validator-identifier': 7.28.5 + + '@braintree/sanitize-url@6.0.4': + optional: true + + '@braintree/sanitize-url@7.1.2': {} + + '@chevrotain/types@11.1.2': {} + + '@docsearch/css@3.8.2': {} + + '@docsearch/js@3.8.2(@algolia/client-search@5.52.1)(search-insights@2.17.3)': + dependencies: + '@docsearch/react': 3.8.2(@algolia/client-search@5.52.1)(search-insights@2.17.3) + preact: 10.29.2 + transitivePeerDependencies: + - '@algolia/client-search' + - '@types/react' + - react + - react-dom + - search-insights + + '@docsearch/react@3.8.2(@algolia/client-search@5.52.1)(search-insights@2.17.3)': + dependencies: + '@algolia/autocomplete-core': 1.17.7(@algolia/client-search@5.52.1)(algoliasearch@5.52.1)(search-insights@2.17.3) + '@algolia/autocomplete-preset-algolia': 1.17.7(@algolia/client-search@5.52.1)(algoliasearch@5.52.1) + '@docsearch/css': 3.8.2 + algoliasearch: 5.52.1 + optionalDependencies: + search-insights: 2.17.3 + transitivePeerDependencies: + - '@algolia/client-search' + + '@esbuild/aix-ppc64@0.21.5': + optional: true + + '@esbuild/android-arm64@0.21.5': + optional: true + + '@esbuild/android-arm@0.21.5': + optional: true + + '@esbuild/android-x64@0.21.5': + optional: true + + '@esbuild/darwin-arm64@0.21.5': + optional: true + + '@esbuild/darwin-x64@0.21.5': + optional: true + + '@esbuild/freebsd-arm64@0.21.5': + optional: true + + '@esbuild/freebsd-x64@0.21.5': + optional: true + + '@esbuild/linux-arm64@0.21.5': + optional: true + + '@esbuild/linux-arm@0.21.5': + optional: true + + '@esbuild/linux-ia32@0.21.5': + optional: true + + '@esbuild/linux-loong64@0.21.5': + optional: true + + '@esbuild/linux-mips64el@0.21.5': + optional: true + + '@esbuild/linux-ppc64@0.21.5': + optional: true + + '@esbuild/linux-riscv64@0.21.5': + optional: true + + '@esbuild/linux-s390x@0.21.5': + optional: true + + '@esbuild/linux-x64@0.21.5': + optional: true + + '@esbuild/netbsd-x64@0.21.5': + optional: true + + '@esbuild/openbsd-x64@0.21.5': + optional: true + + '@esbuild/sunos-x64@0.21.5': + optional: true + + '@esbuild/win32-arm64@0.21.5': + optional: true + + '@esbuild/win32-ia32@0.21.5': + optional: true + + '@esbuild/win32-x64@0.21.5': + optional: true + + '@iconify-json/simple-icons@1.2.83': + dependencies: + '@iconify/types': 2.0.0 + + '@iconify/types@2.0.0': {} + + '@iconify/utils@3.1.3': + dependencies: + '@antfu/install-pkg': 1.1.0 + '@iconify/types': 2.0.0 + import-meta-resolve: 4.2.0 + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@mermaid-js/mermaid-mindmap@9.3.0': + dependencies: + '@braintree/sanitize-url': 6.0.4 + cytoscape: 3.33.4 + cytoscape-cose-bilkent: 4.1.0(cytoscape@3.33.4) + cytoscape-fcose: 2.2.0(cytoscape@3.33.4) + d3: 7.9.0 + khroma: 2.1.0 + non-layered-tidy-tree-layout: 2.0.2 + optional: true + + '@mermaid-js/parser@1.1.1': + dependencies: + '@chevrotain/types': 11.1.2 + + '@rollup/rollup-android-arm-eabi@4.60.4': + optional: true + + '@rollup/rollup-android-arm64@4.60.4': + optional: true + + '@rollup/rollup-darwin-arm64@4.60.4': + optional: true + + '@rollup/rollup-darwin-x64@4.60.4': + optional: true + + '@rollup/rollup-freebsd-arm64@4.60.4': + optional: true + + '@rollup/rollup-freebsd-x64@4.60.4': + optional: true + + '@rollup/rollup-linux-arm-gnueabihf@4.60.4': + optional: true + + '@rollup/rollup-linux-arm-musleabihf@4.60.4': + optional: true + + '@rollup/rollup-linux-arm64-gnu@4.60.4': + optional: true + + '@rollup/rollup-linux-arm64-musl@4.60.4': + optional: true + + '@rollup/rollup-linux-loong64-gnu@4.60.4': + optional: true + + '@rollup/rollup-linux-loong64-musl@4.60.4': + optional: true + + '@rollup/rollup-linux-ppc64-gnu@4.60.4': + optional: true + + '@rollup/rollup-linux-ppc64-musl@4.60.4': + optional: true + + '@rollup/rollup-linux-riscv64-gnu@4.60.4': + optional: true + + '@rollup/rollup-linux-riscv64-musl@4.60.4': + optional: true + + '@rollup/rollup-linux-s390x-gnu@4.60.4': + optional: true + + '@rollup/rollup-linux-x64-gnu@4.60.4': + optional: true + + '@rollup/rollup-linux-x64-musl@4.60.4': + optional: true + + '@rollup/rollup-openbsd-x64@4.60.4': + optional: true + + '@rollup/rollup-openharmony-arm64@4.60.4': + optional: true + + '@rollup/rollup-win32-arm64-msvc@4.60.4': + optional: true + + '@rollup/rollup-win32-ia32-msvc@4.60.4': + optional: true + + '@rollup/rollup-win32-x64-gnu@4.60.4': + optional: true + + '@rollup/rollup-win32-x64-msvc@4.60.4': + optional: true + + '@shikijs/core@2.5.0': + dependencies: + '@shikijs/engine-javascript': 2.5.0 + '@shikijs/engine-oniguruma': 2.5.0 + '@shikijs/types': 2.5.0 + '@shikijs/vscode-textmate': 10.0.2 + '@types/hast': 3.0.4 + hast-util-to-html: 9.0.5 + + '@shikijs/engine-javascript@2.5.0': + dependencies: + '@shikijs/types': 2.5.0 + '@shikijs/vscode-textmate': 10.0.2 + oniguruma-to-es: 3.1.1 + + '@shikijs/engine-oniguruma@2.5.0': + dependencies: + '@shikijs/types': 2.5.0 + '@shikijs/vscode-textmate': 10.0.2 + + '@shikijs/langs@2.5.0': + dependencies: + '@shikijs/types': 2.5.0 + + '@shikijs/themes@2.5.0': + dependencies: + '@shikijs/types': 2.5.0 + + '@shikijs/transformers@2.5.0': + dependencies: + '@shikijs/core': 2.5.0 + '@shikijs/types': 2.5.0 + + '@shikijs/types@2.5.0': + dependencies: + '@shikijs/vscode-textmate': 10.0.2 + '@types/hast': 3.0.4 + + '@shikijs/vscode-textmate@10.0.2': {} + + '@types/d3-array@3.2.2': {} + + '@types/d3-axis@3.0.6': + dependencies: + '@types/d3-selection': 3.0.11 + + '@types/d3-brush@3.0.6': + dependencies: + '@types/d3-selection': 3.0.11 + + '@types/d3-chord@3.0.6': {} + + '@types/d3-color@3.1.3': {} + + '@types/d3-contour@3.0.6': + dependencies: + '@types/d3-array': 3.2.2 + '@types/geojson': 7946.0.16 + + '@types/d3-delaunay@6.0.4': {} + + '@types/d3-dispatch@3.0.7': {} + + '@types/d3-drag@3.0.7': + dependencies: + '@types/d3-selection': 3.0.11 + + '@types/d3-dsv@3.0.7': {} + + '@types/d3-ease@3.0.2': {} + + '@types/d3-fetch@3.0.7': + dependencies: + '@types/d3-dsv': 3.0.7 + + '@types/d3-force@3.0.10': {} + + '@types/d3-format@3.0.4': {} + + '@types/d3-geo@3.1.0': + dependencies: + '@types/geojson': 7946.0.16 + + '@types/d3-hierarchy@3.1.7': {} + + '@types/d3-interpolate@3.0.4': + dependencies: + '@types/d3-color': 3.1.3 + + '@types/d3-path@3.1.1': {} + + '@types/d3-polygon@3.0.2': {} + + '@types/d3-quadtree@3.0.6': {} + + '@types/d3-random@3.0.3': {} + + '@types/d3-scale-chromatic@3.1.0': {} + + '@types/d3-scale@4.0.9': + dependencies: + '@types/d3-time': 3.0.4 + + '@types/d3-selection@3.0.11': {} + + '@types/d3-shape@3.1.8': + dependencies: + '@types/d3-path': 3.1.1 + + '@types/d3-time-format@4.0.3': {} + + '@types/d3-time@3.0.4': {} + + '@types/d3-timer@3.0.2': {} + + '@types/d3-transition@3.0.9': + dependencies: + '@types/d3-selection': 3.0.11 + + '@types/d3-zoom@3.0.8': + dependencies: + '@types/d3-interpolate': 3.0.4 + '@types/d3-selection': 3.0.11 + + '@types/d3@7.4.3': + dependencies: + '@types/d3-array': 3.2.2 + '@types/d3-axis': 3.0.6 + '@types/d3-brush': 3.0.6 + '@types/d3-chord': 3.0.6 + '@types/d3-color': 3.1.3 + '@types/d3-contour': 3.0.6 + '@types/d3-delaunay': 6.0.4 + '@types/d3-dispatch': 3.0.7 + '@types/d3-drag': 3.0.7 + '@types/d3-dsv': 3.0.7 + '@types/d3-ease': 3.0.2 + '@types/d3-fetch': 3.0.7 + '@types/d3-force': 3.0.10 + '@types/d3-format': 3.0.4 + '@types/d3-geo': 3.1.0 + '@types/d3-hierarchy': 3.1.7 + '@types/d3-interpolate': 3.0.4 + '@types/d3-path': 3.1.1 + '@types/d3-polygon': 3.0.2 + '@types/d3-quadtree': 3.0.6 + '@types/d3-random': 3.0.3 + '@types/d3-scale': 4.0.9 + '@types/d3-scale-chromatic': 3.1.0 + '@types/d3-selection': 3.0.11 + '@types/d3-shape': 3.1.8 + '@types/d3-time': 3.0.4 + '@types/d3-time-format': 4.0.3 + '@types/d3-timer': 3.0.2 + '@types/d3-transition': 3.0.9 + '@types/d3-zoom': 3.0.8 + + '@types/debug@4.1.13': + dependencies: + '@types/ms': 2.1.0 + + '@types/estree@1.0.8': {} + + '@types/geojson@7946.0.16': {} + + '@types/hast@3.0.4': + dependencies: + '@types/unist': 3.0.3 + + '@types/linkify-it@5.0.0': {} + + '@types/markdown-it@14.1.2': + dependencies: + '@types/linkify-it': 5.0.0 + '@types/mdurl': 2.0.0 + + '@types/mdast@4.0.4': + dependencies: + '@types/unist': 3.0.3 + + '@types/mdurl@2.0.0': {} + + '@types/ms@2.1.0': {} + + '@types/trusted-types@2.0.7': + optional: true + + '@types/unist@3.0.3': {} + + '@types/web-bluetooth@0.0.21': {} + + '@ungap/structured-clone@1.3.1': {} + + '@upsetjs/venn.js@2.0.0': + optionalDependencies: + d3-selection: 3.0.0 + d3-transition: 3.0.1(d3-selection@3.0.0) + + '@vitejs/plugin-vue@5.2.4(vite@5.4.21)(vue@3.5.34)': + dependencies: + vite: 5.4.21 + vue: 3.5.34 + + '@vue/compiler-core@3.5.34': + dependencies: + '@babel/parser': 7.29.3 + '@vue/shared': 3.5.34 + entities: 7.0.1 + estree-walker: 2.0.2 + source-map-js: 1.2.1 + + '@vue/compiler-dom@3.5.34': + dependencies: + '@vue/compiler-core': 3.5.34 + '@vue/shared': 3.5.34 + + '@vue/compiler-sfc@3.5.34': + dependencies: + '@babel/parser': 7.29.3 + '@vue/compiler-core': 3.5.34 + '@vue/compiler-dom': 3.5.34 + '@vue/compiler-ssr': 3.5.34 + '@vue/shared': 3.5.34 + estree-walker: 2.0.2 + magic-string: 0.30.21 + postcss: 8.5.15 + source-map-js: 1.2.1 + + '@vue/compiler-ssr@3.5.34': + dependencies: + '@vue/compiler-dom': 3.5.34 + '@vue/shared': 3.5.34 + + '@vue/devtools-api@7.7.9': + dependencies: + '@vue/devtools-kit': 7.7.9 + + '@vue/devtools-kit@7.7.9': + dependencies: + '@vue/devtools-shared': 7.7.9 + birpc: 2.9.0 + hookable: 5.5.3 + mitt: 3.0.1 + perfect-debounce: 1.0.0 + speakingurl: 14.0.1 + superjson: 2.2.6 + + '@vue/devtools-shared@7.7.9': + dependencies: + rfdc: 1.4.1 + + '@vue/reactivity@3.5.34': + dependencies: + '@vue/shared': 3.5.34 + + '@vue/runtime-core@3.5.34': + dependencies: + '@vue/reactivity': 3.5.34 + '@vue/shared': 3.5.34 + + '@vue/runtime-dom@3.5.34': + dependencies: + '@vue/reactivity': 3.5.34 + '@vue/runtime-core': 3.5.34 + '@vue/shared': 3.5.34 + csstype: 3.2.3 + + '@vue/server-renderer@3.5.34(vue@3.5.34)': + dependencies: + '@vue/compiler-ssr': 3.5.34 + '@vue/shared': 3.5.34 + vue: 3.5.34 + + '@vue/shared@3.5.34': {} + + '@vueuse/core@12.8.2': + dependencies: + '@types/web-bluetooth': 0.0.21 + '@vueuse/metadata': 12.8.2 + '@vueuse/shared': 12.8.2 + vue: 3.5.34 + transitivePeerDependencies: + - typescript + + '@vueuse/integrations@12.8.2(focus-trap@7.8.0)': + dependencies: + '@vueuse/core': 12.8.2 + '@vueuse/shared': 12.8.2 + vue: 3.5.34 + optionalDependencies: + focus-trap: 7.8.0 + transitivePeerDependencies: + - typescript + + '@vueuse/metadata@12.8.2': {} + + '@vueuse/shared@12.8.2': + dependencies: + vue: 3.5.34 + transitivePeerDependencies: + - typescript + + algoliasearch@5.52.1: + dependencies: + '@algolia/abtesting': 1.18.1 + '@algolia/client-abtesting': 5.52.1 + '@algolia/client-analytics': 5.52.1 + '@algolia/client-common': 5.52.1 + '@algolia/client-insights': 5.52.1 + '@algolia/client-personalization': 5.52.1 + '@algolia/client-query-suggestions': 5.52.1 + '@algolia/client-search': 5.52.1 + '@algolia/ingestion': 1.52.1 + '@algolia/monitoring': 1.52.1 + '@algolia/recommend': 5.52.1 + '@algolia/requester-browser-xhr': 5.52.1 + '@algolia/requester-fetch': 5.52.1 + '@algolia/requester-node-http': 5.52.1 + + ansi-regex@5.0.1: {} + + ansi-styles@4.3.0: + dependencies: + color-convert: 2.0.1 + + argparse@1.0.10: + dependencies: + sprintf-js: 1.0.3 + + argparse@2.0.1: {} + + bail@2.0.2: {} + + balanced-match@4.0.4: {} + + birpc@2.9.0: {} + + brace-expansion@5.0.6: + dependencies: + balanced-match: 4.0.4 + + ccount@2.0.1: {} + + character-entities-html4@2.1.0: {} + + character-entities-legacy@3.0.0: {} + + character-entities@2.0.2: {} + + cliui@8.0.1: + dependencies: + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrap-ansi: 7.0.0 + + color-convert@2.0.1: + dependencies: + color-name: 1.1.4 + + color-name@1.1.4: {} + + comma-separated-tokens@2.0.3: {} + + commander@7.2.0: {} + + commander@8.3.0: {} + + copy-anything@4.0.5: + dependencies: + is-what: 5.5.0 + + cose-base@1.0.3: + dependencies: + layout-base: 1.0.2 + + cose-base@2.2.0: + dependencies: + layout-base: 2.0.1 + + csstype@3.2.3: {} + + cytoscape-cose-bilkent@4.1.0(cytoscape@3.33.4): + dependencies: + cose-base: 1.0.3 + cytoscape: 3.33.4 + + cytoscape-fcose@2.2.0(cytoscape@3.33.4): + dependencies: + cose-base: 2.2.0 + cytoscape: 3.33.4 + + cytoscape@3.33.4: {} + + d3-array@2.12.1: + dependencies: + internmap: 1.0.1 + + d3-array@3.2.4: + dependencies: + internmap: 2.0.3 + + d3-axis@3.0.0: {} + + d3-brush@3.0.0: + dependencies: + d3-dispatch: 3.0.1 + d3-drag: 3.0.0 + d3-interpolate: 3.0.1 + d3-selection: 3.0.0 + d3-transition: 3.0.1(d3-selection@3.0.0) + + d3-chord@3.0.1: + dependencies: + d3-path: 3.1.0 + + d3-color@3.1.0: {} + + d3-contour@4.0.2: + dependencies: + d3-array: 3.2.4 + + d3-delaunay@6.0.4: + dependencies: + delaunator: 5.1.0 + + d3-dispatch@3.0.1: {} + + d3-drag@3.0.0: + dependencies: + d3-dispatch: 3.0.1 + d3-selection: 3.0.0 + + d3-dsv@3.0.1: + dependencies: + commander: 7.2.0 + iconv-lite: 0.6.3 + rw: 1.3.3 + + d3-ease@3.0.1: {} + + d3-fetch@3.0.1: + dependencies: + d3-dsv: 3.0.1 + + d3-force@3.0.0: + dependencies: + d3-dispatch: 3.0.1 + d3-quadtree: 3.0.1 + d3-timer: 3.0.1 + + d3-format@3.1.2: {} + + d3-geo@3.1.1: + dependencies: + d3-array: 3.2.4 + + d3-hierarchy@3.1.2: {} + + d3-interpolate@3.0.1: + dependencies: + d3-color: 3.1.0 + + d3-path@1.0.9: {} + + d3-path@3.1.0: {} + + d3-polygon@3.0.1: {} + + d3-quadtree@3.0.1: {} + + d3-random@3.0.1: {} + + d3-sankey@0.12.3: + dependencies: + d3-array: 2.12.1 + d3-shape: 1.3.7 + + d3-scale-chromatic@3.1.0: + dependencies: + d3-color: 3.1.0 + d3-interpolate: 3.0.1 + + d3-scale@4.0.2: + dependencies: + d3-array: 3.2.4 + d3-format: 3.1.2 + d3-interpolate: 3.0.1 + d3-time: 3.1.0 + d3-time-format: 4.1.0 + + d3-selection@3.0.0: {} + + d3-shape@1.3.7: + dependencies: + d3-path: 1.0.9 + + d3-shape@3.2.0: + dependencies: + d3-path: 3.1.0 + + d3-time-format@4.1.0: + dependencies: + d3-time: 3.1.0 + + d3-time@3.1.0: + dependencies: + d3-array: 3.2.4 + + d3-timer@3.0.1: {} + + d3-transition@3.0.1(d3-selection@3.0.0): + dependencies: + d3-color: 3.1.0 + d3-dispatch: 3.0.1 + d3-ease: 3.0.1 + d3-interpolate: 3.0.1 + d3-selection: 3.0.0 + d3-timer: 3.0.1 + + d3-zoom@3.0.0: + dependencies: + d3-dispatch: 3.0.1 + d3-drag: 3.0.0 + d3-interpolate: 3.0.1 + d3-selection: 3.0.0 + d3-transition: 3.0.1(d3-selection@3.0.0) + + d3@7.9.0: + dependencies: + d3-array: 3.2.4 + d3-axis: 3.0.0 + d3-brush: 3.0.0 + d3-chord: 3.0.1 + d3-color: 3.1.0 + d3-contour: 4.0.2 + d3-delaunay: 6.0.4 + d3-dispatch: 3.0.1 + d3-drag: 3.0.0 + d3-dsv: 3.0.1 + d3-ease: 3.0.1 + d3-fetch: 3.0.1 + d3-force: 3.0.0 + d3-format: 3.1.2 + d3-geo: 3.1.1 + d3-hierarchy: 3.1.2 + d3-interpolate: 3.0.1 + d3-path: 3.1.0 + d3-polygon: 3.0.1 + d3-quadtree: 3.0.1 + d3-random: 3.0.1 + d3-scale: 4.0.2 + d3-scale-chromatic: 3.1.0 + d3-selection: 3.0.0 + d3-shape: 3.2.0 + d3-time: 3.1.0 + d3-time-format: 4.1.0 + d3-timer: 3.0.1 + d3-transition: 3.0.1(d3-selection@3.0.0) + d3-zoom: 3.0.0 + + dagre-d3-es@7.0.14: + dependencies: + d3: 7.9.0 + lodash-es: 4.18.1 + + dayjs@1.11.20: {} + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + decode-named-character-reference@1.3.0: + dependencies: + character-entities: 2.0.2 + + delaunator@5.1.0: + dependencies: + robust-predicates: 3.0.3 + + dequal@2.0.3: {} + + devlop@1.1.0: + dependencies: + dequal: 2.0.3 + + dompurify@3.4.5: + optionalDependencies: + '@types/trusted-types': 2.0.7 + + emoji-regex-xs@1.0.0: {} + + emoji-regex@8.0.0: {} + + entities@4.5.0: {} + + entities@7.0.1: {} + + es-toolkit@1.46.1: {} + + esbuild@0.21.5: + optionalDependencies: + '@esbuild/aix-ppc64': 0.21.5 + '@esbuild/android-arm': 0.21.5 + '@esbuild/android-arm64': 0.21.5 + '@esbuild/android-x64': 0.21.5 + '@esbuild/darwin-arm64': 0.21.5 + '@esbuild/darwin-x64': 0.21.5 + '@esbuild/freebsd-arm64': 0.21.5 + '@esbuild/freebsd-x64': 0.21.5 + '@esbuild/linux-arm': 0.21.5 + '@esbuild/linux-arm64': 0.21.5 + '@esbuild/linux-ia32': 0.21.5 + '@esbuild/linux-loong64': 0.21.5 + '@esbuild/linux-mips64el': 0.21.5 + '@esbuild/linux-ppc64': 0.21.5 + '@esbuild/linux-riscv64': 0.21.5 + '@esbuild/linux-s390x': 0.21.5 + '@esbuild/linux-x64': 0.21.5 + '@esbuild/netbsd-x64': 0.21.5 + '@esbuild/openbsd-x64': 0.21.5 + '@esbuild/sunos-x64': 0.21.5 + '@esbuild/win32-arm64': 0.21.5 + '@esbuild/win32-ia32': 0.21.5 + '@esbuild/win32-x64': 0.21.5 + + escalade@3.2.0: {} + + escape-string-regexp@5.0.0: {} + + esprima@4.0.1: {} + + estree-walker@2.0.2: {} + + extend-shallow@2.0.1: + dependencies: + is-extendable: 0.1.1 + + extend@3.0.2: {} + + fault@2.0.1: + dependencies: + format: 0.2.2 + + focus-trap@7.8.0: + dependencies: + tabbable: 6.4.0 + + format@0.2.2: {} + + fsevents@2.3.3: + optional: true + + get-caller-file@2.0.5: {} + + gray-matter@4.0.3: + dependencies: + js-yaml: 3.14.2 + kind-of: 6.0.3 + section-matter: 1.0.0 + strip-bom-string: 1.0.0 + + hachure-fill@0.5.2: {} + + hast-util-to-html@9.0.5: + dependencies: + '@types/hast': 3.0.4 + '@types/unist': 3.0.3 + ccount: 2.0.1 + comma-separated-tokens: 2.0.3 + hast-util-whitespace: 3.0.0 + html-void-elements: 3.0.0 + mdast-util-to-hast: 13.2.1 + property-information: 7.1.0 + space-separated-tokens: 2.0.2 + stringify-entities: 4.0.4 + zwitch: 2.0.4 + + hast-util-whitespace@3.0.0: + dependencies: + '@types/hast': 3.0.4 + + hookable@5.5.3: {} + + html-void-elements@3.0.0: {} + + iconv-lite@0.6.3: + dependencies: + safer-buffer: 2.1.2 + + import-meta-resolve@4.2.0: {} + + internmap@1.0.1: {} + + internmap@2.0.3: {} + + is-extendable@0.1.1: {} + + is-fullwidth-code-point@3.0.0: {} + + is-plain-obj@4.1.0: {} + + is-what@5.5.0: {} + + js-yaml@3.14.2: + dependencies: + argparse: 1.0.10 + esprima: 4.0.1 + + katex@0.16.47: + dependencies: + commander: 8.3.0 + + khroma@2.1.0: {} + + kind-of@6.0.3: {} + + layout-base@1.0.2: {} + + layout-base@2.0.1: {} + + linkify-it@5.0.0: + dependencies: + uc.micro: 2.1.0 + + lodash-es@4.18.1: {} + + longest-streak@3.1.0: {} + + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + + mark.js@8.11.1: {} + + markdown-it@14.1.1: + dependencies: + argparse: 2.0.1 + entities: 4.5.0 + linkify-it: 5.0.0 + mdurl: 2.0.0 + punycode.js: 2.3.1 + uc.micro: 2.1.0 + + markdown-title@1.0.2: {} + + marked@16.4.2: {} + + mdast-util-from-markdown@2.0.3: + dependencies: + '@types/mdast': 4.0.4 + '@types/unist': 3.0.3 + decode-named-character-reference: 1.3.0 + devlop: 1.1.0 + mdast-util-to-string: 4.0.0 + micromark: 4.0.2 + micromark-util-decode-numeric-character-reference: 2.0.2 + micromark-util-decode-string: 2.0.1 + micromark-util-normalize-identifier: 2.0.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + unist-util-stringify-position: 4.0.0 + transitivePeerDependencies: + - supports-color + + mdast-util-frontmatter@2.0.1: + dependencies: + '@types/mdast': 4.0.4 + devlop: 1.1.0 + escape-string-regexp: 5.0.0 + mdast-util-from-markdown: 2.0.3 + mdast-util-to-markdown: 2.1.2 + micromark-extension-frontmatter: 2.0.0 + transitivePeerDependencies: + - supports-color + + mdast-util-phrasing@4.1.0: + dependencies: + '@types/mdast': 4.0.4 + unist-util-is: 6.0.1 + + mdast-util-to-hast@13.2.1: + dependencies: + '@types/hast': 3.0.4 + '@types/mdast': 4.0.4 + '@ungap/structured-clone': 1.3.1 + devlop: 1.1.0 + micromark-util-sanitize-uri: 2.0.1 + trim-lines: 3.0.1 + unist-util-position: 5.0.0 + unist-util-visit: 5.1.0 + vfile: 6.0.3 + + mdast-util-to-markdown@2.1.2: + dependencies: + '@types/mdast': 4.0.4 + '@types/unist': 3.0.3 + longest-streak: 3.1.0 + mdast-util-phrasing: 4.1.0 + mdast-util-to-string: 4.0.0 + micromark-util-classify-character: 2.0.1 + micromark-util-decode-string: 2.0.1 + unist-util-visit: 5.1.0 + zwitch: 2.0.4 + + mdast-util-to-string@4.0.0: + dependencies: + '@types/mdast': 4.0.4 + + mdurl@2.0.0: {} + + mermaid@11.15.0: + dependencies: + '@braintree/sanitize-url': 7.1.2 + '@iconify/utils': 3.1.3 + '@mermaid-js/parser': 1.1.1 + '@types/d3': 7.4.3 + '@upsetjs/venn.js': 2.0.0 + cytoscape: 3.33.4 + cytoscape-cose-bilkent: 4.1.0(cytoscape@3.33.4) + cytoscape-fcose: 2.2.0(cytoscape@3.33.4) + d3: 7.9.0 + d3-sankey: 0.12.3 + dagre-d3-es: 7.0.14 + dayjs: 1.11.20 + dompurify: 3.4.5 + es-toolkit: 1.46.1 + katex: 0.16.47 + khroma: 2.1.0 + marked: 16.4.2 + roughjs: 4.6.6 + stylis: 4.4.0 + ts-dedent: 2.2.0 + uuid: 14.0.0 + + micromark-core-commonmark@2.0.3: + dependencies: + decode-named-character-reference: 1.3.0 + devlop: 1.1.0 + micromark-factory-destination: 2.0.1 + micromark-factory-label: 2.0.1 + micromark-factory-space: 2.0.1 + micromark-factory-title: 2.0.1 + micromark-factory-whitespace: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-chunked: 2.0.1 + micromark-util-classify-character: 2.0.1 + micromark-util-html-tag-name: 2.0.1 + micromark-util-normalize-identifier: 2.0.1 + micromark-util-resolve-all: 2.0.1 + micromark-util-subtokenize: 2.1.0 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-extension-frontmatter@2.0.0: + dependencies: + fault: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-factory-destination@2.0.1: + dependencies: + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-factory-label@2.0.1: + dependencies: + devlop: 1.1.0 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-factory-space@2.0.1: + dependencies: + micromark-util-character: 2.1.1 + micromark-util-types: 2.0.2 + + micromark-factory-title@2.0.1: + dependencies: + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-factory-whitespace@2.0.1: + dependencies: + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-util-character@2.1.1: + dependencies: + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-util-chunked@2.0.1: + dependencies: + micromark-util-symbol: 2.0.1 + + micromark-util-classify-character@2.0.1: + dependencies: + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-util-combine-extensions@2.0.1: + dependencies: + micromark-util-chunked: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-util-decode-numeric-character-reference@2.0.2: + dependencies: + micromark-util-symbol: 2.0.1 + + micromark-util-decode-string@2.0.1: + dependencies: + decode-named-character-reference: 1.3.0 + micromark-util-character: 2.1.1 + micromark-util-decode-numeric-character-reference: 2.0.2 + micromark-util-symbol: 2.0.1 + + micromark-util-encode@2.0.1: {} + + micromark-util-html-tag-name@2.0.1: {} + + micromark-util-normalize-identifier@2.0.1: + dependencies: + micromark-util-symbol: 2.0.1 + + micromark-util-resolve-all@2.0.1: + dependencies: + micromark-util-types: 2.0.2 + + micromark-util-sanitize-uri@2.0.1: + dependencies: + micromark-util-character: 2.1.1 + micromark-util-encode: 2.0.1 + micromark-util-symbol: 2.0.1 + + micromark-util-subtokenize@2.1.0: + dependencies: + devlop: 1.1.0 + micromark-util-chunked: 2.0.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-util-symbol@2.0.1: {} + + micromark-util-types@2.0.2: {} + + micromark@4.0.2: + dependencies: + '@types/debug': 4.1.13 + debug: 4.4.3 + decode-named-character-reference: 1.3.0 + devlop: 1.1.0 + micromark-core-commonmark: 2.0.3 + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-chunked: 2.0.1 + micromark-util-combine-extensions: 2.0.1 + micromark-util-decode-numeric-character-reference: 2.0.2 + micromark-util-encode: 2.0.1 + micromark-util-normalize-identifier: 2.0.1 + micromark-util-resolve-all: 2.0.1 + micromark-util-sanitize-uri: 2.0.1 + micromark-util-subtokenize: 2.1.0 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + transitivePeerDependencies: + - supports-color + + millify@6.1.0: + dependencies: + yargs: 17.7.2 + + minimatch@10.2.5: + dependencies: + brace-expansion: 5.0.6 + + minisearch@7.2.0: {} + + mitt@3.0.1: {} + + ms@2.1.3: {} + + nanoid@3.3.12: {} + + non-layered-tidy-tree-layout@2.0.2: + optional: true + + oniguruma-to-es@3.1.1: + dependencies: + emoji-regex-xs: 1.0.0 + regex: 6.1.0 + regex-recursion: 6.0.2 + + package-manager-detector@1.6.0: {} + + path-data-parser@0.1.0: {} + + path-to-regexp@6.3.0: {} + + perfect-debounce@1.0.0: {} + + picocolors@1.1.1: {} + + points-on-curve@0.2.0: {} + + points-on-path@0.2.1: + dependencies: + path-data-parser: 0.1.0 + points-on-curve: 0.2.0 + + postcss@8.5.15: + dependencies: + nanoid: 3.3.12 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + preact@10.29.2: {} + + pretty-bytes@7.1.0: {} + + property-information@7.1.0: {} + + punycode.js@2.3.1: {} + + regex-recursion@6.0.2: + dependencies: + regex-utilities: 2.3.0 + + regex-utilities@2.3.0: {} + + regex@6.1.0: + dependencies: + regex-utilities: 2.3.0 + + remark-frontmatter@5.0.0: + dependencies: + '@types/mdast': 4.0.4 + mdast-util-frontmatter: 2.0.1 + micromark-extension-frontmatter: 2.0.0 + unified: 11.0.5 + transitivePeerDependencies: + - supports-color + + remark-parse@11.0.0: + dependencies: + '@types/mdast': 4.0.4 + mdast-util-from-markdown: 2.0.3 + micromark-util-types: 2.0.2 + unified: 11.0.5 + transitivePeerDependencies: + - supports-color + + remark-stringify@11.0.0: + dependencies: + '@types/mdast': 4.0.4 + mdast-util-to-markdown: 2.1.2 + unified: 11.0.5 + + remark@15.0.1: + dependencies: + '@types/mdast': 4.0.4 + remark-parse: 11.0.0 + remark-stringify: 11.0.0 + unified: 11.0.5 + transitivePeerDependencies: + - supports-color + + require-directory@2.1.1: {} + + rfdc@1.4.1: {} + + robust-predicates@3.0.3: {} + + rollup@4.60.4: + dependencies: + '@types/estree': 1.0.8 + optionalDependencies: + '@rollup/rollup-android-arm-eabi': 4.60.4 + '@rollup/rollup-android-arm64': 4.60.4 + '@rollup/rollup-darwin-arm64': 4.60.4 + '@rollup/rollup-darwin-x64': 4.60.4 + '@rollup/rollup-freebsd-arm64': 4.60.4 + '@rollup/rollup-freebsd-x64': 4.60.4 + '@rollup/rollup-linux-arm-gnueabihf': 4.60.4 + '@rollup/rollup-linux-arm-musleabihf': 4.60.4 + '@rollup/rollup-linux-arm64-gnu': 4.60.4 + '@rollup/rollup-linux-arm64-musl': 4.60.4 + '@rollup/rollup-linux-loong64-gnu': 4.60.4 + '@rollup/rollup-linux-loong64-musl': 4.60.4 + '@rollup/rollup-linux-ppc64-gnu': 4.60.4 + '@rollup/rollup-linux-ppc64-musl': 4.60.4 + '@rollup/rollup-linux-riscv64-gnu': 4.60.4 + '@rollup/rollup-linux-riscv64-musl': 4.60.4 + '@rollup/rollup-linux-s390x-gnu': 4.60.4 + '@rollup/rollup-linux-x64-gnu': 4.60.4 + '@rollup/rollup-linux-x64-musl': 4.60.4 + '@rollup/rollup-openbsd-x64': 4.60.4 + '@rollup/rollup-openharmony-arm64': 4.60.4 + '@rollup/rollup-win32-arm64-msvc': 4.60.4 + '@rollup/rollup-win32-ia32-msvc': 4.60.4 + '@rollup/rollup-win32-x64-gnu': 4.60.4 + '@rollup/rollup-win32-x64-msvc': 4.60.4 + fsevents: 2.3.3 + + roughjs@4.6.6: + dependencies: + hachure-fill: 0.5.2 + path-data-parser: 0.1.0 + points-on-curve: 0.2.0 + points-on-path: 0.2.1 + + rw@1.3.3: {} + + safer-buffer@2.1.2: {} + + search-insights@2.17.3: {} + + section-matter@1.0.0: + dependencies: + extend-shallow: 2.0.1 + kind-of: 6.0.3 + + shiki@2.5.0: + dependencies: + '@shikijs/core': 2.5.0 + '@shikijs/engine-javascript': 2.5.0 + '@shikijs/engine-oniguruma': 2.5.0 + '@shikijs/langs': 2.5.0 + '@shikijs/themes': 2.5.0 + '@shikijs/types': 2.5.0 + '@shikijs/vscode-textmate': 10.0.2 + '@types/hast': 3.0.4 + + source-map-js@1.2.1: {} + + space-separated-tokens@2.0.2: {} + + speakingurl@14.0.1: {} + + sprintf-js@1.0.3: {} + + string-width@4.2.3: + dependencies: + emoji-regex: 8.0.0 + is-fullwidth-code-point: 3.0.0 + strip-ansi: 6.0.1 + + stringify-entities@4.0.4: + dependencies: + character-entities-html4: 2.1.0 + character-entities-legacy: 3.0.0 + + strip-ansi@6.0.1: + dependencies: + ansi-regex: 5.0.1 + + strip-bom-string@1.0.0: {} + + stylis@4.4.0: {} + + superjson@2.2.6: + dependencies: + copy-anything: 4.0.5 + + tabbable@6.4.0: {} + + tinyexec@1.1.2: {} + + tokenx@1.3.0: {} + + trim-lines@3.0.1: {} + + trough@2.2.0: {} + + ts-dedent@2.2.0: {} + + uc.micro@2.1.0: {} + + unified@11.0.5: + dependencies: + '@types/unist': 3.0.3 + bail: 2.0.2 + devlop: 1.1.0 + extend: 3.0.2 + is-plain-obj: 4.1.0 + trough: 2.2.0 + vfile: 6.0.3 + + unist-util-is@6.0.1: + dependencies: + '@types/unist': 3.0.3 + + unist-util-position@5.0.0: + dependencies: + '@types/unist': 3.0.3 + + unist-util-remove@4.0.0: + dependencies: + '@types/unist': 3.0.3 + unist-util-is: 6.0.1 + unist-util-visit-parents: 6.0.2 + + unist-util-stringify-position@4.0.0: + dependencies: + '@types/unist': 3.0.3 + + unist-util-visit-parents@6.0.2: + dependencies: + '@types/unist': 3.0.3 + unist-util-is: 6.0.1 + + unist-util-visit@5.1.0: + dependencies: + '@types/unist': 3.0.3 + unist-util-is: 6.0.1 + unist-util-visit-parents: 6.0.2 + + uuid@14.0.0: {} + + vfile-message@4.0.3: + dependencies: + '@types/unist': 3.0.3 + unist-util-stringify-position: 4.0.0 + + vfile@6.0.3: + dependencies: + '@types/unist': 3.0.3 + vfile-message: 4.0.3 + + vite@5.4.21: + dependencies: + esbuild: 0.21.5 + postcss: 8.5.15 + rollup: 4.60.4 + optionalDependencies: + fsevents: 2.3.3 + + vitepress-plugin-llms@1.12.2: + dependencies: + gray-matter: 4.0.3 + markdown-it: 14.1.1 + markdown-title: 1.0.2 + mdast-util-from-markdown: 2.0.3 + millify: 6.1.0 + minimatch: 10.2.5 + path-to-regexp: 6.3.0 + picocolors: 1.1.1 + pretty-bytes: 7.1.0 + remark: 15.0.1 + remark-frontmatter: 5.0.0 + tokenx: 1.3.0 + unist-util-remove: 4.0.0 + unist-util-visit: 5.1.0 + transitivePeerDependencies: + - supports-color + + vitepress-plugin-mermaid@2.0.17(mermaid@11.15.0)(vitepress@1.6.4(@algolia/client-search@5.52.1)(postcss@8.5.15)(search-insights@2.17.3)): + dependencies: + mermaid: 11.15.0 + vitepress: 1.6.4(@algolia/client-search@5.52.1)(postcss@8.5.15)(search-insights@2.17.3) + optionalDependencies: + '@mermaid-js/mermaid-mindmap': 9.3.0 + + vitepress@1.6.4(@algolia/client-search@5.52.1)(postcss@8.5.15)(search-insights@2.17.3): + dependencies: + '@docsearch/css': 3.8.2 + '@docsearch/js': 3.8.2(@algolia/client-search@5.52.1)(search-insights@2.17.3) + '@iconify-json/simple-icons': 1.2.83 + '@shikijs/core': 2.5.0 + '@shikijs/transformers': 2.5.0 + '@shikijs/types': 2.5.0 + '@types/markdown-it': 14.1.2 + '@vitejs/plugin-vue': 5.2.4(vite@5.4.21)(vue@3.5.34) + '@vue/devtools-api': 7.7.9 + '@vue/shared': 3.5.34 + '@vueuse/core': 12.8.2 + '@vueuse/integrations': 12.8.2(focus-trap@7.8.0) + focus-trap: 7.8.0 + mark.js: 8.11.1 + minisearch: 7.2.0 + shiki: 2.5.0 + vite: 5.4.21 + vue: 3.5.34 + optionalDependencies: + postcss: 8.5.15 + transitivePeerDependencies: + - '@algolia/client-search' + - '@types/node' + - '@types/react' + - async-validator + - axios + - change-case + - drauu + - fuse.js + - idb-keyval + - jwt-decode + - less + - lightningcss + - nprogress + - qrcode + - react + - react-dom + - sass + - sass-embedded + - search-insights + - sortablejs + - stylus + - sugarss + - terser + - typescript + - universal-cookie + + vue@3.5.34: + dependencies: + '@vue/compiler-dom': 3.5.34 + '@vue/compiler-sfc': 3.5.34 + '@vue/runtime-dom': 3.5.34 + '@vue/server-renderer': 3.5.34(vue@3.5.34) + '@vue/shared': 3.5.34 + + wrap-ansi@7.0.0: + dependencies: + ansi-styles: 4.3.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + + y18n@5.0.8: {} + + yargs-parser@21.1.1: {} + + yargs@17.7.2: + dependencies: + cliui: 8.0.1 + escalade: 3.2.0 + get-caller-file: 2.0.5 + require-directory: 2.1.1 + string-width: 4.2.3 + y18n: 5.0.8 + yargs-parser: 21.1.1 + + zwitch@2.0.4: {} diff --git a/docs/public/favicon.ico b/docs/public/favicon.ico new file mode 100644 index 000000000..9b4870b72 Binary files /dev/null and b/docs/public/favicon.ico differ diff --git a/docs/zh/configuration/config-files.md b/docs/zh/configuration/config-files.md new file mode 100644 index 000000000..b9fe6f981 --- /dev/null +++ b/docs/zh/configuration/config-files.md @@ -0,0 +1,228 @@ +# 配置文件 + +Kimi Code CLI 把全局配置保存在一份 TOML 文件中,包含 API 供应商、模型别名、Agent 循环参数、后台任务、外部服务等。这份文档介绍配置文件的位置、顶层字段、各嵌套结构,以及一份完整示例。 + +## 配置文件位置 + +默认配置文件位于 `~/.kimi-code/config.toml`。目录和文件会在首次运行时自动创建,并设置严格的访问权限。 + +如果你希望把数据目录放到别处,可以通过环境变量 `KIMI_CODE_HOME` 覆盖默认路径: + +```sh +export KIMI_CODE_HOME=/path/to/kimi-home +``` + +此时配置文件路径变为 `$KIMI_CODE_HOME/config.toml`。无论目录在哪里,文件名固定为 `config.toml`。 + +::: tip 提示 +TOML 中的字段名一律使用 snake_case(例如 `default_model`、`max_context_size`)。如果 key 中包含 `.`,需要使用带引号的 TOML key,否则 TOML 会把 `.` 当作嵌套表分隔符。 +::: + +## 顶层字段 + +| 字段 | 类型 | 默认值 | 说明 | +| --- | --- | --- | --- | +| `default_model` | `string` | — | 默认使用的模型别名,必须在 `models` 中定义 | +| `default_thinking` | `boolean` | `false` | 新会话启动时 Thinking 开关的初始值,可在会话内通过模型菜单切换。即使该字段为 `true`,`[thinking].mode = "off"` 也会强制禁用 Thinking。详见下文 [`thinking`](#thinking) | +| `default_permission_mode` | `string` | `manual` | 新会话的默认权限模式,可选 `manual`、`auto`、`yolo` | +| `default_plan_mode` | `boolean` | `false` | 新会话是否默认以 Plan 模式启动;省略等同 `false` | +| `merge_all_available_skills` | `boolean` | `true` | 是否合并所有可用目录中的 Agent Skills | +| `extra_skill_dirs` | `array` | — | 额外的 Skill 搜索目录,会叠加到默认目录之上 | +| `telemetry` | `boolean` | `true` | 是否启用匿名遥测;仅在显式设为 `false` 时关闭 | +| `providers` | `table` | `{}` | API 供应商表,详见下文 | +| `models` | `table` | — | 模型别名表,详见下文 | +| `thinking` | `table` | — | Thinking 模式默认参数 | +| `loop_control` | `table` | — | Agent 循环控制参数 | +| `background` | `table` | — | 后台任务运行参数 | +| `services` | `table` | — | 内置外部服务配置 | +| `permission` | `table` | — | 权限规则配置,详见下文 | +| `hooks` | `array
    ` | — | 生命周期 hook 配置,详见 [Hooks](../customization/hooks.md) | + +## 完整示例 + +```toml +default_model = "kimi-code/kimi-for-coding" +default_thinking = true +default_permission_mode = "manual" +default_plan_mode = false +merge_all_available_skills = true +telemetry = true + +[providers."managed:kimi-code"] +type = "kimi" +base_url = "https://api.kimi.com/coding/v1" +api_key = "" + +[models."kimi-code/kimi-for-coding"] +provider = "managed:kimi-code" +model = "kimi-for-coding" +max_context_size = 262144 + +[thinking] +mode = "auto" + +[loop_control] +max_steps_per_turn = 1000 +max_retries_per_step = 3 +reserved_context_size = 50000 + +[background] +max_running_tasks = 4 +keep_alive_on_exit = false +agent_task_timeout_s = 900 + +[[permission.rules]] +decision = "allow" +pattern = "Read" + +[[permission.rules]] +decision = "deny" +pattern = "Bash(rm -rf*)" + +[[hooks]] +event = "PreToolUse" +matcher = "Bash" +command = "node ~/.kimi-code/hooks/check-bash.mjs" +timeout = 5 +``` + +## `providers` + +`providers` 表中的每一项定义一个 API 供应商连接信息,以唯一的名称作为 key。 + +| 字段 | 类型 | 必填 | 说明 | +| --- | --- | --- | --- | +| `type` | `string` | 是 | 供应商类型,可选 `anthropic`、`openai`、`kimi`、`google-genai`、`openai_responses`、`vertexai` | +| `api_key` | `string` | 否 | API 密钥 | +| `base_url` | `string` | 否 | API 基础 URL | +| `oauth` | `table` | 否 | OAuth 凭据引用,详见下文 | +| `env` | `table` | 否 | 按供应商约定的键读取的配置子表,作为 `api_key` / `base_url` 等字段的备用来源(如 `KIMI_API_KEY`、`ANTHROPIC_API_KEY`、`GOOGLE_CLOUD_PROJECT` 等)。这只是写在配置文件里的子表,**不会真正写入到终端的环境变量**;仅在 `[providers.]` 上直接字段缺省时被 CLI 读取 | +| `custom_headers` | `table` | 否 | 请求时附加的自定义 HTTP 头 | + +OAuth 凭据引用结构: + +| 字段 | 类型 | 必填 | 说明 | +| --- | --- | --- | --- | +| `storage` | `string` | 是 | 凭据存储位置;目前只支持 `"file"` | +| `key` | `string` | 是 | 凭据条目的唯一标识 | + +```toml +[providers.openai] +type = "openai" +base_url = "https://api.openai.com/v1" +api_key = "sk-xxx" +custom_headers = { "X-Custom-Header" = "value" } +``` + +## `models` + +`models` 表中的每一项定义一个模型别名,以唯一的名称作为 key。 + +| 字段 | 类型 | 必填 | 说明 | +| --- | --- | --- | --- | +| `provider` | `string` | 是 | 使用的供应商名称,必须在 `providers` 中定义 | +| `model` | `string` | 是 | 调用 API 时使用的模型标识符 | +| `max_context_size` | `integer` | 是 | 最大上下文长度(token 数),必须大于等于 1 | +| `max_output_size` | `integer` | 否 | 单次请求的输出预算上限(即请求层面的 `max_tokens`)。目前仅 `anthropic` 供应商会读取该字段。当别名能识别到具体的 Claude 家族时,覆盖值不会超过模型允许的上限,避免超出服务端限制。省略时使用模型推导出的默认值,详见 [`providers.md`](./providers.md#anthropic)。 | +| `capabilities` | `array` | 否 | 显式追加的模型能力标签,例如 `thinking`、`image_in`、`video_in`、`audio_in`、`tool_use` | +| `display_name` | `string` | 否 | 在 UI 中显示的名称,未设置时回退到 `model` | + +`capabilities` 与供应商 capability registry 按模型名前缀自动匹配出来的能力做并集 —— 只能追加、不能移除。通常无需手写;只有当模型未被 registry 覆盖、或希望强制启用某项能力时才用得到。 + +如果模型别名包含 `.`,需要使用带引号的 key: + +```toml +[models."gpt-4.1"] +provider = "openai" +model = "gpt-4.1" +max_context_size = 1047576 +``` + +## `thinking` + +`thinking` 控制 Thinking 模式的默认行为。即便顶层 `default_thinking = true`,将 `mode` 设为 `"off"` 也会强制禁用 Thinking。 + +| 字段 | 类型 | 默认值 | 说明 | +| --- | --- | --- | --- | +| `mode` | `string` | — | 触发策略,可选 `auto`、`on`、`off`。设为 `"off"` 时强制禁用 Thinking;其它取值或省略时不禁用,由会话内的 Thinking 开关与 `effort` 共同决定 | +| `effort` | `string` | `high` | 启用 Thinking 时使用的默认强度,可选 `low`、`medium`、`high`、`xhigh`、`max`,实际可用等级由供应商决定 | + +## `loop_control` + +`loop_control` 控制 Agent 执行循环的步数、重试和上下文压缩阈值。 + +| 字段 | 类型 | 默认值 | 说明 | +| --- | --- | --- | --- | +| `max_steps_per_turn` | `integer` | `1000` | 单轮最大步数 | +| `max_retries_per_step` | `integer` | `3` | 单步最大重试次数 | +| `reserved_context_size` | `integer` | — | 预留给响应生成的 token 数;上下文逼近该阈值时触发压缩 | + +## `background` + +`background` 控制后台任务的运行限制。后台任务通过 `Bash` 工具或 `Agent` 工具的 `run_in_background=true` 参数启动。 + +| 字段 | 类型 | 默认值 | 说明 | +| --- | --- | --- | --- | +| `max_running_tasks` | `integer` | — | 同时运行的最大后台任务数 | +| `keep_alive_on_exit` | `boolean` | `true` | 会话关闭时是否保留仍在运行的后台任务。设为 `false` 后,`kimi -p` 完成退出、SDK 关闭 session 或 harness 关闭时会请求停止后台任务 | +| `agent_task_timeout_s` | `integer` | — | 后台 Agent 任务的最大运行时间(秒) | + +`keep_alive_on_exit` 可以被环境变量 `KIMI_CODE_BACKGROUND_KEEP_ALIVE_ON_EXIT` 覆盖;环境变量优先级高于 `config.toml`。schema 还预留了 `kill_grace_period_ms`、`print_wait_ceiling_s` 两个字段,目前仅 schema 校验通过,CLI 运行时不会读取。 + +## `services` + +`services` 配置 Kimi Code CLI 调用的内置外部服务。当前仅识别 `moonshot_search`(网页搜索)和 `moonshot_fetch`(网页抓取)两个固定 key,其他 key 会被忽略。两项的字段相同: + +| 字段 | 类型 | 必填 | 说明 | +| --- | --- | --- | --- | +| `base_url` | `string` | 否 | 服务 API URL | +| `api_key` | `string` | 否 | API 密钥 | +| `oauth` | `table` | 否 | OAuth 凭据引用,结构同 `providers.*.oauth` | +| `custom_headers` | `table` | 否 | 请求时附加的自定义 HTTP 头 | + +```toml +[services.moonshot_search] +base_url = "https://api.moonshot.cn/v1/search" +api_key = "sk-xxx" + +[services.moonshot_fetch] +base_url = "https://api.moonshot.cn/v1/fetch" +api_key = "sk-xxx" +``` + +## `permission` + +`permission` 配置会话启动时加载的初始权限规则,控制工具调用的默认审批行为。新会话的默认权限模式由顶层 `default_permission_mode` 控制;启动时显式传入的权限模式(例如 CLI 的 `--yolo`)会覆盖这个默认值。 + +规则通过 `[[permission.rules]]` 数组表写出,每条规则包含以下字段: + +| 字段 | 类型 | 必填 | 说明 | +| --- | --- | --- | --- | +| `decision` | `string` | 是 | 决策结果,可选 `allow`、`deny`、`ask` | +| `scope` | `string` | 否 | 规则作用域,可选 `turn-override`、`session-runtime`、`project`、`user`;默认 `user` | +| `pattern` | `string` | 是 | 匹配模式,格式为 `ToolName` 或 `ToolName(arg-pattern)`。`ToolName` 必须与运行时真实工具名一致——内置工具是 `Read`、`Write`、`Edit`、`Bash`、`Grep` 等(详见 [内置工具](../reference/tools.md)) | +| `reason` | `string` | 否 | 规则说明,供调试或审计使用 | + +示例: + +```toml +[[permission.rules]] +decision = "allow" +pattern = "Read" + +[[permission.rules]] +decision = "allow" +pattern = "Grep" + +[[permission.rules]] +decision = "deny" +pattern = "Bash(rm -rf*)" + +[[permission.rules]] +decision = "ask" +pattern = "Bash" +``` + +::: tip 提示 +MCP server 的声明配置写在 `~/.kimi-code/mcp.json` 或项目内 `.kimi-code/mcp.json` 中,不在 `config.toml` 里。交互式配置入口是 `/mcp-config`,详见 [Model Context Protocol](../customization/mcp.md)。 +::: diff --git a/docs/zh/configuration/data-locations.md b/docs/zh/configuration/data-locations.md new file mode 100644 index 000000000..519ad5029 --- /dev/null +++ b/docs/zh/configuration/data-locations.md @@ -0,0 +1,131 @@ +# 数据路径 + +Kimi Code CLI 将运行时数据集中存储在用户主目录下的 `~/.kimi-code/` 目录中。本页介绍各类数据的存放位置、用途,以及如何自定义和清理。 + +## 数据根目录 + +默认数据根是 `~/.kimi-code/`。`~` 由 Node.js 的 `os.homedir()` 解析,因此实际路径在不同平台略有差异:macOS 上是 `/Users//.kimi-code`,Linux 上是 `/home//.kimi-code`,Windows 上是 `C:\Users\\.kimi-code`。 + +可以通过 `KIMI_CODE_HOME` 环境变量覆盖到任意路径: + +```sh +export KIMI_CODE_HOME="$HOME/.config/kimi-code" +``` + +设置后,配置、会话、日志、输入历史、更新缓存、OAuth 凭据等运行时数据都会落到该路径下。`KIMI_CODE_HOME` 与其他环境变量的完整说明见 [环境变量](./env-vars.md)。 + +::: tip 例外 +**内置工具缓存**(例如自动下载的 ripgrep 二进制)不走 `KIMI_CODE_HOME`,而是走 `KIMI_CODE_CACHE_DIR`;未设置时使用平台缓存目录——macOS 上是 `~/Library/Caches/kimi-code`,Linux 上是 `$XDG_CACHE_HOME/kimi-code`(缺省 `~/.cache/kimi-code`),Windows 上是 `%LOCALAPPDATA%\kimi-code`。 + +用户级 Agent Skills 的搜索目录位于 `~/.kimi-code/skills` 与 `~/.agents/skills`;项目级则是工作目录下的 `.kimi-code/skills` 与 `.agents/skills`。详见 [Agent Skills](../customization/skills.md)。 +::: + +## 目录结构 + +数据根下的典型布局如下: + +``` +$KIMI_CODE_HOME (默认 ~/.kimi-code) +├── config.toml # 用户配置 +├── mcp.json # 用户级 MCP server 声明(可选) +├── session_index.jsonl # 会话索引 +├── credentials/ # OAuth 凭据根目录(目录 0o700、文件 0o600) +│ ├── .json # 托管 Kimi / Open Platform 等 provider OAuth 凭据 +│ └── mcp/ # MCP server OAuth 凭据 +│ └── -.json +├── sessions/ # 会话数据 +│ └── / +│ └── / +│ ├── state.json +│ ├── logs/ +│ │ └── kimi-code.log +│ ├── tasks/ # 后台任务持久化 +│ │ ├── .json +│ │ └── / +│ │ └── output.log +│ └── agents/ +│ ├── main/ +│ │ ├── wire.jsonl +│ │ └── plans/ # Plan 模式计划文件 +│ └── agent-0/ +│ └── wire.jsonl +├── bin/ +│ └── rg # ripgrep 缓存(Windows 为 rg.exe) +├── logs/ # 全局诊断日志 +│ └── kimi-code.log +├── updates/ +│ └── latest.json # 更新检查状态 +└── user-history/ + └── .jsonl +``` + +::: tip +上面的目录树展示的是默认数据根(`~/.kimi-code/`)下的典型布局。Agent Skills 与内置工具缓存的路径略有特殊性,详见上方"例外"提示。 +::: + +## 配置文件 + +`config.toml` 是 Kimi Code CLI 的主配置文件,存放供应商、模型、循环控制等用户级设置。详见 [配置文件](./config-files.md)。 + +`mcp.json` 是用户级 MCP server 声明,会与项目内的 `.kimi-code/mcp.json` 合并加载。字段与项目级文件相同,详见 [MCP](../customization/mcp.md)。 + +OAuth 凭据以文件形式存放在数据根下的 `credentials/` 子目录,目录权限 `0o700`、文件权限 `0o600`,仅当前用户可读写。其中: + +- **托管 Kimi / Open Platform 等供应商的 OAuth 凭据**位于 `credentials/.json`,例如 `~/.kimi-code/credentials/managed:kimi-code.json`。 +- **MCP server 的 OAuth 凭据**位于 `credentials/mcp/` 子目录下,文件名按 server key 自动生成,例如 `credentials/mcp/-.json`。 + +凭据写入采用 `tmp → fsync → rename` 的原子流程;POSIX 下严格保证原子性,Windows 上则尽最大努力保证。 + +## 会话数据 + +会话相关的数据集中在 `sessions/` 下,并通过顶层 `session_index.jsonl` 维护一份 JSONL 索引:每行一条记录,包含 `sessionId`、`sessionDir`、`workDir` 三个字段。索引在创建会话时追加写入,加载时会校验 `sessionDir` 是否仍在 `sessions/` 下、且最后一级目录名等于 `sessionId`,以防止外部篡改指向非法路径。 + +每个会话目录的路径形如 `sessions///`,其中 `workDirKey` 是按工作目录编码出来的桶名,格式为 `wd__`(例如 `wd_myproject_a3f8c1d20e9b`),`sessionId` 是会话的唯一标识。`sessions/` 整条路径包括 `/` 桶都按 `0o700` 权限创建,仅当前用户可访问。 + +会话目录的内部结构包含: + +- `state.json`:会话标题、`lastPrompt`、`createdAt`、`updatedAt`、`isCustomTitle`、`forkedFrom` 以及各个 Agent 的元数据。 +- `agents/main/wire.jsonl`:主 Agent 的 Wire 事件流(内部通信记录),用于回放和恢复。`main` 是主 Agent 的固定 id。 +- `agents/main/plans/`:Plan 模式下主 Agent 写入的计划文件,按计划 id 命名为 `.md`。 +- `agents/agent-0/`、`agents/agent-1/` 等:子 Agent 实例的目录,各自包含 `wire.jsonl`。子 Agent id 由会话内的递增计数器生成(`agent-` 加从 0 起的整数)。 +- `logs/kimi-code.log`:该会话的诊断日志。只有发生被记录的诊断事件时才会出现;普通对话不一定产生这个文件。 +- `tasks/`:后台任务持久化目录。每个任务在 `tasks/.json` 保存元信息(状态、pid、退出码等),标准输出与标准错误写入 `tasks//output.log`。任务 id 格式为 `bash-` 或 `agent-` 前缀加 8 位随机字母数字(如 `bash-a1b2c3d4`)。 + +`sessionId` 仅允许 `[A-Za-z0-9._-]+` 且不能为 `.` 或 `..`,以避免路径注入。会话列表按 `updatedAt` 倒序排序,`updatedAt` 取目录与各关键文件 mtime 的最大值。详见 [会话管理](../guides/sessions.md)。 + +## 内置工具缓存 + +Kimi Code CLI 在首次需要 ripgrep 时会自动下载并缓存。下载过程中,压缩包写入系统临时目录,校验 SHA-256 后解压,二进制直接安装到数据根下的 `bin/rg`(Windows 上为 `bin/rg.exe`)并赋予 `0o755` 执行权限。后续在同一数据根下直接复用,无需再次下载。如果系统 `PATH` 中本来就有 `rg`,会优先使用系统版本;删除 `bin/` 会在下一次需要时触发重新下载。 + +## 日志与更新状态 + +顶层 `logs/kimi-code.log` 是全局诊断日志,主要记录启动、登录、导出等不属于单个会话的问题。单个会话自己的诊断日志在 `/logs/kimi-code.log`。 + +如需报告 bug,优先使用 `kimi export` 导出相关会话(详见 [kimi 命令](../reference/kimi-command.md));如果会话日志存在,它会默认包含在导出包里。全局诊断日志默认也会打包;因为它可能包含其它会话或其它项目的事件,不想分享时使用 `--no-include-global-log` 排除。 + +`updates/latest.json` 记录通过 npm 检查到的版本更新状态,由 CLI 自动维护,通常无需手动编辑。 + +## 输入历史 + +终端中的命令输入历史按工作目录分别保存。每个工作目录对应一个文件,路径为 `user-history/.jsonl`,其中文件名是工作目录字符串的 MD5 哈希值(UTF-8 编码)。文件格式为 JSONL,每行一条历史记录。 + +输入历史用于在终端界面下浏览和搜索此前输入过的提示词。 + +## 清理数据 + +直接删除数据根目录(默认 `~/.kimi-code/`,或 `KIMI_CODE_HOME` 指定的路径)可以完全清理 Kimi Code CLI 的所有运行时数据,包括配置、会话、日志、输入历史和内置工具缓存。 + +如只需清理部分数据: + +| 需求 | 操作 | +| --- | --- | +| 重置配置 | 删除 `~/.kimi-code/config.toml` | +| 清理所有会话 | 删除 `~/.kimi-code/sessions/` 与 `~/.kimi-code/session_index.jsonl` | +| 清理诊断日志 | 删除 `~/.kimi-code/logs/` 目录 | +| 清理输入历史 | 删除 `~/.kimi-code/user-history/` 目录 | +| 重置更新检查状态 | 删除 `~/.kimi-code/updates/latest.json` | +| 强制重新下载 ripgrep | 删除 `~/.kimi-code/bin/` 目录 | +| 清除托管 Kimi / Open Platform OAuth 登录态 | 运行 `/logout`(仅清理当前供应商的 OAuth),或删除对应 `~/.kimi-code/credentials/.json` | +| 清除 MCP server OAuth 登录态 | 删除 `~/.kimi-code/credentials/mcp/` 目录;`/logout` **不会**清理 MCP 的 OAuth 凭据 | +| 移除用户级 MCP 声明 | 删除 `~/.kimi-code/mcp.json` | +| 清空用户级 Skills | 删除 `~/.kimi-code/skills/` 目录 | diff --git a/docs/zh/configuration/env-vars.md b/docs/zh/configuration/env-vars.md new file mode 100644 index 000000000..7116ab400 --- /dev/null +++ b/docs/zh/configuration/env-vars.md @@ -0,0 +1,125 @@ +# 环境变量 + +Kimi Code CLI 通过环境变量来覆盖默认路径、切换 OAuth 端点以及调整运行时行为。大部分变量在 `kimi` 进程启动时读取,少数(如遥测开关、OAuth 锁、诊断日志)在相关子系统初始化时读取。Kimi 自有变量使用 `KIMI_*` 前缀;此外,CLI 也会读取若干系统标准变量。 + +::: warning 注意 +**供应商凭证不在此列**:`KIMI_API_KEY`、`ANTHROPIC_API_KEY`、`OPENAI_API_KEY`、`GOOGLE_API_KEY` 等密钥变量**不会**从 `process.env` 自动读取。它们必须写在 `config.toml` 的 `[providers.]` 段(`api_key` / `base_url`)或 `[providers..env]` 子表中;仅在 shell 中 `export` 不会让某个供应商自动获得凭证。详见 [配置覆盖](./overrides.md#供应商凭证) 与 [供应商](./providers.md)。 +::: + +## 核心路径 + +`KIMI_CODE_HOME` 用于覆盖 Kimi Code CLI 的数据根目录,默认值是 `~/.kimi-code`。CLI 自身的应用数据、`kimi-core` 的配置、ripgrep 缓存以及 OAuth 凭证都会落在这个目录下。 + +```sh +export KIMI_CODE_HOME="/path/to/custom/kimi-code" +``` + +数据布局的详细说明请参阅 [数据路径](./data-locations.md)。 + +::: warning 注意 +设置后请确保目录可写。多个 `kimi` 实例如果共享同一个 `KIMI_CODE_HOME`,会共享配置与凭证文件。 +::: + +## 供应商凭证键名 + +下列键名出现在 `config.toml` 的 `[providers..env]` 子表中,用作供应商 `api_key` / `base_url` 的回退来源。**`kimi` 主进程不会从 `process.env` 直接读取它们**;只有 `[providers..env]` 子表内对应键的值才会被供应商客户端识别。详细解析顺序见 [配置覆盖:供应商凭证](./overrides.md#供应商凭证)。 + +| 键名 | 适用供应商 | 用途 | 默认值 | +| --- | --- | --- | --- | +| `KIMI_API_KEY` | Kimi / Moonshot | API 密钥 | 无 | +| `KIMI_BASE_URL` | Kimi / Moonshot | API 基础 URL | `https://api.moonshot.ai/v1` | +| `ANTHROPIC_API_KEY` | Anthropic | API 密钥 | 无 | +| `ANTHROPIC_BASE_URL` | Anthropic | API 基础 URL | 跟随 Anthropic SDK 默认值 | +| `OPENAI_API_KEY` | OpenAI(`openai` 与 `openai_responses` 均使用) | API 密钥 | 无 | +| `OPENAI_BASE_URL` | OpenAI(`openai` 与 `openai_responses` 均使用) | API 基础 URL | `https://api.openai.com/v1` | +| `GOOGLE_API_KEY` | Google GenAI、Vertex AI(作为 `VERTEXAI_API_KEY` 的备用) | API 密钥 | 无 | +| `VERTEXAI_API_KEY` | Vertex AI | API 密钥(未使用 ADC 时) | 无 | +| `GOOGLE_CLOUD_PROJECT` | Vertex AI | GCP 项目 ID | 无 | +| `GOOGLE_CLOUD_LOCATION` | Vertex AI | GCP 区域 | 无 | + +例如在 `config.toml` 中预置 Kimi 凭证: + +```toml +[providers.kimi.env] +KIMI_API_KEY = "sk-xxx" +KIMI_BASE_URL = "https://api.moonshot.ai/v1" +``` + +::: warning 注意 +`GOOGLE_APPLICATION_CREDENTIALS`(服务账号 JSON 路径)由 Google SDK 自身从终端环境变量中读取,是这组键名中**唯一**走系统环境变量的;它走的是 Google Cloud 标准的 ADC 流程,CLI 不参与解析。其它键名都需要写在 `[providers..env]` 子表里才会生效。 +::: + +供应商类型与字段的完整说明请参阅 [供应商](./providers.md)。 + +## OAuth 与托管服务 + +OAuth 流程默认连接 Kimi 官方的认证与托管端点,下列变量可以将它们指向自建或测试环境。 + +| 环境变量 | 用途 | 默认值 | +| --- | --- | --- | +| `KIMI_CODE_OAUTH_HOST` | OAuth 认证 host,优先级最高 | —(未设置时回退到 `KIMI_OAUTH_HOST`,再回退到下面的硬编码默认) | +| `KIMI_OAUTH_HOST` | OAuth 认证 host,作为 `KIMI_CODE_OAUTH_HOST` 的 fallback | —(未设置时回退到下面的硬编码默认) | +| `KIMI_CODE_BASE_URL` | 托管 Kimi API 的 base URL,用于 OAuth 登录后的 API 调用 | `https://api.kimi.com/coding/v1` | + +当 `KIMI_CODE_OAUTH_HOST` 和 `KIMI_OAUTH_HOST` 都未设置时,OAuth 认证 host 使用硬编码常量 `https://auth.kimi.com`。 + +::: warning 注意 +`KIMI_CODE_BASE_URL` 与上一节的 `KIMI_BASE_URL` 是两个不同变量:前者面向 OAuth 登录的托管服务,默认指向 `kimi.com`;后者面向直接使用 Kimi API 密钥的供应商,默认指向 `moonshot.ai`。请按场景区分。 +::: + +## 运行时开关 + +| 环境变量 | 用途 | 合法值 / 默认值 | +| --- | --- | --- | +| `KIMI_DISABLE_TELEMETRY` | 关闭遥测上报 | `1`、`true`、`t`、`yes`、`y`(不区分大小写) | +| `KIMI_CODE_BACKGROUND_KEEP_ALIVE_ON_EXIT` | 覆盖 `[background].keep_alive_on_exit`,控制会话关闭时是否保留仍在运行的后台任务 | 真值:`1`、`true`、`yes`、`on`;假值:`0`、`false`、`no`、`off`;未设置时读取 `config.toml`,再回退到 `true` | +| `KIMI_SHELL_PATH` | 覆盖 Windows 上 Git Bash (`bash.exe`) 的绝对路径,仅在 Windows 自动探测失败时需要 | 无 | +| `KIMI_MODEL_MAX_COMPLETION_TOKENS` | 单步 LLM 请求 `max_completion_tokens` 的期望预算(实际值按上下文窗口与输入大小再做 clamp);设为 `0` 或负数则完全禁用 clamp。**目前只对 `kimi` 类型的供应商生效**;Anthropic 等其它供应商请改用 `[models.].max_output_size`(详见 [配置文件](./config-files.md#models)) | 默认 32000,受 `loop_control.reserved_context_size` 影响 | + +例如在共享主机上禁用遥测: + +```sh +export KIMI_DISABLE_TELEMETRY="1" +``` + +`KIMI_CODE_BACKGROUND_KEEP_ALIVE_ON_EXIT` 的优先级高于 `config.toml`。例如临时运行 `KIMI_CODE_BACKGROUND_KEEP_ALIVE_ON_EXIT=0 kimi -p "..."` 时,即使配置文件里写了 `keep_alive_on_exit = true`,本次进程退出前也会请求停止后台任务。 + +## 诊断日志 + +下列变量控制 `kimi` 的诊断日志。日志会写入两个位置:全局诊断日志在 `$KIMI_CODE_HOME/logs/kimi-code.log`,每个会话自身的诊断日志在 `/logs/kimi-code.log`(路径细节见 [数据路径](./data-locations.md#日志与更新状态))。所有变量都只在进程启动时读取一次。 + +| 环境变量 | 用途 | 默认值 | +| --- | --- | --- | +| `KIMI_LOG_LEVEL` | 日志级别,可选 `off`、`error`、`warn`、`info`、`debug` | `info` | +| `KIMI_LOG_GLOBAL_MAX_BYTES` | 全局日志文件单个最大字节数 | `6291456`(6 MB) | +| `KIMI_LOG_GLOBAL_FILES` | 全局日志文件保留份数 | `5` | +| `KIMI_LOG_SESSION_MAX_BYTES` | 会话级日志文件单个最大字节数 | `5242880`(5 MB) | +| `KIMI_LOG_SESSION_FILES` | 会话级日志文件保留份数 | `3` | + +整数类变量解析失败(非正整数、非数字)时静默回落到默认值。 + +## 剪贴板桥接 + +`KIMI_WSL_CLIPBOARD_IMAGE_PATH` 由 CLI 在调用 WSL 剪贴板辅助子进程时自动注入,用于传递临时图片路径。该变量写入到 PowerShell 子进程的环境中,由子进程脚本内部读取;kimi 主进程自身不读取此变量。在外部 shell 中设置它对 kimi 主进程**无效**,用户无需手动管理此变量。 + +## 系统环境变量 + +Kimi Code CLI 也会读取一些标准的系统环境变量,用于检测运行环境与默认行为: + +- `HOME`:用户主目录,用于解析默认数据路径。 +- `VISUAL`、`EDITOR`:调用外部编辑器时的可执行命令,`VISUAL` 优先。 +- `PATH`:定位 `rg`、`git` 等外部依赖。 +- `NO_COLOR`:设置且非空时,强制关闭颜色与主题检测,界面回退到深色主题。遵循 [no-color.org](https://no-color.org) 约定。 +- `FORCE_COLOR`:值为 `"0"` 时,同样关闭颜色与主题检测,界面回退到深色主题。 +- `CI`:非空且非 `"0"` 时,关闭主题检测并回退到深色主题;遥测模块也会读取此变量以标记 CI 环境。 +- `LANG`:用于在遥测上下文中标记 locale(仅作为标记,不改变 CLI 行为)。 +- `TERM_PROGRAM`:用于检测终端对 OSC 9 通知的支持(iTerm2、WezTerm、ghostty、WarpTerminal 等);也会写入遥测上下文。 +- `TERM`:用于检测终端对 OSC 9 通知的支持(xterm-kitty、xterm-ghostty 等)。 +- `TMUX`:检测是否运行在 tmux 内,用于终端通知路径的判断。 +- `COLORFGBG`:检测终端配色(深色 / 浅色)。 +- `DISPLAY`、`WAYLAND_DISPLAY`、`XDG_SESSION_TYPE`:检测 Linux 图形会话,用于剪贴板与图片相关功能。`XDG_SESSION_TYPE` 值为 `wayland` 时也判定为 Wayland 会话。 +- `WSL_DISTRO_NAME`、`WSLENV`:检测是否运行在 WSL 内,用于剪贴板的 PowerShell 桥接回退。 +- `TERMUX_VERSION`:检测是否运行在 Termux 中。 +- `LOCALAPPDATA`:Windows 上探测 Git Bash 安装路径时使用。 + +这些变量遵循各操作系统的常规约定,`kimi` 仅读取不修改。 diff --git a/docs/zh/configuration/overrides.md b/docs/zh/configuration/overrides.md new file mode 100644 index 000000000..88ab6284e --- /dev/null +++ b/docs/zh/configuration/overrides.md @@ -0,0 +1,110 @@ +# 配置覆盖 + +Kimi Code CLI 的运行参数来自用户配置文件、命令行选项,以及若干在进程级环境变量上读取的运行时路径、端点与开关。三者面向不同场景 —— 配置文件保存长期偏好,命令行选项做本次启动的临时切换,环境变量主要负责定位数据目录、切换 OAuth 端点和少量运行时开关。 + +环境变量在 Kimi Code CLI 中**不是配置字段的通用后备来源**:它们分成下面三类,作用范围互不相同,不能简单合并成一条线性优先级。 + +## 环境变量的三类作用 + +1. **配置文件定位**:`KIMI_CODE_HOME` 决定配置文件、会话、日志等所在的数据根目录,配置文件路径变为 `$KIMI_CODE_HOME/config.toml`,否则使用 `~/.kimi-code/`。这是先于其它解析的"在哪里找配置"步骤,不是普通参数的后备来源;也无法通过 `KIMI_CONFIG_PATH` 之类的变量任意切换配置文件路径。 +2. **运行时开关**:`KIMI_DISABLE_TELEMETRY` 等少量开关会直接关闭对应子系统。即便 `config.toml` 中 `telemetry = true`,只要这个变量被设置成真值,遥测仍会被关闭——它对相关子系统的语义是"额外禁用",而不是"普通覆盖"。 +3. **运行端点与诊断**:`KIMI_CODE_OAUTH_HOST`、`KIMI_OAUTH_HOST`、`KIMI_CODE_BASE_URL`、`KIMI_LOG_LEVEL` 等供 OAuth 与诊断子系统初始化时读取。完整列表见 [环境变量](./env-vars.md)。 + +## 普通运行参数的优先级 + +对其它运行参数(模型别名、Plan / yolo 模式、Skills 目录等),按下面顺序解析: + +1. **命令行选项**:本次启动指定的参数,覆盖所有其他来源;仅对本次启动生效。 +2. **用户配置文件**:`$KIMI_CODE_HOME/config.toml`(缺省为 `~/.kimi-code/config.toml`),保存长期偏好。 + +少数环境变量会明确覆盖配置文件中的相关字段,例如 `KIMI_CODE_BACKGROUND_KEEP_ALIVE_ON_EXIT` 的优先级高于 `[background].keep_alive_on_exit`。这类例外会在 [环境变量](./env-vars.md) 与对应的 [配置文件](./config-files.md) 字段说明里标出。 + +::: warning 注意 +普通运行参数**不会**从 shell 环境变量取后备值。例如供应商 `api_key` / `base_url` 只读取 `config.toml` 中的字段(包括 `[providers..env]` 子表),不会回退到 `export KIMI_API_KEY` 这类终端变量;详见下文 [供应商凭证](#供应商凭证)。 +::: + +Kimi Code CLI 目前只读取一份用户级配置文件,没有项目级(仓库内)配置文件机制。如果需要在不同项目之间隔离配置,可以通过 `KIMI_CODE_HOME` 指向不同的数据目录(详见下文 [典型场景](#典型场景)),或在启动时用 CLI 选项临时覆盖具体字段。 + +## 配置文件 + +配置文件位置由 `KIMI_CODE_HOME` 环境变量控制,未设置时使用 `~/.kimi-code/`。文件名固定为 `config.toml`,目录会以 `0o700` 权限创建。文件内可声明 `default_provider`、`default_model`、`providers`、`models`、`thinking`、`loop_control` 等长期偏好。具体字段见 [配置文件](./config-files.md)。 + +## 供应商凭证 + +供应商凭证(`api_key`、`base_url`)的解析有自己的规则:Kimi Code CLI 只从 `config.toml` 中读取供应商字段,**不会**从 shell 环境变量取后备值。仅在终端里 `export KIMI_API_KEY` 不会让某个 `[providers.]` 自动获得凭证,必须显式写到配置文件里。 + +对单个供应商而言,凭证按以下顺序解析: + +1. `[providers.].api_key` —— 配置文件中显式写入的密钥,优先级最高。 +2. `[providers..env]` 子表中的对应键(如 `KIMI_API_KEY`、`ANTHROPIC_API_KEY`、`OPENAI_API_KEY`、`GOOGLE_API_KEY`)—— 把习惯写在 shell 里的环境变量名搬到 TOML 子表里,仅在 `api_key` 留空时生效。这只是配置子表的形式,不会真正修改进程环境。 +3. 若两者都缺,启动时会报错并提示对应的供应商缺少凭证。 + +`base_url` 的解析方式与 `api_key` 类似:先读 `[providers.].base_url`,再读 `[providers..env]` 中的 `*_BASE_URL` 键(如 `ANTHROPIC_BASE_URL`、`OPENAI_BASE_URL`、`KIMI_BASE_URL`)。供应商类型与字段的完整说明见 [供应商](./providers.md)。 + +## 进程级环境变量 + +`process.env` 中的变量在 Kimi Code CLI 启动时被读取,作用分成上文 [环境变量的三类作用](#环境变量的三类作用) 中已说明的三类: + +- **数据根目录与日志路径**:`KIMI_CODE_HOME` 切换 `~/.kimi-code/`;`KIMI_LOG_LEVEL` 等控制诊断日志。 +- **运行时开关**:`KIMI_DISABLE_TELEMETRY` 关闭遥测(会覆盖 `config.toml` 中 `telemetry = true` 的设置)。 +- **OAuth 端点与诊断**:`KIMI_CODE_OAUTH_HOST`、`KIMI_OAUTH_HOST`、`KIMI_CODE_BASE_URL` 控制托管 Kimi 登录端点;`KIMI_LOG_LEVEL` 等控制诊断日志。 +- **后台任务退出策略**:`KIMI_CODE_BACKGROUND_KEEP_ALIVE_ON_EXIT` 覆盖 `[background].keep_alive_on_exit`,用于临时决定本次进程退出时是否保留后台任务。 + +完整变量与适用范围见 [环境变量](./env-vars.md)。 + +## 命令行选项 + +启动时通过 CLI 选项指定的参数优先级最高,仅对本次启动生效。常用选项: + +| 选项 | 作用 | +| --- | --- | +| `-S, --session [id]` | 恢复指定 session;不带 id 时进入交互式选择 | +| `-C, --continue` | 续上当前工作目录的上一次 session | +| `-y, --yolo` | 自动批准普通工具调用(别名 `--yes`、`--auto-approve`) | +| `--plan` | 以 Plan 模式启动 | +| `-m, --model ` | 指定本次启动使用的模型别名 | +| `-p, --prompt ` | 以非交互模式执行单次提示词后退出 | +| `--output-format ` | 指定 `-p` 模式的输出格式,可选 `text` 或 `stream-json` | +| `--skills-dir ` | 替换自动发现的 Skills 目录(可重复指定多个,仅对本次启动生效) | + +选项互斥规则: + +- `--output-format` 只能在 prompt 模式(`-p / --prompt`)下使用。 +- `--prompt` 不能与 `--yolo` 同用,也不能与 `--plan` 同用。 +- `--prompt` 模式下使用 `-S / --session` 必须给出 session id,不接受不带 id 的交互式选择。 +- `--continue` 与 `--session` 不能同用。 +- 在非 prompt 模式下,`--yolo` 不能与 `--continue` 或 `--session` 组合;`--plan` 不能与 `--continue` 或 `--session` 组合。 +- `--yolo` 与 `--plan` 可以同时使用。 + +::: tip 提示 +`--skills-dir` 替换本次启动自动发现的 Skills 目录,适合一次性指定;若需长期追加搜索目录,可在 `config.toml` 顶层写 `extra_skill_dirs`(详见 [Agent Skills](../customization/skills.md)),两者语义不同,可按需选用。 +::: + +## 典型场景 + +**切换数据目录用于隔离测试。** `KIMI_CODE_HOME` 会同时影响配置文件、session 存档、ripgrep 缓存等所有数据位置: + +```sh +KIMI_CODE_HOME="$PWD/.kimi-sandbox" kimi +``` + +**在配置文件中预置临时凭证。** 由于供应商凭证只读取 `config.toml`,若要在一次启动里使用另一个 API key,可以预先把它写入 `[providers..env]` 子表: + +```toml +[providers.kimi.env] +KIMI_API_KEY = "sk-test" +``` + +也可以直接为该供应商写 `api_key`;两者优先级见上文 [供应商凭证](#供应商凭证)。 + +**本次启动跳过审批。** 适用于已知安全的批处理任务: + +```sh +kimi --yolo +``` + +**本次启动进入 Plan 模式。** 若希望默认行为也如此,可在配置文件中设置 `default_plan_mode = true`: + +```sh +kimi --plan +``` diff --git a/docs/zh/configuration/providers.md b/docs/zh/configuration/providers.md new file mode 100644 index 000000000..bb7e79d78 --- /dev/null +++ b/docs/zh/configuration/providers.md @@ -0,0 +1,135 @@ +# 平台与模型 + +Kimi Code CLI 通过统一的供应商抽象对接多家 LLM 平台。每个供应商负责一种 API 协议,模型则在供应商之上声明自己的名称、上下文长度和能力。本页介绍当前支持的所有供应商类型,以及如何在 `~/.kimi-code/config.toml` 中配置它们。 + +## 概述 + +`providers` 表里的 `type` 字段决定使用哪一种实现。目前支持的类型有: + +| 类型 | 协议 | 典型平台 | +| --- | --- | --- | +| `kimi` | OpenAI 兼容(chat completions 风格) | Kimi Code、Moonshot AI 开放平台 | +| `anthropic` | Anthropic Messages | Claude API | +| `openai` | OpenAI Chat Completions | OpenAI 及其兼容服务 | +| `openai_responses` | OpenAI Responses API | OpenAI 较新的 Responses 接口 | +| `google-genai` | Google GenAI | Gemini API | +| `vertexai` | Google GenAI on Vertex | Google Cloud Vertex AI | + +所有供应商默认以流式方式与模型交互;thinking、视觉、工具调用等能力按模型名前缀自动匹配,无需在配置里手写。 + +API 密钥可以写在 `api_key` 字段,也可以放在 `[providers..env]` 子表里。优先级为 `api_key` > 子表键 > 若均未配置,启动时将报错。**Kimi Code CLI 不会从 shell 环境变量自动取后备值**——仅在终端里 `export KIMI_API_KEY` 不会让某个供应商自动获得凭证,需要显式写入 `config.toml`(详见 [配置覆盖:供应商凭证](./overrides.md#供应商凭证))。`api_key` 与 `oauth` 在同一个供应商上互斥,同时设置会在解析模型时报错;OAuth 由内置登录流程自动注入,无需手写。 + +`[providers..env]` 子表可以在 `config.toml` 内直接提供凭证或端点覆盖,这些值仅对当前供应商生效,不会泄漏到全局 shell 环境: + +```toml +[providers.my-anthropic.env] +ANTHROPIC_API_KEY = "sk-ant-xxxxx" +ANTHROPIC_BASE_URL = "https://my-proxy.example.com" +``` + +切换供应商最常见的方式有两种:在 TUI 里用 `/model` 斜杠命令选择已配置的模型,或者直接编辑 `config.toml` 调整 `[providers.*]` 与 `[models.*]` 表。完整字段说明见 [配置文件](./config-files.md)。 + +## `kimi` + +`kimi` 通过 OpenAI 兼容协议对接 Moonshot AI。 + +- 默认 `base_url`:`https://api.moonshot.ai/v1` +- 环境变量:`KIMI_API_KEY`、`KIMI_BASE_URL` +- 额外能力:支持视频上传 + +```toml +[providers.kimi] +type = "kimi" +base_url = "https://api.moonshot.ai/v1" +api_key = "sk-xxxxx" +``` + +Kimi Code 托管服务在 OAuth 登录后会自动配置 `base_url` 与凭证,无需手动填写;详见 [OAuth 与凭证注入](#oauth-与凭证注入) 与 [环境变量](./env-vars.md)。 + +## `anthropic` + +`anthropic` 用于对接 Claude API。标准 Claude 模型会自动启用视觉、工具调用及 Thinking(如支持)。若使用自定义或尚未覆盖的模型,需在 `[models.]` 中显式声明 `capabilities`。 + +Thinking 可通过 `/model`、`/settings` 或配置控制。 + +- 默认 `base_url`:跟随 Anthropic SDK 默认值 +- 环境变量:`ANTHROPIC_API_KEY`、`ANTHROPIC_BASE_URL` +- 默认 `max_tokens`:按模型自动设置。如需覆盖(例如测试或为尚未识别的别名指定值),在模型别名上设置 `max_output_size`(详见 [`config-files.md`](./config-files.md#models))。已识别别名的覆盖值会被限制在服务端允许的上限内。 + +```toml +[providers.anthropic] +type = "anthropic" +api_key = "sk-ant-xxxxx" + +[models."claude-opus-4-7"] +provider = "anthropic" +model = "claude-opus-4-7" +max_context_size = 200000 +# 可选:在测试时降低输出预算,或为本 CLI 尚未识别的模型指定一个值。 +# 省略则使用上述按模型推导出的默认值。 +# max_output_size = 32000 +``` + +## `openai` + +`openai` 对应 OpenAI Chat Completions 协议,也可用来连接任何兼容该协议的第三方服务(自行覆盖 `base_url` 即可)。thinking、视觉、工具调用等能力按模型名自动推断。 + +- 默认 `base_url`:`https://api.openai.com/v1` +- 环境变量:`OPENAI_API_KEY`、`OPENAI_BASE_URL` + +```toml +[providers.openai] +type = "openai" +base_url = "https://api.openai.com/v1" +api_key = "sk-xxxxx" +``` + +## `openai_responses` + +`openai_responses` 对应 OpenAI 较新的 Responses API。它始终以流式方式工作,能力按模型名自动推断。 + +- 默认 `base_url`:`https://api.openai.com/v1` +- 环境变量:`OPENAI_API_KEY`、`OPENAI_BASE_URL` + +```toml +[providers.openai-responses] +type = "openai_responses" +base_url = "https://api.openai.com/v1" +api_key = "sk-xxxxx" +``` + +## `google-genai` + +`google-genai` 用于直连 Google Gemini API。thinking、视觉及多模态能力按模型名自动推断。 + +- 环境变量:`GOOGLE_API_KEY` + +```toml +[providers.gemini] +type = "google-genai" +api_key = "xxxxx" +``` + +## `vertexai` + +`vertexai` 与 `google-genai` 共用同一份实现,`type = "vertexai"` 时切换到 Vertex AI 的访问路径。 + +认证遵循 Google Cloud 的标准流程:通过 `gcloud auth application-default login` 或设置 `GOOGLE_APPLICATION_CREDENTIALS` 指向服务账号 JSON 完成鉴权(这一步是 Google SDK 的通用机制,与 Kimi Code 配置无关)。**项目与区域必须写在 `[providers.vertexai.env]` 子表中**——直接 `export GOOGLE_CLOUD_PROJECT`、`export GOOGLE_CLOUD_LOCATION` 不会被 CLI 读取。`GOOGLE_CLOUD_LOCATION` 缺失时,CLI 会尝试从 `base_url` 自动推断。API 密钥(`VERTEXAI_API_KEY` 或 `GOOGLE_API_KEY`)同样写在子表内。 + +```toml +[providers.vertexai] +type = "vertexai" + +[providers.vertexai.env] +GOOGLE_CLOUD_PROJECT = "my-gcp-project" +GOOGLE_CLOUD_LOCATION = "us-central1" +``` + +```sh +gcloud auth application-default login # 一次性 +kimi +``` + +## OAuth 与凭证注入 + +部分平台(如 Kimi Code 托管服务)使用 OAuth 而非静态 API 密钥。凭证由内置的 kimi-oauth 工具链在运行时注入,登录流程会自动负责写入与刷新,普通配置文件无需手工配置这部分内容。 diff --git a/docs/zh/customization/agents.md b/docs/zh/customization/agents.md new file mode 100644 index 000000000..5cf2a6ef3 --- /dev/null +++ b/docs/zh/customization/agents.md @@ -0,0 +1,45 @@ +# Agent 与子 Agent + +Kimi Code CLI 中的 Agent 是驱动会话的核心。一次会话始终由一个主 Agent 主持,主 Agent 在执行任务过程中可以根据需要派发子 Agent 来处理更聚焦的子任务。 + +主 Agent 负责理解用户的整体意图、规划步骤、与用户对话、调用工具,并最终汇总结果。它的上下文贯穿整个会话,是用户在终端中直接交互的对象。 + +子 Agent 则是主 Agent 临时派生出来的"助手"。它接受一份明确的任务描述,在自己的上下文里独立完成工作,再把结论返回给主 Agent。子 Agent 不会与用户直接对话,也不会混入主 Agent 的上下文历史。这种分工特别适合"探索代码库"、"审阅大段实现"、"规划复杂改动"这类边界清晰、需要大量阅读但产物较短的工作。 + +## 内置子 Agent + +Kimi Code CLI 内置了三种子 Agent,分别面向不同的任务形态,开箱即用: + +- **`coder`**:默认子 Agent,通用的软件工程助手,可以读写文件、执行命令、搜索代码并落地具体改动。 +- **`explore`**:代码库探索专用,只做只读操作,不会修改任何文件。适合在不修改文件的前提下快速搜索、阅读和总结仓库。 +- **`plan`**:实现规划与架构设计专用,工具集进一步收窄,连 Shell 命令都不提供,专注于"想清楚怎么做"而不是"动手做"。 + +## 调用方式 + +子 Agent 由主 Agent 自动调度。主 Agent 会根据任务复杂度、上下文消耗以及子任务的独立性,在需要时自动派发,无需用户手动指定。 + +每次派发都会在终端中以审批请求的形式呈现(除非命中已有的 allow 规则或处于 YOLO 模式),方便你审视任务描述。你可以在与主 Agent 的对话中直接指定子 Agent 类型,例如"先用 explore 把相关文件梳理一遍再动手"。 + +子 Agent 也可以放在后台运行,完成后结果会自动回到主 Agent,无需手动轮询。也可以唤回已有的子 Agent 实例继续推进同一任务。 + +## 上下文隔离与资源开销 + +每个子 Agent 都拥有完全独立的上下文窗口。它看不到主 Agent 的对话历史,只能看到主 Agent 显式传入的任务描述。子 Agent 自己的中间思考和工具调用记录不会回流到主 Agent,只有最终结果会出现在主 Agent 的上下文里。 + +这种隔离带来两个好处:一是主 Agent 的上下文保持精炼,长会话中不会被大量探索性日志撑满;二是多个子 Agent 可以并行运行,互不干扰。 + +需要注意的是,每个子 Agent 都会独立消耗模型 token,因此简单任务上没有必要派发子 Agent,主 Agent 直接处理反而更经济。子 Agent 也不支持继续嵌套调度。 + +## 权限继承 + +子 Agent 的权限规则继承自主 Agent:主 Agent 通过 `/permission` 或在审批中接受的"始终允许"规则,会自动覆盖到它派发出的所有子 Agent,子 Agent 不需要重新审批同一类工具调用。`Agent` 工具本身默认放行,因此主 Agent 可以在不打断用户的前提下完成多次委派。 + +如果你希望某一类工具在子 Agent 中始终不可用,应该收紧主 Agent 的权限规则。 + +## 会话目录中的存储位置 + +子 Agent 的运行状态会持久化到当前会话目录的 `agents/` 子目录下,每个子 Agent 实例对应一个独立的子目录,其中包含按时间顺序记录提示词、消息历史与最终状态的 `wire.jsonl` 文件。后台子 Agent 还会通过 `tasks/` 子目录暴露生命周期状态。 + +::: warning 注意 +会话目录、wire 文件和任务记录都属于本地调试材料,可能包含用户 prompt、命令输出、仓库路径、工具返回内容或凭证痕迹。不要把这些文件直接提交到公开仓库、issue 或聊天记录里;确实需要分享时,请先脱敏。 +::: diff --git a/docs/zh/customization/hooks.md b/docs/zh/customization/hooks.md new file mode 100644 index 000000000..f55c36e52 --- /dev/null +++ b/docs/zh/customization/hooks.md @@ -0,0 +1,150 @@ +# Hooks + +Hooks 让你在 Kimi Code CLI 的关键生命周期点运行本地命令。它适合做轻量的策略检查、审计记录、桌面通知或与本地自动化脚本联动,例如在危险工具调用前拦截,或在后台子 Agent 完成后触发通知。 + +Hook 命令在本地 Shell 中运行,Kimi Code CLI 会把事件 payload 以 JSON 写入命令的 stdin。命令的 stdout、stderr 和退出码决定 hook 的结果;除明确阻断的情况外,hook 失败时默认放行(fail-open),不会让主流程因为脚本异常而中断。 + +::: warning 注意 +Hooks 适合做本地提醒和轻量拦截,不应作为唯一安全边界。脚本报错、超时或返回普通非零退出码时会默认放行(fail-open);高风险工具调用仍应依赖权限审批和人工确认。 +::: + +## 配置 + +在 `~/.kimi-code/config.toml` 中使用 `[[hooks]]` 数组表声明 hook: + +```toml +[[hooks]] +event = "PreToolUse" +matcher = "Bash" +command = "node ~/.kimi-code/hooks/check-bash.mjs" +timeout = 5 + +[[hooks]] +event = "Notification" +matcher = "task\\.completed" +command = "terminal-notifier -title Kimi -message 'Background task finished'" +``` + +字段含义如下: + +| 字段 | 类型 | 必填 | 说明 | +| --- | --- | --- | --- | +| `event` | `string` | 是 | 事件名,取值必须是下文「事件」表中的某一项;其他值会让整份配置加载失败 | +| `matcher` | `string` | 否 | 用于匹配事件目标的正则表达式;缺省或空字符串表示匹配全部 | +| `command` | `string` | 是 | 要运行的 Shell 命令,长度不能为零 | +| `timeout` | `integer` | 否 | 超时时间,单位秒,范围 1–600;未设置时默认为 30 秒 | + +每个 `[[hooks]]` 表只允许出现这四个字段,写错或多写字段会导致配置文件解析失败。 + +同一次事件触发时,命中的多个 hook 会并行运行;如果多个配置项的 `command` 完全相同,只会运行一次。`matcher` 使用 JavaScript 正则表达式语义;非法正则会被静默跳过,等同于不匹配。 + +Hook 命令通过 Shell 启动(等价于 `sh -c `),子进程的工作目录就是当前会话的 `cwd`。在非 Windows 平台上,子进程会被放入独立的进程组,超时或会话被中断时会先发送 `SIGTERM`、100 毫秒后再发送 `SIGKILL`,确保 hook 内部 fork 出的子进程也能被一并清理。 + +传给 hook 的 JSON 字段统一使用 snake_case。每个 payload 都包含: + +```json +{ + "hook_event_name": "PreToolUse", + "session_id": "session_abc", + "cwd": "/path/to/project" +} +``` + +其余字段由事件类型决定,见下文事件表。 + +## 返回值 + +Hook 命令的退出码和 stdout 会被解释为以下结果: + +| 结果 | 行为 | +| --- | --- | +| 退出码 `0` | 放行;如果 stdout 是 JSON,可从 `message` 或 `hookSpecificOutput.message` 读取文本 | +| 退出码 `2` | 阻断;stderr 会作为阻断原因 | +| 其他非零退出码 | 默认放行(fail-open) | +| 超时或进程异常 | 默认放行(fail-open) | + +当 stdout 是 JSON,并且 `hookSpecificOutput.permissionDecision` 为 `deny` 时,也会被视为阻断: + +```json +{ + "hookSpecificOutput": { + "permissionDecision": "deny", + "permissionDecisionReason": "Use rg instead" + } +} +``` + +阻断只对支持控制流的事件生效。例如 `PreToolUse` 可以阻断工具调用,`Stop` 可以让当前轮次追加一次继续消息。观察型事件(例如 `PostToolUse`、`PostToolUseFailure`、`PostCompact`、`SubagentStop`、`StopFailure`、`Notification`)以「即发即忘(fire-and-forget)」方式异步触发,返回值被忽略,不会改变主流程。`PreCompact` 使用 `trigger`(而非 `triggerBlock`)调用,返回值同样被完全忽略,不属于可阻断事件。 + +阻断生效时,如果脚本未通过 stderr 或 JSON 输出提供原因,CLI 会回退到 `Blocked by hook` 作为占位原因。`PreToolUse` 阻断会作为工具失败结果写回上下文,模型可以根据原因选择替代方案。 + +## 事件 + +当前会自动触发的事件如下: + +| 事件 | Matcher | 主要 payload | 行为 | +| --- | --- | --- | --- | +| `UserPromptSubmit` | 用户提交的文本内容 | `prompt`(`ContentPart[]` 数组) | 仅对真实 User 消息触发。hook 返回的文本会包裹为 hook 结果,写入会话历史用于 transcript/replay,并展示给用户;当前 LLM 轮次会继续,但不会把 hook 结果发给模型;若 hook 阻断,阻断原因会作为 Assistant 消息返回给用户,且不再调用模型;若所有 hook 均无输出,正常 LLM 轮次继续 | +| `PreToolUse` | 工具名 | `tool_name`、`tool_input`、`tool_call_id` | 在权限检查前触发;阻断后工具不会执行 | +| `PostToolUse` | 工具名 | `tool_name`、`tool_input`、`tool_call_id`、`tool_output` | 工具成功后触发;`tool_output` 被截断至前 2000 个字符 | +| `PostToolUseFailure` | 工具名 | `tool_name`、`tool_input`、`tool_call_id`、`error` | 工具失败或被 hook 阻断后触发 | +| `Stop` | 空字符串 | `stop_hook_active` | 模型准备停止时触发;阻断后会把原因直接作为系统触发的 User 消息追加进上下文,并最多继续一次 | +| `StopFailure` | 错误类型 | `error_type`、`error_message` | 当前轮次因非取消错误失败后触发 | +| `SessionStart` | `startup` 或 `resume` | `source` | 新会话主 Agent 创建后,或历史会话恢复完成后触发 | +| `SessionEnd` | `exit` | `reason` | 会话关闭并 flush 元数据后触发 | +| `SubagentStart` | 子 Agent 名称 | `agent_name`、`prompt` | 子 Agent 配置完成、真正开始运行前触发;`prompt` 被截断至前 500 个字符 | +| `SubagentStop` | 子 Agent 名称 | `agent_name`、`response` | 子 Agent 成功完成后异步触发,失败时不触发;`response` 被截断至前 500 个字符 | +| `PreCompact` | `manual` 或 `auto` | `trigger`、`token_count` | 上下文压缩真正开始前触发;此事件使用 `trigger`(非 `triggerBlock`)调用,返回值被完全忽略,阻断决策不会被读取 | +| `PostCompact` | `manual` 或 `auto` | `trigger`、`estimated_token_count` | 上下文压缩成功写入后异步触发;阻断结果不会改变主流程 | +| `Notification` | 通知类型 | `sink`、`notification_type`、`title`、`body`、`severity`、`source_kind`、`source_id` | 当前在后台子 Agent 结果写入上下文时触发;`notification_type` 取值为 `task.completed`、`task.failed`、`task.killed` 或 `task.lost`,sink 为 `context` | + +`UserPromptSubmit` 的返回文本会被包裹成一条 hook 结果: + +```xml + +hook response + +``` + +如果多个 `UserPromptSubmit` hook 返回文本,每个结果都会拥有独立的 `` 标签。这条消息会带有 hook 结果来源,用于 transcript/replay,但不会发给模型。模型只看到原始用户输入,当前轮次继续。 + +如果 `UserPromptSubmit` hook 阻断请求,阻断原因会使用同样格式返回给用户,但本轮不会继续请求模型。 + +`Stop` 的阻断原因会直接作为系统触发的 User 消息写入上下文,让当前轮次继续: + +```text +continue from hook +``` + +## 示例:阻断危险 Shell 命令 + +下面的 hook 会在 `Bash` 工具调用前读取 stdin 中的 `tool_input.command`。如果命令包含 `rm -rf`,脚本以退出码 `2` 结束并把原因写到 stderr: + +::: warning 注意 +这个示例只演示 hook 如何阻断工具调用,不是完整的 Shell 安全解析器。真实策略更适合使用 allowlist,或用专门的 Shell 解析逻辑处理引号、变量展开、别名和多段命令。 +::: + +```toml +[[hooks]] +event = "PreToolUse" +matcher = "Bash" +command = "node ~/.kimi-code/hooks/block-dangerous-bash.mjs" +timeout = 5 +``` + +```js +let input = ''; +process.stdin.on('data', (chunk) => { + input += chunk; +}); +process.stdin.on('end', () => { + const payload = JSON.parse(input); + const command = payload.tool_input?.command ?? ''; + if (command.includes('rm -rf')) { + console.error('Blocked dangerous shell command'); + process.exit(2); + } +}); +``` + +当 hook 阻断工具调用时,Kimi Code CLI 会把阻断原因作为工具失败结果写回上下文,模型可以据此选择更安全的替代方案。 diff --git a/docs/zh/customization/mcp.md b/docs/zh/customization/mcp.md new file mode 100644 index 000000000..997032f79 --- /dev/null +++ b/docs/zh/customization/mcp.md @@ -0,0 +1,83 @@ +# Model Context Protocol + +[Model Context Protocol (MCP)](https://modelcontextprotocol.io/) 是一个开放协议,让模型可以安全地调用外部进程或服务暴露的工具。Kimi Code CLI 作为 MCP client 接入这些外部工具,并把它们与内置工具(`Read`、`Bash`、`Grep` 等)一起暴露给 Agent 使用。 + +## 集成范围 + +Kimi Code CLI 支持通过 stdio(本地子进程)和 HTTP 两种方式接入外部 MCP 服务器。接入的 MCP 工具与内置工具一样,可以被 Agent 调用、受权限规则约束、参与审批流程,行为上没有差异。 + +## 配置与登录 + +MCP server 配置写在 `mcp.json` 中,分为两层: + +- 用户级:`~/.kimi-code/mcp.json`(或 `$KIMI_CODE_HOME/mcp.json`),跨项目共享 +- 项目级:`.kimi-code/mcp.json`,仅当前仓库 + +项目级覆盖用户级同名条目。 + +最方便的入口是在 TUI 中运行 `/mcp-config`,它会引导你新增、编辑或删除 server。要查看当前连接状态,可运行 `/mcp`。 + +`mcp.json` 的顶层结构如下: + +```json +{ + "mcpServers": { + "filesystem": { + "command": "npx", + "args": ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"] + }, + "linear": { + "url": "https://mcp.linear.app/mcp" + } + } +} +``` + +含 `command` 字段的条目为 stdio server,含 `url` 字段的条目为 HTTP server,通常不需要手写 `transport` 字段。HTTP server 支持通过 `headers` 或 `bearerTokenEnvVar` 提供静态凭证;需要 OAuth 时,可运行 `/mcp-config login ` 完成浏览器授权。 + +可选字段: + +| 字段 | 类型 | 适用 transport | 说明 | +| --- | --- | --- | --- | +| `env` | `Record` | stdio | 注入子进程的环境变量 | +| `cwd` | `string` | stdio | 子进程工作目录 | +| `headers` | `Record` | HTTP | 附加到每次请求的静态请求头 | +| `enabled` | `boolean` | 两者 | 设为 `false` 可禁用该 server | +| `startupTimeoutMs` | `number` | 两者 | 连接超时,默认 `30000` | +| `toolTimeoutMs` | `number` | 两者 | 单次工具调用超时 | +| `enabledTools` | `string[]` | 两者 | 白名单 | +| `disabledTools` | `string[]` | 两者 | 黑名单 | + +::: warning 注意 +项目级 `.kimi-code/mcp.json` 中的 stdio 条目会在会话启动时执行本地命令,只在你信任的仓库里启用。 +::: + +## 工具命名与权限 + +MCP 工具按 `mcp____` 命名。权限匹配支持 `*` 和 `**` 通配,例如 `mcp__github__*` 命中该 server 下所有工具。 + +未命中权限规则的调用会触发审批请求;在审批弹窗中选择 "Approve for this session" 后,后续同类调用将自动放行。 + +也可以在 `config.toml` 的 `[[permission.rules]]` 中预置永久规则: + +```toml +[[permission.rules]] +decision = "allow" +pattern = "mcp__github__*" + +[[permission.rules]] +decision = "deny" +pattern = "mcp__filesystem__write_file" +``` + +`pattern` 的完整语法及 `decision`、`scope` 等字段的取值详见 [配置文件](../configuration/config-files.md#permission)。 + +## 安全性 + +- 只接入可信来源的 MCP server +- 在审批请求中检查工具名与参数是否合理 +- 对高风险工具维持手动审批,避免宽泛的 `mcp__*` 通配放行 + +::: warning 注意 +在 YOLO 模式下,MCP 工具调用会被自动批准。仅在完全信任所接入的 MCP server 时使用此模式。 +::: diff --git a/docs/zh/customization/skills.md b/docs/zh/customization/skills.md new file mode 100644 index 000000000..9b0415675 --- /dev/null +++ b/docs/zh/customization/skills.md @@ -0,0 +1,127 @@ +# Agent Skills + +Agent Skills 是 Kimi Code CLI 用来扩展模型能力的轻量机制。一个 Skill 就是一份带 YAML frontmatter 的 Markdown 文档,描述某项专业知识或工作流程。Kimi Code CLI 在启动时自动扫描已知目录,把发现的 Skill 注入到系统提示词中,让 Agent 知道当前会话里有哪些可用的 Skill。 + +相比把同样的指引每次都粘到提示词里,Skill 的好处在于:内容沉淀在文件里、可以跨项目和团队复用、可以通过斜杠命令一键加载,也可以让模型在需要时自动调用。常见用法是把代码风格、提交规范、审查流程等固化为 Skill。 + +## 创建 Skill + +Skill 文件需要放在 [已知的扫描目录](#skill-存放位置) 中。一个 Skill 可以使用两种文件结构: + +- **目录形式(推荐)**:在 Skills 目录下创建一个子目录,主文件命名为 `SKILL.md`,必要时可在同目录下放置脚本、参考资料等辅助文件。如果同一目录下同时存在 `/SKILL.md` 和同名的 `.md`,以子目录为准。 +- **扁平形式**:直接使用单个 `.md` 文件,Skill 名称取文件名(去掉 `.md`)。 + +### 文件格式 + +`SKILL.md` 由 YAML frontmatter 和 Markdown 正文两部分组成。 + +```markdown +--- +name: code-style +description: 项目代码风格规范,定义命名、缩进、注释和文件组织 +type: prompt +whenToUse: 当用户让我编写、修改或审查项目源代码时 +disableModelInvocation: false +arguments: + - target + - mode +--- + +请按下述规范处理代码: + +- 缩进使用 2 空格 +- 变量名使用 `camelCase`,类型名使用 `PascalCase` +- 公开函数必须带 TSDoc 注释 +- 单行不超过 100 字符 +``` + +### Frontmatter 字段 + +| 字段 | 说明 | +| --- | --- | +| `name` | Skill 名称。目录型 `SKILL.md` 中为必填;扁平 `.md` 文件省略时使用文件名。名称大小写不敏感。 | +| `description` | 一行总结。模型用它来判断何时使用这个 Skill。目录型 `SKILL.md` 中为必填;扁平 `.md` 文件省略时回退到正文第一行非空内容(截到 240 字符)。 | +| `type` | Skill 类型。可选 `prompt`(默认)、`inline`(与 `prompt` 语义相同)、`flow`(仅支持手动调用,不支持模型自动调用)。其它值会被跳过。 | +| `whenToUse` | 触发场景描述。也接受 `when-to-use`、`when_to_use` 写法。 | +| `disableModelInvocation` | 设为 `true` 禁止模型自动调用此 Skill。也接受 `disable-model-invocation`、`disable_model_invocation` 写法。 | +| `arguments` | 命名参数列表,可写成字符串数组或空白分隔的字符串(如 `arguments: target mode`)。声明后,正文可用 `$` 读取参数;纯数字或空字符串会被忽略。 | + +::: warning 注意 +目录型 `SKILL.md` 中 `name` 和 `description` **必须**显式填写,省略任意一项均会导致解析失败。 +::: + +### 正文占位符 + +正文在发送给模型前会展开少量占位符: + +- `$ARGUMENTS`:调用时附带的完整原始参数字符串 +- `$ARGUMENTS[0]`、`$ARGUMENTS[1]` 及简写 `$0`、`$1`:按空白分词后的位置参数(从 0 开始) +- `$`:`arguments` 中声明的命名参数 +- `${KIMI_SKILL_DIR}`:当前 Skill 文件所在目录 + +位置参数支持单双引号包裹,如 `/skill:commit "fix login" patch` 中 `$0` 展开为 `fix login`。若正文不含任何参数占位符,调用时附带的文本会以 `\n\nARGUMENTS: <文本>` 的形式追加到正文末尾。 + +## Skill 存放位置 + +Kimi Code CLI 按作用域分四档扫描,越具体的作用域优先级越高: + +**Project > User > Extra > Built-in** + +用户级: + +- `~/.kimi-code/skills/` +- `~/.agents/skills/` + +项目级(项目根 = 工作目录向上最近的包含 `.git` 的目录): + +- `.kimi-code/skills/` +- `.agents/skills/` + +额外目录通过 `config.toml` 顶层的 `extra_skill_dirs` 字段声明: + +```toml +extra_skill_dirs = ["~/team-skills", ".agents/team-skills"] +``` + +内置 Skills 随 CLI 一起分发,优先级最低。 + +## 调用 Skill + +用户可以通过斜杠命令主动调用: + +``` +/skill:code-style +/skill:git-commits 修复登录接口的并发问题 +``` + +模型也可以根据 `description` 和 `whenToUse` 自动调用 Skill(除非 `disableModelInvocation` 设为 `true` 或 `type` 为 `flow`)。模型调用时,正文先展开占位符,再注入到系统提示中。Skill 调用时最多允许嵌套 3 层,超过后会被终止。 + +## 完整示例 + +```markdown +--- +name: review-pr +description: 按团队标准审查一个 Pull Request,输出结构化的 review 报告 +type: prompt +whenToUse: 当用户让我审查 PR、检查代码变更或评估提交质量时 +arguments: + - pr_ref +--- + +请按照以下流程审查用户指定的 PR:$pr_ref + +1. 拉取并阅读 `$pr_ref` 的全部 diff。 +2. 对照以下检查项逐条核对: + - 是否包含对应的测试用例 + - 公开 API 是否有文档更新 + - 是否引入了新的依赖;若有,说明引入理由 + - 错误处理是否覆盖了边界情况 +3. 参考同目录下的检查清单:`references/checklist.md` +4. 输出一份 review 报告,包含: + - 总体结论(approve / request changes / comment) + - 必须修改项(blocking) + - 建议改进项(non-blocking) + - 值得肯定的地方 +``` + +保存为 `~/.kimi-code/skills/review-pr/SKILL.md`,检查清单放在同目录的 `references/checklist.md`,然后重开会话,即可通过 `/skill:review-pr #1234` 调用,其中 `#1234` 会展开到 `$pr_ref`。 diff --git a/docs/zh/faq.md b/docs/zh/faq.md new file mode 100644 index 000000000..f575d64b3 --- /dev/null +++ b/docs/zh/faq.md @@ -0,0 +1,3 @@ +# 常见问题 + +本页汇总新用户最常遇到的问题与简短回答。如果某一项需要更完整的解释,会在答案末尾给出相应文档的链接。 diff --git a/docs/zh/guides/getting-started.md b/docs/zh/guides/getting-started.md new file mode 100644 index 000000000..36d519e22 --- /dev/null +++ b/docs/zh/guides/getting-started.md @@ -0,0 +1,135 @@ +# 开始使用 + +## Kimi Code CLI 是什么 + +Kimi Code CLI 是一个运行在终端中的 AI Agent,帮助你完成软件开发任务和日常的终端操作。它能阅读和编辑代码、执行 Shell 命令、搜索文件与抓取网页,并在执行过程中根据反馈自主规划和调整下一步行动。 + +它适用于以下场景: + +- **编写和修改代码**:实现新功能、修复 bug、完成重构 +- **理解项目**:探索陌生的代码库,解答架构和实现层面的问题 +- **自动化任务**:批量处理文件、运行构建与测试、串联多个脚本 + +整套 CLI 以 TypeScript 编写,通过 npm 分发,运行在 Node.js 之上。 + +## 安装 + +### 脚本安装(推荐) + +最快的安装方式是使用官方安装脚本,无需预装 Node.js: + +- **macOS / Linux**: + +```sh +curl -fsSL https://code.kimi.com/kimi-code/install.sh | bash +``` + +- **Windows(PowerShell)**: + +```powershell +irm https://code.kimi.com/kimi-code/install.ps1 | iex +``` + +脚本会自动下载最新版本、校验 checksum,并把 `kimi` 可执行文件放到你的 `PATH` 中。 + +### npm 安装 + +如果你更习惯通过 npm 安装,需要 Node.js 24.15.0 或更高版本: + +```sh +node --version +``` + +包名是 `@moonshot-ai/kimi-code`: + +```sh +npm install -g @moonshot-ai/kimi-code +``` + +或用 pnpm: + +```sh +pnpm add -g @moonshot-ai/kimi-code +``` + +## 升级与卸载 + +安装完成后,验证可执行文件是否就绪: + +```sh +kimi --version +``` + +**升级**:脚本安装的用户重新运行脚本即可;npm 安装的用户执行: + +```sh +npm install -g @moonshot-ai/kimi-code@latest +``` + +**卸载**:脚本安装的用户删除 `kimi` 可执行文件即可;npm 安装的用户执行: + +```sh +npm uninstall -g @moonshot-ai/kimi-code +``` + +## 第一次启动 + +进入你想要工作的项目目录,直接运行 `kimi` 启动交互界面: + +```sh +cd your-project +kimi +``` + +如果只想执行一条指令而不进入交互界面,可以使用 `-p` 选项: + +```sh +kimi -p "帮我看一下这个项目的目录结构" +``` + +如需继续上一次会话,添加 `-C` 选项即可: + +```sh +kimi -C +``` + +首次启动时,Kimi Code CLI 尚未配置任何凭证,需要配置 API 来源才能开始对话。在交互界面中输入斜杠命令 `/login` 进入登录流程: + +``` +/login +``` + +`/login` 会弹出平台选择器,支持: + +- **Kimi Code** — OAuth 验证码流程,在任意设备打开链接、登录并输入验证码即可授权 +- **Moonshot AI Open Platform** — 直接输入 API key 登录 + +需要退出登录时,输入 `/logout` 即可清除当前凭证。 + +::: tip 提示 +如果你想使用 Anthropic、OpenAI、Google 等其他供应商,需要直接编辑 `config.toml` 配置 API 密钥,详见 [平台与模型](../configuration/providers.md)。模型、供应商等运行时配置也写入 `config.toml`。配置项说明见 [配置文件](../configuration/config-files.md)、[环境变量](../configuration/env-vars.md) 和 [配置覆盖](../configuration/overrides.md)。 +::: + +## 第一个对话 + +登录完成后,你就可以直接用自然语言向 Kimi Code CLI 描述任务。例如,让它先帮你熟悉一下当前项目: + +``` +帮我看一下这个项目的目录结构,简单介绍一下每个目录是做什么的 +``` + +Kimi Code CLI 会自动调用文件读取、搜索和网页抓取工具,浏览相关内容之后再给出回答(读取文件、搜索网页等只读操作默认自动执行,无需确认)。对于会修改文件或执行 Shell 命令的操作,它默认会在执行前征求你的确认,你可以根据需要批准或拒绝。 + +也可以让它做一些更具体的事,比如: + +``` +在 src/utils 里新增一个函数,用来把任意字符串转成 kebab-case,并补一个单元测试 +``` + +Kimi Code CLI 会规划步骤、修改代码、运行测试,并在每一步告诉你它做了什么。 + +在交互界面中,输入 `/help` 可以查看所有可用的 [斜杠命令](../reference/slash-commands.md),以及常用的快捷键提示。如果想退出 Kimi Code CLI,可以输入 `/exit`;也可以按 `Ctrl-C`,界面会先清空输入框并提示再按一次,再次按下即退出;还可以在输入框为空时连按两次 `Ctrl-D` 退出。 + +## 数据存放在哪里 + +Kimi Code CLI 的本地数据默认保存在 `~/.kimi-code/` 目录下,包含配置文件、会话记录、日志和更新缓存等。如果你想改到别的位置,可以通过环境变量 `KIMI_CODE_HOME` 指定一个新的根目录。完整的目录结构和环境变量说明见 [数据路径](../configuration/data-locations.md) 和 [环境变量](../configuration/env-vars.md)。 diff --git a/docs/zh/guides/interaction.md b/docs/zh/guides/interaction.md new file mode 100644 index 000000000..81985f119 --- /dev/null +++ b/docs/zh/guides/interaction.md @@ -0,0 +1,67 @@ +# 交互与输入 + +Kimi Code CLI 以交互式 TUI 运行在终端中,核心是一个输入框、对话视图和状态栏。本节介绍输入操作、模式切换、审批流程和常用快捷键,完整清单可通过 `/help` 查看。 + +## 输入框基本操作 + +输入框接受自由文本,按 `Enter` 发送,`Shift-Enter` 换行。输入框为空时按 `↑` / `↓` 可以浏览当前工作目录的输入历史。 + +按 `Ctrl-D` 两次退出 CLI。`Ctrl-C` 在流式输出中会中断当前轮次,空闲状态下连按两次退出。`Esc` 关闭弹窗,流式输出中也会中断轮次。 + +## 斜杠命令与 `@` 提及 + +以 `/` 开头的内容会识别为斜杠命令,涵盖会话管理、模式切换、配置等场景,如 `/help`、`/new`、`/sessions`、`/model` 等。完整清单见 [斜杠命令参考](../reference/slash-commands.md)。 + +输入 `/` 后会弹出命令补全菜单,也包括来自 [Agent Skills](../customization/skills.md) 的命令。若 skill 与内置命令重名,需要用完整形式 `/skill:` 调用。按 `Esc` 关闭菜单。 + +部分命令仅在 Agent 空闲时可用,流式输出中需先中断才能调用。`/yolo`、`/plan`、`/help` 等模式切换类命令则始终可用。 + +键入 `@` 触发文件路径补全,选中后插入相对路径,Agent 可直接读取对应文件。以点开头的目录默认隐藏,如需可显式写成 `@.github/`。 + +## Plan 模式 + +Plan 模式下,Agent 会先把计划写清楚再动手。按 `Shift-Tab` 或 `/plan` 切换;`/plan clear` 清除当前计划文件(仅限空闲状态)。 + +Agent 会先输出方案等待确认,不会直接修改项目文件。计划完成后会展示内容供你审批,可选择拒绝(保持 Plan 模式)或要求修改。退出 Plan 模式仍需你确认,即使开启了 YOLO 模式也如此。 + +## YOLO 模式 + +YOLO 模式自动批准大多数工具调用,跳过审批确认。输入 `/yolo`(或 `/yes`)切换,空闲或流式状态下都可用。 + +::: warning 注意 +YOLO 模式跳过普通审批,但不会跳过 Plan 模式的退出审批。 +::: + +## 审批流程 + +Agent 调用可能产生副作用的工具(如修改文件、执行命令)时,会弹出审批面板让你确认。YOLO 模式和 Plan 模式下的计划文件写入除外。 + +使用方向键选择选项,`Enter` 确认;按数字键 `1`/`2`/`3` 可直接选择对应位置。`Esc`、`Ctrl-C`、`Ctrl-D` 等同于拒绝。面板中通常有「Approve for this session」选项,选择后同类调用将自动放行。 + +## 流式输出期间的操作 + +Agent 思考或调用工具(流式输出)时,输入框仍可用: + +- `Esc` — 中断当前轮次。 +- `Ctrl-C` — 同样可以中断;空闲状态下连按两次退出 CLI。 +- `Ctrl-S` — 把输入框中的内容作为追加消息插入当前轮次。 +- `Ctrl-O` — 全局切换工具输出的折叠状态。 + +## 外部编辑器 + +按 `Ctrl-G` 把当前输入交给外部编辑器处理,保存后内容回填到输入框。 + +编辑器优先级:`/editor` 配置 > `$VISUAL` > `$EDITOR`。未配置时可先运行 `/editor` 选择默认编辑器。 + +## 粘贴图片与视频 + +支持在输入框中直接粘贴剪贴板里的图片或视频,交给多模态模型处理: + +- Unix(macOS / Linux):`Ctrl-V` +- Windows:`Alt-V` + +粘贴后输入框中会出现占位符,可像普通文本一样编辑,发送时自动替换为实际内容。纯文本剪贴板会回退到普通粘贴。媒体附件仅保留在当前会话中。 + +## 查看完整快捷键 + +输入 `/help` 弹出包含全部快捷键和斜杠命令的面板。`↑` / `↓` 滚动,`PageUp` / `PageDown` 翻页,`Esc` / `Enter` / `q` 关闭。 diff --git a/docs/zh/guides/migration.md b/docs/zh/guides/migration.md new file mode 100644 index 000000000..7a1b25819 --- /dev/null +++ b/docs/zh/guides/migration.md @@ -0,0 +1,37 @@ +# 从 kimi-cli 迁移 + +Kimi Code CLI 是新一代的终端 Agent,也是一个全新的起点。如果你一直在使用上一代的 kimi-cli,不必从头开始——一条命令就能把配置、MCP server 与会话历史一起迁移到新版本。 + +## 为什么迁移 + +kimi-code 基于 Node.js 重写,不再依赖 Python 或 `uv` 工具链,安装和升级更简单,也提供开箱即用的原生二进制。 + +终端界面重新设计,启动更快,运行更轻量。 + +kimi-cli 会逐步过渡到 kimi-code,迁移后你可以继续使用已有的配置和会话历史。 + +## 如何迁移 + +迁移有两种方式。 + +装好 kimi-code 之后**第一次运行 `kimi`** 时,它会自动检测 `~/.kimi/` 下是否存在 kimi-cli 的数据。一旦检测到,就会弹出迁移提示,你可以选择立即迁移、稍后再说,或不再提示。 + +你也可以**随时手动运行**: + +```sh +kimi migrate +``` + +你可以选择是否同时迁移聊天会话。如果暂时不需要历史记录,选 **Config only**;否则选 **Config + N sessions** 一并迁移。结束后会显示结果摘要。 + +## 迁移会发生什么 + +**会被迁移的内容**:配置(`config.toml`)、MCP 服务配置、输入历史,以及你选择迁移的聊天会话。 + +**不会被迁移的内容**:OAuth 登录凭证和 MCP 服务的授权都不会被复制,迁移后需要在 kimi-code 里重新执行 `/login` 和重新授权 MCP 服务。kimi-cli 的插件也不在迁移范围内。 + +::: tip 提示 +迁移**不会改动或删除** `~/.kimi/` 下的任何旧数据。kimi-cli 仍可照常使用,两者互不影响。迁移也可以重复运行,已经迁移过的会话不会被重复导入。 +::: + +迁移完成后,从 kimi-cli 导入的会话会带上 `[imported]` 标记,方便你与新建的会话区分。 diff --git a/docs/zh/guides/sessions.md b/docs/zh/guides/sessions.md new file mode 100644 index 000000000..03bcdd9cd --- /dev/null +++ b/docs/zh/guides/sessions.md @@ -0,0 +1,110 @@ +# 会话与上下文 + +Kimi Code CLI 把每次对话持久化为一个「会话」,保留消息历史和元数据,可以随时关闭终端后再回来继续。本节介绍恢复会话、上下文压缩和 TUI 内的管理方法。 + +## 会话存储 + +所有会话保存在 `$KIMI_CODE_HOME/sessions/` 下(默认 `~/.kimi-code/sessions/`),按工作目录分组存放: + +```text +~/.kimi-code/ +├── config.toml +├── session_index.jsonl +└── sessions/ + └── / + └── / + ├── state.json + └── agents/ + ├── main/ + │ └── wire.jsonl + └── / + └── wire.jsonl +``` + +- `state.json` — 会话标题、元数据等。 +- `agents/*/wire.jsonl` — Agent 事件流。 + +::: warning 注意 +`sessions/` 目录下的文件手动修改后可能导致会话无法恢复,建议不要手工编辑。 +::: + +## 启动与恢复会话 + +默认每次执行 `kimi` 都会创建新会话。如果想接着上一次继续: + +**继续当前目录最近的会话:** + +```sh +kimi --continue +``` + +**恢复指定会话:** + +```sh +kimi --session abc123 +``` + +也可以带 `-r` / `--resume`,效果相同。 + +**交互式选择:** + +```sh +kimi --session +``` + +::: warning 注意 +`--continue` 与 `--session` 互斥;`--yolo` 和 `--plan` 也不能与它们共用。 +::: + +## 在 TUI 中切换会话 + +- `/new`(`/clear`):切换到新会话。 +- `/sessions`(`/resume`):浏览并恢复历史会话。 +- `/fork`:派生当前会话(详见下文)。 +- `/title `(`/rename`):设置会话标题,方便识别。不带参数时显示当前标题。 + +`/sessions` 在流式输出期间也能浏览,但切换前需先按 `Esc` 或 `Ctrl-C` 中断。`/new`、`/fork`、`/compact` 仅在空闲时可用。 + +## 上下文压缩 + +对话变长时,Kimi Code CLI 会在上下文接近窗口上限时自动压缩历史消息。你也可以手动触发: + +```text +/compact +``` + +带上自定义指引,告诉模型压缩时优先保留哪些信息: + +```text +/compact 保留与数据库迁移相关的讨论 +``` + +## 派生会话 + +想在不破坏当前对话的前提下尝试新思路,使用 `/fork`: + +```text +/fork +``` + +派生后的会话彼此独立,不影响原会话,你随时可以切回。 + +## 导出会话 + +用 `kimi export` 打包会话为 ZIP: + +```sh +kimi export +``` + +不传 `sessionId` 时导出当前目录最近的会话(会交互式确认,加 `-y` 跳过)。用 `-o` 指定输出路径: + +```sh +kimi export -o ~/Desktop/my-session.zip +``` + +未指定 `-o` 时,ZIP 写入当前工作目录。会话目录里的诊断日志会一并打包;此外,全局诊断日志 `$KIMI_CODE_HOME/logs/kimi-code.log`(记录 TUI 启动、登录等不属于任何会话的事件)默认也会包含进来,不需要时加 `--no-include-global-log` 跳过。 + +::: tip 提示 +导出文件可能包含敏感信息,分享前请确认内容。 +::: diff --git a/docs/zh/guides/use-cases.md b/docs/zh/guides/use-cases.md new file mode 100644 index 000000000..3c70365ba --- /dev/null +++ b/docs/zh/guides/use-cases.md @@ -0,0 +1,118 @@ +# 常见使用案例 + +以下是 Kimi Code CLI 的典型使用场景和 prompt 示例。 + +## 理解陌生项目 + +接手陌生仓库时,用 `kimi --plan` 或按 `Shift-Tab` 进入 Plan 模式,让 Agent 先输出调研计划: + +``` +帮我梳理这个仓库的整体架构。重点说清楚: +1. 入口在哪里,启动后做了什么 +2. 主要模块之间的依赖关系 +3. 配置和数据的加载流程 +最后画一张简单的模块关系图。 +``` + +聚焦问题直接问: + +``` +src/runtime 下的 event loop 是怎么工作的?事件从哪里产生、又被谁消费? +``` + +``` +这个项目里「权限审批」是怎么实现的?涉及哪些文件,关键类型是什么? +``` + +大型调研可以让主 Agent 派生 **子 Agent** 并行处理子任务,详见 [Agents](../customization/agents.md)。 + +## 实现新功能 + +描述清楚需求和验收标准即可。复杂需求建议先用 Plan 模式生成方案。 + +``` +在 src/utils 下新增一个 retry 工具: +- 函数签名 retry(fn: () => Promise, options): Promise +- 支持 maxAttempts、initialDelayMs、backoffFactor 三个选项 +- 失败时抛出最后一次的错误 +- 并补一组单元测试覆盖成功、重试后成功、全部失败三种情况 +``` + +如不满意,直接告诉助手即可,无需手动编辑: + +``` +backoff 算了一个固定值,我希望加一点抖动,避免雷击效应。改一下并更新测试。 +``` + +## 修复 bug + +把现象、复现条件和期望行为一次性说清楚: + +``` +跑 npm test 时偶发地报这个错: + + TypeError: Cannot read properties of undefined (reading 'id') + at SessionStore.update (src/session/store.ts:142:18) + +只在并发触发多个 update 的用例里出现。帮我定位原因并修复,最后跑一次完整测试确认。 +``` + +不确定原因时,让它先调查: + +``` +用户反馈:登录成功后第一次刷新页面会回到登录页,再刷一次就正常了。先帮我排查可能的原因,列出几个最可疑的位置,等我确认方向后再动手改。 +``` + +纯机械任务直接放手: + +``` +跑一遍测试,失败的用例都修掉,跑完再跑一次确认全绿。 +``` + +## 写测试与重构 + +边界清晰、验收标准明确的任务特别适合交给 Agent: + +``` +src/parser/markdown.ts 目前几乎没有测试。请补一组单元测试,覆盖正常段落、嵌套列表、代码块、表格、引用块和混合场景。用项目里已有的测试风格。 +``` + +``` +把 src/handlers 下重复的「读 body → 校验 → 写日志 → 返回」逻辑抽成一个中间件。改完跑一遍测试,保证现有行为不变。 +``` + +多文件重构建议先用 Plan 模式确认方案,可用 `/fork` 试探替代方案。 + +## 一次性脚本与自动化任务 + +批量改文件、跑统计、调研对比等任务用一段 prompt 就能完成: + +``` +把 src 目录下所有 .js 文件里的 var 声明改成 const 或 let,能用 const 的优先用 const。改完跑一次 lint 确认。 +``` + +``` +分析 logs/ 下最近 7 天的访问日志,按接口路径统计调用次数、p50 和 p99 响应时间,结果输出成一个 markdown 表格。 +``` + +``` +帮我调研一下 TypeScript 里几种主流的依赖注入方案(tsyringe、inversify、awilix),从 API 风格、装饰器依赖、运行时开销三个维度对比,给一份不超过一页的建议。 +``` + +用 `--yolo` 或 `/yolo` 跳过审批,或用 [permission 配置](../configuration/config-files.md#permission) 给特定工具加白名单。 + +## 生成与维护文档 + +``` +我刚改了 src/auth/login.ts 的接口签名,把对应的 JSDoc、README 里的示例代码、还有 docs/zh/guides 下提到这个接口的段落都同步更新一遍。 +``` + +``` +src/api 下所有公开函数里,凡是没有 docstring 的都补上文档注释,风格参考已有的注释。 +``` + +``` +根据 src/cli 下的命令实现,生成一份命令参考的草稿,列出每个子命令、参数和默认值,放到 docs/zh/reference 下我后续审阅。 +``` + +需要留档或复盘时,用 `kimi export ` 打包为 ZIP。 diff --git a/docs/zh/index.md b/docs/zh/index.md new file mode 100644 index 000000000..b93405a77 --- /dev/null +++ b/docs/zh/index.md @@ -0,0 +1,13 @@ +--- +layout: home +hero: + name: Kimi Code CLI + text: The Starting Point for Next-Gen Agents + actions: + - theme: brand + text: 开始使用 + link: guides/getting-started + - theme: alt + text: GitHub + link: https://github.com/MoonshotAI/kimi-code +--- diff --git a/docs/zh/reference/keyboard.md b/docs/zh/reference/keyboard.md new file mode 100644 index 000000000..d59b249e0 --- /dev/null +++ b/docs/zh/reference/keyboard.md @@ -0,0 +1,112 @@ +# 键盘快捷键 + +Kimi Code CLI 的 TUI 交互模式支持以下键盘快捷键。键位主要在输入框中生效,部分键位在弹窗(如 `/help` 帮助面板、审批面板)或流式输出(streaming)期间有不同的行为。 + +在 TUI 中输入 `/help` 可以随时打开内置的快捷键清单。 + +## 通用快捷键 + +下列键位在输入框中始终可用: + + +| 快捷键 | 功能 | +| ------------- | ------------------------------------------- | +| `Enter` | 提交当前输入 | +| `Shift-Enter` | 在输入中插入换行 | +| `↑` / `↓` | 浏览输入历史 | +| `Esc` | 关闭弹窗 / 取消补全 / 中断流式输出或正在进行的上下文压缩(compaction) | +| `Ctrl-C` | 中断当前流式输出,或清空输入框 | +| `Ctrl-D` | 在输入框为空时退出 Kimi Code CLI | + + +**流式输出期间**按 `Ctrl-C` 会立即取消,无需二次确认。 + +**退出程序**(输入框为空时按 `Ctrl-C`,或按 `Ctrl-D`)使用「双击确认」机制:第一次按下时,状态栏会显示提示(例如 `Press Ctrl+C again to exit`),再按一次相同的键才会真正退出。如果中途按了其他键,确认状态会自动清除。 + +## 模式切换 + + +| 快捷键 | 功能 | +| ----------- | ---------- | +| `Shift-Tab` | 切换 Plan 模式 | + + +按 `Shift-Tab` 可以开启或关闭 Plan 模式。开启后,Agent 会优先使用只读工具进行研究和规划,并可以写入当前计划文件;必要时也能调用 `Bash`,它会按当前权限模式和普通规则处理,不会因为处于 Plan 模式而额外发起独立审批。单纯切换模式不会创建空计划文件。再次按 `Shift-Tab` 即可退出 Plan 模式。 + +## 输入与编辑 + + +| 快捷键 | 功能 | +| -------- | --------------------------------------- | +| `Ctrl-G` | 在外部编辑器中编辑当前输入 | +| `Ctrl-V` | 粘贴剪贴板中的图片或视频(Unix / macOS) | +| `Alt-V` | 粘贴剪贴板中的图片或视频(Windows) | +| `Ctrl-E` | 展开或折叠 Plan 卡片(无 Plan 卡片时按系统默认行为将光标移到行尾) | +| `Ctrl--` | 撤销(Undo) | + + +按 `Ctrl-G` 会打开外部编辑器,编辑当前输入内容。编辑器按以下优先级选择: + +1. `/editor` 命令配置的编辑器 +2. `$VISUAL` 环境变量 +3. `$EDITOR` 环境变量 + +保存并退出编辑器后,编辑后的内容会替换输入框中的内容;不保存退出则保持原样。 + +粘贴图片或视频时,会在输入框中显示为占位符,实际媒体数据在提交时一并发送给模型。粘贴的优先来源是系统剪贴板里的图片或文件路径;Linux 上 Wayland 与 X11 都会被尝试,WSL 下还会通过 PowerShell 兜底读取 Windows 剪贴板。 + +## 流式输出期间 + +流式输出(streaming)期间,输入框依然可以接收输入,并支持以下额外操作: + + +| 快捷键 | 功能 | +| -------- | ---------------------- | +| `Ctrl-S` | Steer:将当前输入立即注入正在运行的轮次 | +| `Esc` | 中断当前流式输出 | +| `Ctrl-C` | 中断当前流式输出 | + + +按 `Ctrl-S` 时,模型会在下一个可中断的时机立刻看到你的消息,无需等待当前轮次结束。 + +## 工具输出 + + +| 快捷键 | 功能 | +| -------- | --------- | +| `Ctrl-O` | 展开或折叠工具输出 | + + +当历史中存在折叠的工具调用结果时,按 `Ctrl-O` 可以在折叠和展开之间切换,便于查看完整的工具输出。 + +## 审批面板 + +当 Agent 发起需要确认的工具调用时,TUI 会弹出审批面板。详细的审批流程见 [交互与输入](../guides/interaction.md#审批流程),下表给出面板内可用的键位: + + +| 快捷键 | 功能 | +| --------------------------- | ----------------------------- | +| `↑` / `↓` | 在候选选项之间移动光标 | +| `Enter` | 确认当前选中的选项 | +| `1` ~ `9` | 直接选择对应序号的选项 | +| `Esc` / `Ctrl-C` / `Ctrl-D` | 拒绝当前请求 | +| `Ctrl-E` | 当面板包含 diff 或文件内容预览时,展开或折叠完整内容 | +| `Ctrl-O` | 切换其他工具输出的折叠状态 | + + +需要附带反馈的选项(例如「Reject」「Revise」)会在确认后切换到反馈输入态:直接输入反馈文本,按 `Enter` 提交;按 `Esc` 退出反馈输入并回到候选列表。 + +## 弹窗模式 + +输入 `/help` 打开帮助面板后,可使用以下键位浏览和关闭面板: + + +| 快捷键 | 功能 | +| --------------------- | ---------- | +| `↑` / `↓` | 单行滚动 | +| `PageUp` / `PageDown` | 每次滚动 10 行 | +| `Esc` | 关闭面板 | +| `Enter` | 关闭面板 | +| `q` / `Q` | 关闭面板 | + + diff --git a/docs/zh/reference/kimi-command.md b/docs/zh/reference/kimi-command.md new file mode 100644 index 000000000..78365bda8 --- /dev/null +++ b/docs/zh/reference/kimi-command.md @@ -0,0 +1,154 @@ +# kimi 命令 + +`kimi` 是 Kimi Code CLI 的主命令,用于在终端中启动一次交互式会话。不带任何参数运行时,它会在当前工作目录下开启一个新会话;配合不同的 flag,可以续上历史会话、跳过审批、从 Plan 模式开始,或者指定自定义的 Skills 目录。 + +```sh +kimi [options] +kimi [options] +``` + +## 主命令选项 + +下表列出 `kimi` 主命令支持的全部选项。所有 flag 都是可选的,直接运行 `kimi` 即可进入交互式会话。 + +| 选项 | 简写 | 说明 | +| --- | --- | --- | +| `--version` | `-V` | 打印版本号并退出。 | +| `--help` | `-h` | 显示帮助信息并退出。 | +| `--session [id]` | `-S` | 恢复一个会话。带 ID 时直接打开指定会话;不带 ID 时进入交互式选择器,从历史会话中挑选。 | +| `--continue` | `-C` | 继续当前工作目录下最近一次的会话,无需手动指定 ID。 | +| `--model ` | `-m` | 为本次启动指定模型别名。省略时,新会话使用配置文件中的 `default_model`,恢复会话使用会话当前模型。 | +| `--prompt ` | `-p` | 非交互执行单次 prompt,并把 Assistant 输出流式写到 stdout。该模式会使用 `auto` 权限处理工具调用,不会打开 TUI。 | +| `--output-format ` | | 设置非交互输出格式,支持 `text` 与 `stream-json`。仅可与 `--prompt` 一起使用,默认 `text`。 | +| `--yolo` | `-y` | 自动批准普通工具调用,跳过审批请求;Plan 模式中的 `Bash` 审批和退出审批不会被跳过。 | +| `--plan` | | 以 Plan 模式启动新会话,AI 会优先使用只读工具进行探索和规划,可以写入当前计划文件;Plan 模式中的 `Bash` 按权限模式单独处理。 | +| `--skills-dir ` | | 从指定目录加载 Skills,替换自动发现的用户和项目目录。可重复传入以叠加多个目录。详见下文 [自定义 Skills 目录](#自定义-skills-目录)。 | + +`-r` / `--resume` 是 `--session` 的隐藏别名;`--yes` 和 `--auto-approve` 是 `--yolo` 的隐藏别名。它们在帮助信息中不会显示,行为与对应的官方 flag 完全一致。 + +::: warning 注意 +`--yolo` 会跳过普通工具调用的人工确认,包括文件写入和 Shell 命令执行。请只在受信任的工作目录下使用。Plan 模式的退出审批不会被 `--yolo` 跳过;Plan 模式下的 `Bash` 也按 `--yolo` 的普通放行规则处理。 +::: + +### flag 冲突规则 + +以下组合会在启动时被拒绝: + +- `--continue` 与 `--session` 互斥:两者都表示"恢复历史会话",含义重叠。 +- `--yolo` 不能与 `--continue` 或 `--session` 同时使用:恢复会话时会沿用原会话的审批设置。此规则仅适用于交互式模式;在 `--prompt` 模式下,`--yolo` 已因与 `--prompt` 互斥而被更早拦截。 +- `--plan` 不能与 `--continue` 或 `--session` 同时使用:Plan 模式只对新会话生效。 +- `--prompt` 不能与 `--yolo` 或 `--plan` 同时使用:非交互模式固定使用 `auto` 权限,并且不进入 Plan 模式。 +- `--prompt` 可以与 `--continue` 或带 ID 的 `--session ` 一起使用;不带 ID 的 `--session` 会尝试打开选择器,因此不能用于非交互模式。 +- `--output-format` 只能与 `--prompt` 一起使用;交互式 TUI 不支持把完整事件流写成 stdout JSONL。 + +如果需要在恢复会话时强制使用 YOLO 或 Plan 模式,请改在交互式会话内通过斜杠命令切换。 + +## 典型用法 + +最常见的入口是直接运行 `kimi`,在当前目录开启一次全新的会话: + +```sh +kimi +``` + +如果上一次会话被打断(关闭终端、网络断开等),想从断点继续,使用 `--continue`: + +```sh +kimi --continue +``` + +它会自动找到当前工作目录下时间最近的那个会话并恢复。若想挑选其他历史会话,运行 `kimi --session` 进入交互式选择器,或者直接传入已知的 session ID: + +```sh +kimi --session 01HZ...XYZ +``` + +当任务比较琐碎,不希望被频繁的审批请求打断时,可以加上 `--yolo`: + +```sh +kimi --yolo +``` + +需要先让 AI 阅读代码、产出实现计划,而不是立刻动手修改文件时,使用 `--plan` 进入 Plan 模式: + +```sh +kimi --plan +``` + +### 自定义 Skills 目录 + +如果需要加载自定义的 Skills 目录,可以通过两种方式指定: + +- **CLI flag `--skills-dir `**:可重复传入,会**替换**自动发现的用户和项目目录,适合临时切换或在脚本中使用。例如同时挂载两个目录: + + ```sh + kimi --skills-dir /path/to/team-skills --skills-dir ./local-skills + ``` + +- **`config.toml` 的 `extra_skill_dirs`**:在配置文件中追加额外目录,与自动发现的目录**叠加**生效,适合长期配置团队共享 Skills(详见 [Agent Skills](../customization/skills.md))。 + +## 非交互执行 + +需要在脚本或 CI 中运行单次 prompt 时,使用 `-p`: + +```sh +kimi -p "Summarize the current repository status" +``` + +输出采用 transcript 样式:thinking 内容和 Assistant 正文都会以 `• ` 开头,换行后使用两个空格缩进。Assistant 正文会输出到 stdout;thinking、工具进度和 `To resume this session: kimi -r ` 提示输出到 stderr。`-p` 模式不会请求人工审批,普通工具调用、Plan 审批和 Agent 提问都会按 `auto` 权限策略处理。静态 deny 规则仍然会阻止匹配的工具调用。 + +需要临时切换模型时,加上 `-m`: + +```sh +kimi -m kimi-code/kimi-for-coding -p "Explain the latest diff" +``` + +如果脚本需要结构化读取输出,可以使用 JSONL: + +```sh +kimi -p "List changed files" --output-format stream-json +``` + +`stream-json` 模式下,stdout 每行都是一个 JSON 对象。普通回复会输出 Assistant 消息;如果模型调用工具,会先输出带 `tool_calls` 的 Assistant 消息,再输出对应的 Tool 消息,最后继续输出后续 Assistant 消息。thinking 内容不会写入 JSONL;工具进度和恢复会话提示仍然写到 stderr。 + +## 子命令 + +### `kimi export` + +把一个会话打包成 ZIP 文件,便于分享、归档或者提交问题反馈。导出的压缩包包含会话目录下的所有文件,例如上下文记录、状态文件和会话诊断日志(如果该会话已经产生 `logs/kimi-code.log`)。 + +```sh +kimi export [sessionId] [options] +``` + +| 参数 / 选项 | 简写 | 说明 | +| --- | --- | --- | +| `sessionId` | | 要导出的会话 ID。省略时会自动选择当前工作目录下最近一次的会话,并要求确认。 | +| `--output ` | `-o` | 输出 ZIP 文件路径。省略时写入当前目录下的默认文件名。 | +| `--yes` | `-y` | 跳过默认会话的确认提示,直接导出。 | +| `--no-include-global-log` | | 不打包当前活动的全局诊断日志,即 `~/.kimi-code/logs/kimi-code.log`。默认包含。 | + +默认导出包含目标会话目录内的文件;如果会话目录里有 `logs/kimi-code.log`,会一并出现在 ZIP 的 `logs/kimi-code.log`。全局诊断日志 `~/.kimi-code/logs/kimi-code.log` 默认也会打包,因为它可能包含其它会话或其它项目的事件,如果不想分享可以加 `--no-include-global-log`。加上后,ZIP 内路径是 `logs/global/kimi-code.log`,不会包含轮转出来的 `kimi-code.log.1` 等旧文件。 + +省略 `sessionId` 时,命令会先打印待导出的会话信息并请求确认;用 `-y` 可以跳过确认,适合在脚本里使用: + +```sh +# 导出当前工作目录最近一次会话,跳过确认 +kimi export -y + +# 导出指定会话到自定义路径 +kimi export 01HZ...XYZ -o ./bug-report.zip + +# 排除全局诊断日志,避免分享其它会话的事件 +kimi export 01HZ...XYZ -o ./bug-report.zip --no-include-global-log +``` + +### `kimi migrate` + +将旧版 kimi-cli 的本地数据迁移到 kimi-code。该命令无任何 flag,纯交互式运行,会引导你完成数据迁移的全流程。 + +```sh +kimi migrate +``` + +如果你之前使用过旧版 kimi-cli,可以运行此命令将历史会话、配置等数据迁移到 kimi-code 中,避免数据丢失。完整的迁移流程、迁移内容与注意事项见 [从 kimi-cli 迁移](../guides/migration.md)。 diff --git a/docs/zh/reference/slash-commands.md b/docs/zh/reference/slash-commands.md new file mode 100644 index 000000000..ddecaed21 --- /dev/null +++ b/docs/zh/reference/slash-commands.md @@ -0,0 +1,86 @@ +# 斜杠命令 + +斜杠命令是 Kimi Code CLI 在交互式 TUI 中提供的内置控制命令,用于切换模式、管理会话、查看状态等。在输入框中输入 `/` 即可触发命令补全,候选列表会随后续字符实时过滤;命令的别名(alias)也会一并参与匹配。 + +输入完整命令名(如 `/help`)后按 `Enter` 即可执行。如果输入的 `/` 开头内容不匹配任何内置或 Skill 命令,则按普通消息发送给 Agent。 + +::: tip 提示 +部分命令仅在空闲(idle)状态下可用。会话正在流式输出或正在压缩上下文时执行这些命令会被拦截,并提示先按 `Esc` 或 `Ctrl-C` 中断当前操作。下表的 「随时可用」 列标注了在流式输出 / 上下文压缩期间也可用的命令。 +::: + +## 账号与配置 + +| 命令 | 别名 | 说明 | 随时可用 | +| --- | --- | --- | --- | +| `/login` | — | 选择账号或平台并登录:Kimi Code 走 OAuth device code 流程,Moonshot AI 开放平台通过 API 密钥登录。 | 否 | +| `/logout` | — | 清除当前所选账号的凭据(Kimi Code OAuth 凭据,或对应开放平台的供应商配置)。 | 否 | +| `/model` | — | 切换当前会话使用的 LLM 模型。 | 是 | +| `/settings` | `/config` | 打开 TUI 内的设置面板。 | 是 | +| `/permission` | — | 选择权限模式(permission mode)。 | 是 | +| `/editor` | — | 配置 `Ctrl-G` 调起的外部编辑器。 | 是 | +| `/theme` | — | 切换终端 UI 配色主题。 | 是 | + +## 会话管理 + +| 命令 | 别名 | 说明 | 随时可用 | +| --- | --- | --- | --- | +| `/new` | `/clear` | 开启一个全新会话,丢弃当前上下文。 | 否 | +| `/sessions` | `/resume` | 浏览历史会话并切换/恢复。 | 是 | +| `/tasks` | `/task` | 浏览后台任务列表。 | 是 | +| `/fork` | — | 基于当前会话 fork 一份新会话,保留完整对话历史。 | 否 | +| `/title []` | `/rename` | 不带参数时显示当前会话标题;带参数时将其设置为新标题(最长 200 个字符)。 | 是 | +| `/compact []` | — | 压缩当前对话上下文,释放 token 占用;可选附带一段自定义指令,提示模型在压缩时保留哪些信息。 | 否 | +| `/init` | — | 分析当前代码库并生成 `AGENTS.md`。 | 否 | + +## 模式与运行控制 + +| 命令 | 别名 | 说明 | 随时可用 | +| --- | --- | --- | --- | +| `/yolo [on\|off]` | `/yes` | 切换自动批准模式(auto-approve)。不带参数时按当前状态翻转;显式传 `on`/`off` 时强制设为对应状态。开启后跳过普通工具调用审批;Plan 模式的退出审批不会被跳过。 | 是 | +| `/plan [on\|off]` | — | 切换 Plan 模式。不带参数时按当前状态翻转;显式传 `on`/`off` 时强制设为对应状态。单纯切换不会创建空计划文件。 | 是 | +| `/plan clear` | — | 清除当前 plan 方案。 | 否 | + +::: warning 注意 +`/yolo` 会跳过普通工具调用的审批确认,使用前请确保了解可能的风险。Plan 模式的退出审批不会被 `/yolo` 跳过;Plan 模式下的 `Bash` 也按 `/yolo` 的普通放行规则处理。 +::: + +## 信息与状态 + +| 命令 | 别名 | 说明 | 随时可用 | +| --- | --- | --- | --- | +| `/help` | `/h`、`/?` | 显示快捷键和所有可用命令。 | 是 | +| `/usage` | — | 显示 token 用量、上下文占用以及配额信息。 | 是 | +| `/status` | — | 显示当前会话运行时状态,包括版本、模型、工作目录和权限模式等。 | 是 | +| `/mcp` | — | 列出当前会话中的 MCP server 及其连接状态。 | 是 | +| `/version` | — | 显示 Kimi Code CLI 版本号。 | 是 | +| `/feedback` | — | 提交反馈以改进 Kimi Code CLI。 | 是 | + +## 退出 + +| 命令 | 别名 | 说明 | 随时可用 | +| --- | --- | --- | --- | +| `/exit` | `/quit`、`/q` | 退出 Kimi Code CLI。 | 否 | + +## Skill 动态命令 + +除内置命令外,用户可激活的 Skill 会自动注册为斜杠命令,统一以 `skill:` 作为命名空间前缀: + +``` +/skill: [附加文本] +``` + +例如 `/skill:code-style` 会加载名为 `code-style` 的 Skill 内容并发送给 Agent;命令后附带的文本会拼接到 Skill 提示词之后,例如 `/skill:git-commits 修复登录失败的问题`。 + +为方便输入,Skill 命令同时支持省略 `skill:` 前缀的简写形式 `/`,前提是该名称未被内置命令占用。也就是说,`/code-style` 会回退匹配到 `/skill:code-style`。 + +Kimi Code CLI 随包内置了 `mcp-config` Skill,用于配置 MCP server 和处理 MCP OAuth 登录。它在补全和帮助里仍属于 Skill 命名空间(`/skill:mcp-config`),也可以直接输入 `/mcp-config` 调用。 + +可作为斜杠命令暴露的 Skill 类型包括 `prompt`、`inline`、`flow` 以及未显式声明类型的 Skill。Skill 的安装与编写详见 [Agent Skills](../customization/skills.md)。 + +::: info 说明 +所有 Skill 命令仅在空闲状态下可用,流式输出或上下文压缩期间需先按 `Esc` 或 `Ctrl-C` 中断当前操作。 +::: + +::: info 说明 +Flow 类型的 Skill 同样通过 `/skill:` 暴露,没有独立的 `/flow:` 命名空间。 +::: diff --git a/docs/zh/reference/tools.md b/docs/zh/reference/tools.md new file mode 100644 index 000000000..06ef48f76 --- /dev/null +++ b/docs/zh/reference/tools.md @@ -0,0 +1,104 @@ +# 内置工具 + +内置工具是 Kimi Code CLI 随核心引擎一起提供的工具集,无需安装 MCP server 即可使用。Agent 在每次对话中会根据任务需要自动选择并调用这些工具;用户也可以通过权限审批界面查看每次工具调用的细节。 + +与 MCP 工具相比,内置工具由运行时直接管理,生命周期与会话绑定,无需外部进程。两者都遵循统一的审批机制:**只读类工具**(如 `Read`、`Grep`、`Glob`、`WebSearch` 等)默认自动放行,**写入与执行类工具**(如 `Write`、`Edit`、`Bash`、`TaskStop`)默认需要用户审批。在 YOLO 模式下,普通工具调用的审批会被跳过,但 Plan 模式下的退出审批不受影响。 + +## 文件类 + +文件类工具负责读取、写入、搜索本地文件系统,是代码分析和修改任务的基础工具。 + +| 工具 | 默认审批 | 说明 | +| --- | --- | --- | +| `Read` | 自动放行 | 读取文本文件内容 | +| `Write` | 需审批 | 创建或覆盖文件 | +| `Edit` | 需审批 | 精确字符串替换 | +| `Grep` | 自动放行 | 基于 ripgrep 的全文搜索 | +| `Glob` | 自动放行 | 按 glob 模式查找文件 | +| `ReadMediaFile` | 自动放行 | 读取图片或视频文件 | + +**`Read`** 接受文件路径(`path`)以及可选的 `line_offset`(起始行号,支持负数从末尾倒数)和 `n_lines`(读取行数上限)。单次最多返回 1000 行或 100 KB,超出部分会附带截断提示。如果文件是图片或视频,工具会提示改用 `ReadMediaFile`。 + +**`Write`** 接受 `path`、`content` 和可选的 `mode`(`overwrite` 或 `append`,默认覆盖)。父目录必须已存在;`append` 模式将内容追加到文件末尾,不自动添加换行。 + +**`Edit`** 接受 `path`、`old_string`(要替换的精确文本)和 `new_string`(替换后的文本)。默认只替换唯一一处匹配,若文件中存在多处相同内容会报错并提示使用 `replace_all: true`。`old_string` 与 `new_string` 不能相同。 + +**`Grep`** 调用 ripgrep 搜索文件内容,支持正则表达式(`pattern`)、搜索路径(`path`)、文件类型过滤(`type`,如 `ts`、`py`)、glob 过滤(`glob`)和输出模式(`output_mode`:`files_with_matches` / `content` / `count_matches`,默认 `files_with_matches`)。`content` 模式支持 `-A`、`-B`、`-C` 上下文行参数与 `-i`(忽略大小写)、`-n`(行号,默认 true)、`multiline`(跨行匹配)。所有模式均支持 `offset` + `head_limit` 分页,`head_limit` 默认 250、传 0 表示不限。`files_with_matches` 模式的结果按文件最近修改时间倒序排列;其他模式保持 ripgrep 原始输出顺序。`.env`、私钥、`.aws/credentials`、`.gcp/credentials` 等敏感文件会被自动过滤;`include_ignored=true` 可同时搜索被 `.gitignore` 等忽略的文件,但敏感文件仍保持过滤。 + +**`Glob`** 按 glob 模式(`pattern`)在指定目录(`path`,默认为工作目录)中匹配文件,结果按修改时间倒序排列,最多返回 1000 条。可选参数 `include_dirs`(默认 true)控制是否返回目录条目。纯通配符模式(如 `**`、`**/*`)会被拒绝并提示添加字面锚点;包含花括号扩展(`{a,b,c}`)的模式同样会被拒绝——底层 glob 引擎把 `{`、`}` 当字面量处理,这类模式会静默匹配 0 个文件。 + +**`ReadMediaFile`** 将图片或视频文件以多模态内容发送给模型,仅接受 `path`。文件大小上限为 100 MB。工具是否可用取决于当前模型的视觉能力(`image_in` / `video_in`)。 + +## Shell + +| 工具 | 默认审批 | 说明 | +| --- | --- | --- | +| `Bash` | 需审批 | 执行 Shell 命令 | + +**`Bash`** 是最通用也是权限要求最严格的工具。它接受 `command`(必填)以及可选的 `cwd`(工作目录)、`timeout`(毫秒)、`description`(后台任务描述,`run_in_background=true` 时必填)、`run_in_background`(是否以后台任务运行)和 `disable_timeout`(后台任务是否取消超时)。前台 `timeout` 默认 60 秒、最长 5 分钟;后台 `timeout` 默认 10 分钟、最长 10 分钟。 + +前台模式下 `Bash` 会阻塞当前轮次,直到命令结束或超时;后台模式会立即返回任务 ID。后台任务默认 10 分钟后超时;如果确实需要让任务不受超时限制,可以设置 `disable_timeout=true`。任务结束、失败或被停止时会自动通知 Agent 继续处理,过程中也可通过 `TaskOutput` 主动查看结果。stdin 始终被关闭,交互式命令会立即收到 EOF。两阶段终止策略(SIGTERM → 5 秒宽限期 → SIGKILL)确保超时后进程能可靠结束。Windows 平台下默认使用 Git Bash 作为 shell。 + +## 网络类 + +| 工具 | 默认审批 | 说明 | +| --- | --- | --- | +| `WebSearch` | 自动放行 | 网络搜索 | +| `FetchURL` | 自动放行 | 获取指定 URL 的内容 | + +**`WebSearch`** 接受 `query`(搜索词)和可选的 `limit`(返回结果数,1–20,默认 5)及 `include_content`(是否返回网页正文,默认 false,开启后消耗 token 较多)。该工具需要宿主提供搜索实现,未注入实现时不会出现在工具列表中。 + +**`FetchURL`** 接受单个 `url` 参数,返回页面内容。对于 HTML 页面,宿主会提取正文文章(`extracted`)而非返回完整 HTML;纯文本或 Markdown 页面则直接透传(`passthrough`)。同样需要宿主注入实现。 + +## Plan 模式 + +| 工具 | 默认审批 | 说明 | +| --- | --- | --- | +| `EnterPlanMode` | 自动放行 | 进入 Plan 模式 | +| `ExitPlanMode` | 自动放行(需用户确认计划) | 退出 Plan 模式并提交计划 | + +Plan 模式是一种受约束的工作状态:进入后 `Write` 与 `Edit` 工具被收紧——只允许写入当前的计划文件,其它路径会被阻断;`TaskStop` 也被完全拦截。其余工具(包括 `Bash`)仍按当前权限规则处理,因此通过 `Bash` 调用的命令理论上仍可能修改文件,是否放行取决于当前的审批策略。 + +**`EnterPlanMode`** 不接受任何参数,进入成功后返回工作流指引,包括计划文件路径(如果宿主提供了的话)。 + +**`ExitPlanMode`** 读取当前计划文件内容,将计划呈现给用户审批后退出 Plan 模式。可选参数 `options` 允许 Agent 提供 1–3 个备选方案(每项含 `label` 与 `description`,`label` 最长 80 字符),供用户在审批时选择;标签需互不重复,且不能使用 `Approve`、`Reject`、`Reject and Exit`、`Revise` 这些保留词(系统用它们标记审批结果)。用户批准后,所有工具重新可用;若用户要求修改,Agent 将继续留在 Plan 模式中。 + +## 状态管理 + +| 工具 | 默认审批 | 说明 | +| --- | --- | --- | +| `TodoList` | 自动放行 | 管理任务待办列表 | + +**`TodoList`** 用于在多步骤操作中维护一份可见的子任务列表,状态存储在 Agent 会话内。`todos` 参数接受一个数组,每项含 `title`(标题)和 `status`(`pending` / `in_progress` / `done`);省略 `todos` 则仅查询当前列表,传入空数组则清空列表。 + +## 协作类 + +协作类工具负责 Agent 间协作、用户交互和 Skill 调用。 + +| 工具 | 默认审批 | 说明 | +| --- | --- | --- | +| `Agent` | 自动放行 | 派生子 Agent 执行子任务 | +| `AskUserQuestion` | 自动放行 | 向用户提问以获取结构化输入 | +| `Skill` | 自动放行 | 调用已注册的 inline Skill | + +**`Agent`** 用于将子任务委托给子 Agent 执行。必填参数为 `prompt`(完整任务描述)和 `description`(3–5 个词的简短说明,用于 UI 展示)。可选参数包括 `subagent_type`(Agent 类型,默认 `coder`)、`resume`(恢复已有 Agent 的 ID)、`run_in_background`(是否后台运行,默认 false)和 `timeout`(超时秒数,30–3600)。`subagent_type` 与 `resume` 互斥:恢复已有 Agent 时只通过 ID 寻址。前台 `timeout` 缺省表示不超时,子 Agent 运行至完成;后台 `timeout` 缺省时回落到 `config.toml` 的 `[background] agent_task_timeout_s`,该字段也未设置则无时间上限。前台模式下父 Agent 会等待子 Agent 完成再继续;后台模式立即返回任务 ID,完成时通过合成 user 消息自动回到主 Agent,无需轮询。子 Agent 体系细节参见 [子 Agent](../customization/agents.md)。 + +**`AskUserQuestion`** 以结构化多选题的形式向用户提问,适用于需要用户消歧或选择方案的场景。`questions` 参数接受 1–4 道题,每道题需提供 `question`(问题文本,以 `?` 结尾)、`options`(2–4 个选项,每项含 `label` 和 `description`)以及可选的 `header`(最多 12 字符的短分类标签,如 `Auth`、`Style`)和 `multi_select`(是否多选,默认 false)。系统会自动附加「其他」选项,无需在 `options` 中手工提供。若宿主未实现交互式提问能力,本工具会返回失败提示,Agent 应改为直接在文本回复中向用户提问。 + +**`Skill`** 允许 Agent 主动调用已注册的 inline 类型 Skill。接受 `skill`(Skill 名称)和可选的 `args`(附加参数文本)。只有 `type = "inline"` 的 Skill 能通过此工具调用;其他类型(如 `prompt`、`flow`)以及在 frontmatter 中设置了 `disableModelInvocation: true` 的 Skill 会被拒绝。为防止递归死循环,Skill 嵌套调用深度上限为 3 层。Skill 体系细节参见 [Skills](../customization/skills.md)。 + +## 后台任务 + +后台任务工具用于管理通过 `Bash` 或 `Agent` 启动的后台任务。后台任务进入完成、失败、停止或丢失等终止状态时,会把状态和末尾输出自动送回 Agent;如果只想提前检查进度,再使用 `TaskOutput`。 + +| 工具 | 默认审批 | 说明 | +| --- | --- | --- | +| `TaskList` | 自动放行 | 列出后台任务 | +| `TaskOutput` | 自动放行 | 查看后台任务的输出 | +| `TaskStop` | 需审批 | 停止正在运行的后台任务 | + +**`TaskList`** 返回后台任务列表,每条记录包含任务 ID、状态、命令、描述和 PID。可选参数 `active_only`(默认 true,仅列出运行中的任务)和 `limit`(最多返回条数,默认 20,取值范围 1–100)。已进入终止状态的任务还会附带 `exit_code`,被 `TaskStop` 显式终止的任务会附带 `reason`。 + +**`TaskOutput`** 根据 `task_id` 返回指定任务的状态与输出。内联预览最多包含最近 32 KB 的内容;完整日志保存在磁盘上,工具会一并返回 `output_path` 并提示通过 `Read` 分页读取(建议每页约 300 行)。可选 `block`(默认 false)和 `timeout`(等待秒数,默认 30,取值范围 0–3600)参数可用于等待任务完成后再返回。返回结构中 `retrieval_status` 取 `success` / `timeout` / `not_ready`;任务因超时被外部 deadline 中止时会附带 `timed_out: true` 与 `terminal_reason: timed_out`,被 `TaskStop` 显式终止时会附带 `stop_reason` 与 `terminal_reason: stopped`。 + +**`TaskStop`** 接受 `task_id` 和可选的 `reason`(停止原因,默认 `Stopped by TaskStop`)。对已处于终止状态的任务也能安全调用,会直接返回当前状态而不报错。 diff --git a/docs/zh/release-notes/changelog.md b/docs/zh/release-notes/changelog.md new file mode 100644 index 000000000..d169d59f4 --- /dev/null +++ b/docs/zh/release-notes/changelog.md @@ -0,0 +1,5 @@ +# 变更记录 + +::: info 说明 +本页将随首个正式版本同步发布详细变更记录。 +::: diff --git a/flake.lock b/flake.lock new file mode 100644 index 000000000..689872643 --- /dev/null +++ b/flake.lock @@ -0,0 +1,27 @@ +{ + "nodes": { + "nixpkgs": { + "locked": { + "lastModified": 1779102034, + "narHash": "sha256-vZJZjLo513IeI8hjzHFc6TDezUd4uCE2Eq4SNO3DNNg=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "687f05a9184cad4eaf905c48b63649e3a86f5433", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-25.11", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "nixpkgs": "nixpkgs" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 000000000..b4de4f080 --- /dev/null +++ b/flake.nix @@ -0,0 +1,302 @@ +{ + description = "Kimi Code CLI"; + + inputs = { + # Pinned to the 25.11 release channel because nixpkgs-unstable currently + # ships nodejs_24 = 24.14.1, which trips the >= 24.15.0 floor that the + # native SEA build enforces (see apps/kimi-code/scripts/native/build.mjs). + nixpkgs.url = "github:NixOS/nixpkgs/nixos-25.11"; + }; + + outputs = + { self, nixpkgs }: + let + lib = nixpkgs.lib; + + systems = [ + "x86_64-linux" + "aarch64-linux" + "x86_64-darwin" + "aarch64-darwin" + ]; + + forAllSystems = + f: + lib.genAttrs systems ( + system: + f (import nixpkgs { + inherit system; + }) + ); + + minNodeVersion = "24.15.0"; + requiredNodeMajor = "24"; + + # nodejsFor picks pkgs.nodejs_24 when it satisfies the minimum, otherwise + # falls back to pkgs.nodejs_latest. The build pipeline (tsdown target + # `node24`, SEA flags) assumes a 24.x runtime, so we hard-fail if neither + # candidate is in the 24.x line. + nodejsFor = + pkgs: + let + candidate = + if lib.versionAtLeast pkgs.nodejs_24.version minNodeVersion then + pkgs.nodejs_24 + else + pkgs.nodejs_latest; + major = lib.versions.major candidate.version; + in + if major == requiredNodeMajor then + candidate + else + throw '' + Kimi Code requires Node.js ${requiredNodeMajor}.x (>= ${minNodeVersion}), + but nixpkgs only offers ${candidate.version}. + Pin a newer nixpkgs revision or update minNodeVersion in flake.nix. + ''; + + pnpmFor = + pkgs: + pkgs.pnpm_10.override { + nodejs = nodejsFor pkgs; + }; + + # --------------------------------------------------------------------- + # Derive workspace members from pnpm-workspace.yaml + each package.json. + # + # Source of truth is pnpm-workspace.yaml's `packages:` section. We expand + # each glob (or literal path) against the repo root, keep only entries + # that contain a package.json, and read their `name` field. Both the + # `src` fileset and the `pnpmWorkspaces` filter for pnpmConfigHook are + # derived from this list, so adding/removing a workspace only requires + # updating pnpm-workspace.yaml (followed by `nix run .#update-pnpm-deps`). + # --------------------------------------------------------------------- + + # Minimal parser for the `packages:` list in pnpm-workspace.yaml. Only + # handles top-level `- ` items under the `packages:` key; other + # sections (catalog, overrides) are ignored. Sufficient for the format + # pnpm produces and we maintain. + parsePnpmWorkspaceGlobs = + file: + let + lines = lib.splitString "\n" (builtins.readFile file); + isPackagesHeader = l: builtins.match "^packages:[[:space:]]*$" l != null; + isTopLevelKey = l: builtins.match "^[^[:space:]#].*:.*$" l != null; + extractItem = + l: + let + m = builtins.match "^[[:space:]]+-[[:space:]]+['\"]?([^'\"#[:space:]]+)['\"]?[[:space:]]*$" l; + in + if m == null then null else builtins.head m; + step = + state: line: + if state.done then + state + else if !state.inSection then + if isPackagesHeader line then state // { inSection = true; } else state + else + let + item = extractItem line; + in + if item != null then + state // { items = state.items ++ [ item ]; } + else if isTopLevelKey line then + state // { inSection = false; done = true; } + else + state; + result = lib.foldl' step { + inSection = false; + items = [ ]; + done = false; + } lines; + in + result.items; + + expandWorkspaceGlob = + root: glob: + if lib.hasSuffix "/*" glob then + let + dir = lib.removeSuffix "/*" glob; + absDir = root + "/${dir}"; + in + if builtins.pathExists absDir then + map (n: "${dir}/${n}") ( + builtins.attrNames (lib.filterAttrs (_: t: t == "directory") (builtins.readDir absDir)) + ) + else + [ ] + else if builtins.pathExists (root + "/${glob}") then + [ glob ] + else + [ ]; + + workspaceMembers = + let + globs = parsePnpmWorkspaceGlobs ./pnpm-workspace.yaml; + paths = lib.unique (lib.concatMap (expandWorkspaceGlob ./.) globs); + in + builtins.filter (p: builtins.pathExists (./. + "/${p}/package.json")) paths; + + workspacePaths = map (p: ./. + "/${p}") workspaceMembers; + + workspaceNames = map ( + p: (builtins.fromJSON (builtins.readFile (./. + "/${p}/package.json"))).name + ) workspaceMembers; + in + { + packages = forAllSystems ( + pkgs: + let + nodejs = nodejsFor pkgs; + pnpm = pnpmFor pkgs; + appPackageJson = builtins.fromJSON (builtins.readFile ./apps/kimi-code/package.json); + nativeTarget = + if pkgs.stdenv.hostPlatform.isLinux && pkgs.stdenv.hostPlatform.isAarch64 then + "linux-arm64" + else if pkgs.stdenv.hostPlatform.isLinux then + "linux-x64" + else if pkgs.stdenv.hostPlatform.isDarwin && pkgs.stdenv.hostPlatform.isAarch64 then + "darwin-arm64" + else if pkgs.stdenv.hostPlatform.isDarwin then + "darwin-x64" + else + throw "Unsupported Kimi Code native target for ${pkgs.stdenv.hostPlatform.system}"; + + kimi-code = pkgs.stdenv.mkDerivation (finalAttrs: { + pname = "kimi-code"; + version = appPackageJson.version; + + src = lib.fileset.toSource { + root = ./.; + fileset = lib.fileset.unions ( + [ + ./build + ./.npmrc + ./.nvmrc + ./package.json + ./pnpm-lock.yaml + ./pnpm-workspace.yaml + ./tsconfig.json + ./vitest.config.ts + ./LICENSE + ] + ++ workspacePaths + ); + }; + + pnpmWorkspaces = [ "." ] ++ workspaceNames; + + pnpmDeps = pkgs.fetchPnpmDeps { + inherit (finalAttrs) pname version src pnpmWorkspaces; + inherit pnpm; + fetcherVersion = 3; + hash = "sha256-dht627zodv/xeefY7fEvSaKXavTxi+JWA3gwL18U8dk="; + }; + + nativeBuildInputs = [ + nodejs + pnpm + (pkgs.pnpmConfigHook.override { inherit pnpm; }) + ] + # The SEA inject step (postject) invalidates the macOS code + # signature on the copied Node executable; build.mjs then re-applies + # an ad-hoc signature via `codesign`. The Nix darwin sandbox does + # not expose /usr/bin/codesign, so we supply nixpkgs' ad-hoc-only + # replacement instead. + ++ lib.optionals pkgs.stdenv.hostPlatform.isDarwin [ + pkgs.darwin.sigtool + ]; + + # The SEA binary is produced by `postject`-injecting a blob into a + # plain Node executable. Stripping rewrites section tables and can + # invalidate the injected blob's offsets, so leave the binary + # untouched after the build. + dontStrip = true; + + buildPhase = '' + runHook preBuild + export KIMI_CODE_BUILD_TARGET=${nativeTarget} + ${lib.optionalString pkgs.stdenv.hostPlatform.isDarwin '' + # pkgs.darwin.sigtool's codesign supports `--sign -` (ad-hoc) + # but not the inspection mode (`-dv`) that 05-verify.mjs runs + # afterwards. Disable the verify step for the Nix build; the + # release CI keeps it via the unmodified script. + substituteInPlace apps/kimi-code/scripts/native/build.mjs \ + --replace-fail \ + "await runVerifyStep({ requireGatekeeper: false });" \ + "// runVerifyStep skipped in nix sandbox (sigtool lacks -dv)" + ''} + pnpm --filter=@moonshot-ai/kimi-code run build:native:sea + runHook postBuild + ''; + + installPhase = '' + runHook preInstall + + install -Dm755 \ + "apps/kimi-code/dist-native/bin/${nativeTarget}/kimi" \ + "$out/bin/kimi" + + runHook postInstall + ''; + + meta = { + description = "Kimi Code CLI"; + homepage = "https://github.com/MoonshotAI/kimi-code"; + license = lib.licenses.mit; + mainProgram = "kimi"; + platforms = systems; + }; + }); + + # Expose pnpmDeps as a top-level package so `nix build .#kimi-code-pnpm-deps` + # (used by the update-pnpm-deps app) is a stable selector that doesn't + # depend on attribute drilling into a derivation. + kimi-code-pnpm-deps = kimi-code.pnpmDeps; + + update-pnpm-deps = pkgs.writeShellApplication { + name = "update-pnpm-deps"; + runtimeInputs = [ + pkgs.nix + pkgs.git + nodejs + pkgs.gnused + pkgs.gnugrep + pkgs.coreutils + ]; + text = builtins.readFile ./build/nix/update-pnpm-deps.sh; + }; + in + { + inherit kimi-code kimi-code-pnpm-deps update-pnpm-deps; + default = kimi-code; + } + ); + + apps = forAllSystems (pkgs: { + kimi-code = { + type = "app"; + program = "${self.packages.${pkgs.system}.kimi-code}/bin/kimi"; + }; + update-pnpm-deps = { + type = "app"; + program = "${self.packages.${pkgs.system}.update-pnpm-deps}/bin/update-pnpm-deps"; + }; + default = self.apps.${pkgs.system}.kimi-code; + }); + + devShells = forAllSystems (pkgs: { + default = + let + nodejs = nodejsFor pkgs; + pnpm = pnpmFor pkgs; + in + pkgs.mkShell { + packages = [ + nodejs + pnpm + ]; + }; + }); + }; +} diff --git a/package.json b/package.json new file mode 100644 index 000000000..a3ba656e3 --- /dev/null +++ b/package.json @@ -0,0 +1,57 @@ +{ + "name": "@moonshot-ai/monorepo", + "version": "0.1.1", + "private": true, + "license": "MIT", + "type": "module", + "scripts": { + "build": "pnpm -r run build", + "build:packages": "pnpm -r --filter './packages/*' run build", + "dev:cli": "pnpm -C apps/kimi-code run dev", + "vis": "pnpm -C apps/vis run dev", + "dev:docs": "pnpm -C docs install --ignore-workspace && pnpm -C docs run dev", + "typecheck": "pnpm run build:packages && pnpm -r --filter './packages/*' run typecheck && pnpm --filter @moonshot-ai/kimi-code run typecheck", + "lint": "oxlint --type-aware", + "lint:fix": "pnpm run lint --fix", + "lint:pkg": "pnpm -r --filter '!@moonshot-ai/monorepo' exec publint && pnpm -r --filter './packages/*' exec attw --pack . --profile node16", + "sherif": "sherif", + "test": "vitest run", + "test:watch": "vitest", + "test:coverage": "vitest run --coverage", + "clean": "pnpm -r run clean", + "changeset": "changeset", + "version": "changeset version", + "publish": "pnpm run typecheck && pnpm run lint && pnpm run sherif && pnpm run test && pnpm run build && pnpm run lint:pkg && changeset publish", + "prepare": "simple-git-hooks" + }, + "devDependencies": { + "@arethetypeswrong/cli": "0.18.2", + "@changesets/changelog-github": "0.7.0", + "@changesets/cli": "2.30.0", + "@microsoft/api-extractor": "7.58.7", + "@types/node": "^22.15.3", + "@vitest/coverage-v8": "4.1.4", + "lint-staged": "16.4.0", + "oxlint": "1.59.0", + "oxlint-tsgolint": "0.20.0", + "publint": "0.3.18", + "sherif": "1.11.1", + "simple-git-hooks": "2.13.1", + "tsdown": "0.22.0", + "tsx": "^4.21.0", + "typescript": "6.0.2", + "vitest": "4.1.4" + }, + "simple-git-hooks": { + }, + "lint-staged": { + "*.{js,jsx,ts,tsx,mjs,cjs,mts,cts}": [ + "oxlint --fix --quiet", + "oxlint --type-aware --quiet" + ] + }, + "engines": { + "node": ">=24.15.0" + }, + "packageManager": "pnpm@10.33.0" +} diff --git a/packages/agent-core/README.md b/packages/agent-core/README.md new file mode 100644 index 000000000..ce786952b --- /dev/null +++ b/packages/agent-core/README.md @@ -0,0 +1,11 @@ +# @moonshot-ai/agent-core + +The unified agent engine for Kimi Code. + +Part of the [Kimi Code](https://github.com/MoonshotAI/kimi-code) monorepo. + +See the main repository for documentation, issues, and contribution guidelines. + +## License + +MIT diff --git a/packages/agent-core/package.json b/packages/agent-core/package.json new file mode 100644 index 000000000..87631862c --- /dev/null +++ b/packages/agent-core/package.json @@ -0,0 +1,85 @@ +{ + "name": "@moonshot-ai/agent-core", + "version": "0.1.1", + "private": true, + "description": "The unified agent engine for Kimi", + "license": "MIT", + "author": "Moonshot AI", + "homepage": "https://github.com/MoonshotAI/kimi-code/tree/main/packages/agent-core#readme", + "repository": { + "type": "git", + "url": "git+https://github.com/MoonshotAI/kimi-code.git", + "directory": "packages/agent-core" + }, + "bugs": { + "url": "https://github.com/MoonshotAI/kimi-code/issues" + }, + "keywords": [ + "kimi", + "agent", + "ai", + "llm", + "session", + "tools" + ], + "files": [ + "dist" + ], + "type": "module", + "imports": { + "#/*": [ + "./src/*.ts", + "./src/*/index.ts" + ] + }, + "exports": { + ".": { + "types": "./src/index.ts", + "default": "./src/index.ts" + }, + "./session/store": { + "types": "./src/session/store/index.ts", + "default": "./src/session/store/index.ts" + } + }, + "scripts": { + "build": "tsdown", + "test": "vitest run", + "test:baseline": "vitest run", + "typecheck": "tsc -p tsconfig.json --noEmit", + "clean": "rm -rf dist" + }, + "dependencies": { + "@antfu/utils": "^9.3.0", + "@modelcontextprotocol/sdk": "^1.29.0", + "@moonshot-ai/kaos": "workspace:^", + "@moonshot-ai/kosong": "workspace:^", + "@mozilla/readability": "^0.6.0", + "ajv": "^8.18.0", + "ajv-formats": "^3.0.1", + "js-yaml": "^4.1.1", + "linkedom": "^0.18.12", + "nunjucks": "^3.2.4", + "open": "^10.2.0", + "picomatch": "^4.0.4", + "proper-lockfile": "^4.1.2", + "regexp.escape": "^2.0.1", + "retry": "0.13.1", + "smol-toml": "^1.6.1", + "tar": "^7.5.13", + "yauzl": "^3.3.0", + "zod": "catalog:" + }, + "devDependencies": { + "@types/js-yaml": "^4.0.9", + "@types/nunjucks": "^3.2.6", + "@types/picomatch": "^4.0.3", + "@types/proper-lockfile": "^4.1.4", + "@types/regexp.escape": "^2.0.0", + "@types/retry": "0.12.0", + "@types/tar": "^7.0.87", + "@types/yauzl": "^2.10.3", + "@types/yazl": "^2.4.6", + "yazl": "^3.3.1" + } +} diff --git a/packages/agent-core/src/agent/background/index.ts b/packages/agent-core/src/agent/background/index.ts new file mode 100644 index 000000000..14af9fa52 --- /dev/null +++ b/packages/agent-core/src/agent/background/index.ts @@ -0,0 +1,191 @@ +import type { ContentPart } from '@moonshot-ai/kosong'; + +import type { Agent } from '../..'; +import type { TelemetryPropertyValue } from '../../telemetry'; +import { + BackgroundProcessManager, + type BackgroundProcessManagerOptions, + type BackgroundTaskInfo, + isBackgroundTaskTerminal, + type ReconcileResult, +} from '../../tools/builtin'; +import type { BackgroundTaskOrigin } from '../context'; +import { renderNotificationXml } from '../context/notification-xml'; + +type BackgroundTaskNotification = Record & { + readonly id: string; + readonly category: 'task'; + readonly type: string; + readonly source_kind: 'background_task'; + readonly source_id: string; + readonly title: string; + readonly severity: 'info' | 'warning'; + readonly body: string; + readonly tail_output: string; +}; + +interface BackgroundTaskNotificationContext { + readonly content: readonly ContentPart[]; + readonly origin: BackgroundTaskOrigin; + readonly notification: BackgroundTaskNotification; +} + +const NOTIFICATION_TAIL_BYTES = 3_000; + +export class BackgroundManager extends BackgroundProcessManager { + private readonly scheduledNotificationKeys = new Set(); + private readonly deliveredNotificationKeys = new Set(); + + constructor( + public readonly agent: Agent, + options: BackgroundProcessManagerOptions = {}, + ) { + super(options); + + this.onLifecycle((event, info) => { + switch (event) { + case 'started': + this.agent.emitEvent({ type: 'background.task.started', info }); + this.agent.telemetry.track('background_task_created', { + kind: info.taskId.startsWith('agent-') ? 'agent' : 'bash', + }); + return; + case 'updated': + this.agent.emitEvent({ type: 'background.task.updated', info }); + return; + case 'terminated': { + this.agent.emitEvent({ type: 'background.task.terminated', info }); + const success = info.status === 'completed'; + const duration_s = + info.endedAt !== null ? (info.endedAt - info.startedAt) / 1000 : null; + const properties: Record = { + kind: info.taskId.startsWith('agent-') ? 'agent' : 'bash', + success, + duration_s, + }; + if (!success) { + properties['reason'] = + info.timedOut === true + ? 'timeout' + : info.status === 'killed' + ? 'killed' + : 'error'; + } + this.agent.telemetry.track('background_task_completed', properties); + return; + } + } + }); + } + + override async reconcile(): Promise { + const result = await super.reconcile(); + await this.restoreBackgroundTaskNotifications(); + return result; + } + + protected override onLiveTaskTerminal(info: BackgroundTaskInfo): void | Promise { + return this.notifyBackgroundTask(info); + } + + private async restoreBackgroundTaskNotifications(): Promise { + for (const info of this.list(false)) { + if (!isBackgroundTaskTerminal(info.status)) continue; + await this.restoreBackgroundTaskNotification(info); + } + } + + private async notifyBackgroundTask(info: BackgroundTaskInfo): Promise { + const context = await this.buildBackgroundTaskNotificationContext(info); + if (context === undefined) return; + this.agent.turn.steer(context.content, context.origin); + this.fireNotificationHook(context.notification); + } + + private async restoreBackgroundTaskNotification(info: BackgroundTaskInfo): Promise { + const context = await this.buildBackgroundTaskNotificationContext(info); + if (context === undefined) return; + this.agent.context.appendUserMessage(context.content, context.origin); + this.fireNotificationHook(context.notification); + } + + private async buildBackgroundTaskNotificationContext( + info: BackgroundTaskInfo, + ): Promise { + const origin: BackgroundTaskOrigin = { + kind: 'background_task', + taskId: info.taskId, + status: info.status, + notificationId: `task:${info.taskId}:${info.status}`, + }; + const notificationId = origin.notificationId; + const key = notificationKey(origin); + if (this.scheduledNotificationKeys.has(key)) return; + if (this.hasDeliveredNotification(origin)) return; + + this.scheduledNotificationKeys.add(key); + const tailOutput = (await this.getOutputSnapshot(info.taskId, NOTIFICATION_TAIL_BYTES)) + .preview; + if (this.hasDeliveredNotification(origin)) return; + const label = info.taskId.startsWith('agent-') ? 'agent' : 'task'; + const notification: BackgroundTaskNotification = { + id: notificationId, + category: 'task', + type: `task.${info.status}`, + source_kind: 'background_task', + source_id: info.taskId, + title: `Background ${label} ${info.status}`, + severity: info.status === 'completed' ? 'info' : 'warning', + body: `${info.description} ${info.status}.`, + tail_output: tailOutput, + }; + const content = [ + { + type: 'text', + text: renderNotificationXml(notification), + }, + ] as const; + return { content, origin, notification }; + } + + private fireNotificationHook(notification: BackgroundTaskNotification): void { + void this.agent.hooks?.fireAndForgetTrigger('Notification', { + matcherValue: notification.type, + inputData: { + sink: 'context', + notificationType: notification.type, + title: notification.title, + body: notification.body, + severity: notification.severity, + sourceKind: notification.source_kind, + sourceId: notification.source_id, + }, + }); + } + + markDeliveredNotification(origin: BackgroundTaskOrigin): void { + this.deliveredNotificationKeys.add(notificationKey(origin)); + } + + private hasDeliveredNotification(origin: BackgroundTaskOrigin): boolean { + return this.deliveredNotificationKeys.has(notificationKey(origin)); + } + + override stop(taskId: string, reason?: string) { + this.agent.records.logRecord({ + type: 'background.stop', + taskId, + }); + return super.stop(taskId, reason); + } + + override _reset(): void { + super._reset(); + this.scheduledNotificationKeys.clear(); + this.deliveredNotificationKeys.clear(); + } +} + +function notificationKey(origin: BackgroundTaskOrigin): string { + return `${origin.taskId}\0${origin.status}\0${origin.notificationId}`; +} diff --git a/packages/agent-core/src/agent/compaction/compaction-instruction.md b/packages/agent-core/src/agent/compaction/compaction-instruction.md new file mode 100644 index 000000000..68a3f3b12 --- /dev/null +++ b/packages/agent-core/src/agent/compaction/compaction-instruction.md @@ -0,0 +1,67 @@ + +--- This message is a direct task, not part of the above conversation --- + +You are now given a task to compact this conversation context according to specific priorities and output requirements. + +Output text only. DO NOT CALL ANY TOOLS. Calling tools will be rejected and fails the task. You already have all the information you need in the conversation history. You have only one chance. + +The goal of compaction is to keep essential code patterns, technical details, and architectural decisions for continuing development without losing context after the above messages are cleared work. + +{{ customInstruction }} + + + +1. **Current Task State**: What is being worked on RIGHT NOW +2. **Errors & Solutions**: All encountered errors and their resolutions +3. **Code Evolution**: Final working versions only (remove intermediate attempts) +4. **System Context**: Project structure, dependencies, environment setup +5. **Design Decisions**: Architectural choices and their rationale +6. **TODO Items**: Unfinished tasks and known issues + + + +## Current Focus + +[What we're working on now] + +## Environment + +- [Key setup/config points] +- ... + +## Completed Tasks + +- [Task]: [Brief outcome] +- ... + +## Active Issues + +- [Issue]: [Status/Next steps] +- ... + +## Code State + +### [Critical file name] + +[Brief description of the file's purpose and current state] + +``` +[The latest version of critical code snippets in this file, <20 lines] +``` + +### [Critical file name] + +- [Useful classes/methods/functions]: [Brief description/usage] +- ... + + + +## Important Context + +- [Any crucial information not covered above] +- ... + +## All User Messages + +- [Detailed non tool use user message] +- ... diff --git a/packages/agent-core/src/agent/compaction/config.ts b/packages/agent-core/src/agent/compaction/config.ts new file mode 100644 index 000000000..50d92f306 --- /dev/null +++ b/packages/agent-core/src/agent/compaction/config.ts @@ -0,0 +1,19 @@ +export interface CompactionConfig { + triggerRatio: number; + blockRatio: number; + reservedContextSize: number; + maxCompactionPerTurn: number; + maxRecentSteps: number; + maxRecentUserMessages: number; + maxRecentSizeRatio: number; +} + +export const DEFAULT_COMPACTION_CONFIG: CompactionConfig = { + triggerRatio: 0.85, + blockRatio: 0.85, // Same as triggerRatio to disable async compaction + reservedContextSize: 50_000, + maxCompactionPerTurn: 3, + maxRecentSteps: 3, + maxRecentUserMessages: Infinity, + maxRecentSizeRatio: 0.2, +}; diff --git a/packages/agent-core/src/agent/compaction/full.ts b/packages/agent-core/src/agent/compaction/full.ts new file mode 100644 index 000000000..80f23f2bd --- /dev/null +++ b/packages/agent-core/src/agent/compaction/full.ts @@ -0,0 +1,557 @@ +import { + ErrorCodes, + KimiError, + isKimiError, + makeErrorPayload, + toKimiErrorPayload, +} from '#/errors'; +import { + APIConnectionError, + APIEmptyResponseError, + APIStatusError, + APITimeoutError, + inputTotal, + type GenerateResult, + type Message, + type TokenUsage, +} from '@moonshot-ai/kosong'; + +import type { Agent } from '..'; +import { isAbortError } from '../../loop/errors'; +import { + DEFAULT_MAX_RETRY_ATTEMPTS, + retryBackoffDelays, + sleepForRetry, +} from '../../loop/retry'; +import type { TelemetryPropertyValue } from '../../telemetry'; +import { + applyCompletionBudget, + resolveCompletionBudget, +} from '../../utils/completion-budget'; +import { renderPrompt } from '../../utils/render-prompt'; +import { + estimateTokens, + estimateTokensForMessage, + estimateTokensForMessages, +} from '../../utils/tokens'; +import { sliceCompleteMessages } from '../context/complete-slice'; +import { project } from '../context/projector'; +import compactionInstructionTemplate from './compaction-instruction.md'; +import { DEFAULT_COMPACTION_CONFIG, type CompactionConfig } from './config'; +import { renderMessagesToText } from './render-messages'; +import type { CompactionBeginData, CompactionResult } from './types'; + +export interface CompactionStrategy { + shouldCompact(usedSize: number, maxSize: number): boolean; + shouldBlock(usedSize: number, maxSize: number): boolean; + computeCompactCount(messages: readonly Message[], maxSize: number): number; + readonly checkAfterStep: boolean; + readonly maxCompactionPerTurn: number; +} + +export class DefaultCompactionStrategy implements CompactionStrategy { + constructor(protected readonly config: CompactionConfig = DEFAULT_COMPACTION_CONFIG) {} + + shouldCompact(usedSize: number, maxSize: number): boolean { + if (maxSize <= 0) return false; + return ( + usedSize >= maxSize * this.config.triggerRatio || + this.shouldUseReservedContext(maxSize, usedSize) + ); + } + + shouldBlock(usedSize: number, maxSize: number): boolean { + if (maxSize <= 0) return false; + return ( + usedSize >= maxSize * this.config.blockRatio || + this.shouldUseReservedContext(maxSize, usedSize) + ); + } + + private shouldUseReservedContext(maxSize: number, usedSize: number): boolean { + const reservedSize = this.config.reservedContextSize; + return reservedSize > 0 && reservedSize < maxSize && usedSize + reservedSize >= maxSize; + } + + computeCompactCount(messages: readonly Message[], maxSize: number) { + let splitAt = messages.length; + let recentSize = 0; + let userMessageCount = 0; + let onlySeenTrailingUsers = true; + for (let i = messages.length - 1; i >= 0; i--) { + const m1 = messages[i - 1]; + const m2 = messages[i]; + if (m2 === undefined) continue; + const isTrailingAssistantPlaceholder = + onlySeenTrailingUsers && + m2.role === 'assistant' && + m2.content.length === 0 && + m2.toolCalls.length === 0; + if (isTrailingAssistantPlaceholder) { + splitAt = i; + continue; + } + const isTrailingUserMessage = onlySeenTrailingUsers && m2.role === 'user'; + if (!isTrailingUserMessage && messages.length - i >= this.config.maxRecentSteps) break; + + if (m2.role === 'user') { + userMessageCount++; + if (!isTrailingUserMessage && userMessageCount > this.config.maxRecentUserMessages) { + break; + } + } + + recentSize += estimateTokensForMessage(m2); + if (isTrailingUserMessage) { + splitAt = i; + continue; + } + if (recentSize > maxSize * this.config.maxRecentSizeRatio) { + break; + } + const canSplitBeforeMessage = + m1?.role !== m2.role && !(m1?.role === 'user' && m2.role === 'assistant') && m2.role !== 'tool'; + if (canSplitBeforeMessage) { + splitAt = i; + } + if (m2.role !== 'user') { + onlySeenTrailingUsers = false; + } + } + + return splitAt; + } + + get checkAfterStep(): boolean { + return this.config.triggerRatio !== this.config.blockRatio; + } + + get maxCompactionPerTurn(): number { + return this.config.maxCompactionPerTurn; + } +} + +export interface CompactedHistory { + text: string; +} + +type CompactionTelemetryTrigger = CompactionBeginData['source'] | 'manual-with-prompt' | 'unknown'; + +export class FullCompaction { + protected compactionCountInTurn = 0; + protected compacting: { + abortController: AbortController; + startedAt: number; + telemetryTrigger: CompactionTelemetryTrigger; + promise: Promise; + } | null = null; + protected _compactedHistory: CompactedHistory[] = []; + protected readonly strategy: CompactionStrategy; + + constructor( + protected readonly agent: Agent, + strategy?: CompactionStrategy, + ) { + this.strategy = + strategy ?? + new DefaultCompactionStrategy({ + ...DEFAULT_COMPACTION_CONFIG, + reservedContextSize: + agent.providerManager?.config.loopControl?.reservedContextSize ?? + DEFAULT_COMPACTION_CONFIG.reservedContextSize, + }); + } + + get isCompacting(): boolean { + return this.compacting !== null; + } + + begin(data: Readonly): void { + this.agent.records.logRecord({ + type: 'full_compaction.begin', + ...data, + }); + if (this.compacting) return; + if (data.source === 'manual') { + this.compactionCountInTurn = 0; + } else { + this.compactionCountInTurn += 1; + } + if (this.compactionCountInTurn > this.strategy.maxCompactionPerTurn) return; + if (!this.agent.records.restoring) { + this.startCompactionWorker(data); + } + } + + private startCompactionWorker(data: Readonly): void { + const abortController = new AbortController(); + this.agent.emitEvent({ + type: 'compaction.started', + trigger: data.source, + instruction: data.instruction, + }); + const active = { + abortController, + startedAt: Date.now(), + telemetryTrigger: compactionTelemetryTrigger(data.source, data.instruction), + promise: Promise.resolve(), + }; + this.compacting = active; + active.promise = this.compactionWorker(abortController.signal, data); + } + + cancel(): void { + this.markCanceled(); + } + + private markCanceled(): void { + if (!this.compacting) return; + this.agent.records.logRecord({ + type: 'full_compaction.cancel', + }); + this.compacting.abortController.abort(); + this.compacting = null; + this.agent.emitEvent({ type: 'compaction.cancelled' }); + } + + complete( + result: CompactionResult, + llmUsage?: TokenUsage | undefined, + retryCount: number = 0, + ): void { + this.agent.records.logRecord({ + type: 'full_compaction.complete', + ...result, + }); + const active = this.compacting; + this.compacting = null; + const history = this.agent.context.history; + this._compactedHistory.push({ + text: renderMessagesToText(history), + }); + this.agent.emitEvent({ type: 'compaction.completed', result }); + if (active !== null) { + const properties: Record = { + trigger_type: active.telemetryTrigger, + before_tokens: result.tokensBefore, + after_tokens: result.tokensAfter, + duration_ms: Date.now() - active.startedAt, + compacted_count: result.compactedCount, + retry_count: retryCount, + }; + if (llmUsage !== undefined) { + properties['llm_input_tokens'] = inputTotal(llmUsage); + properties['llm_output_tokens'] = llmUsage.output; + } + this.agent.telemetry.track('compaction_finished', properties); + } + } + + private get tokenCountWithPending(): number { + return this.agent.context.tokenCountWithPending; + } + + private get maxContextSize() { + return this.agent.config.modelCapabilities.max_context_tokens; + } + + private get shouldCompact(): boolean { + return this.strategy.shouldCompact(this.tokenCountWithPending, this.maxContextSize); + } + + private get shouldBlock(): boolean { + return this.strategy.shouldBlock(this.tokenCountWithPending, this.maxContextSize); + } + + resetForTurn(): void { + this.compactionCountInTurn = 0; + } + + async handleOverflowError(signal: AbortSignal, error: unknown) { + const didStartCompaction = this.beginAutoCompaction(); + if (!didStartCompaction && !this.compacting) throw error; + // Always block on overflow errors + await this.block(signal); + } + + async beforeStep(signal: AbortSignal): Promise { + this.checkAutoCompaction(); + if (this.shouldBlock) { + await this.block(signal); + } + } + + async afterStep(): Promise { + if (this.strategy.checkAfterStep) { + this.checkAutoCompaction(false); + } + // Do not block after the step + } + + private checkAutoCompaction(throwOnLimit: boolean = true): boolean { + if (this.compacting) return true; + if (!this.shouldCompact) return false; + + return this.beginAutoCompaction(throwOnLimit); + } + + private beginAutoCompaction(throwOnLimit: boolean = true): boolean { + if (this.compacting) return true; + const maxCompactions = this.strategy.maxCompactionPerTurn; + if (this.compactionCountInTurn >= maxCompactions) { + if (throwOnLimit) { + throw new KimiError(ErrorCodes.CONTEXT_OVERFLOW, `Compaction limit exceeded (${String(maxCompactions)})`, { + details: { maxCompactions }, + }); + } + return false; + } + const history = this.agent.context.history; + const compactedCount = this.computeCompactableCount(history); + if (compactedCount === 0) return false; + if ( + this.maxContextSize > 0 && + estimateTokensForMessages(project(history.slice(compactedCount))) >= this.maxContextSize + ) { + return false; + } + this.agent.fullCompaction.begin({ source: 'auto', instruction: undefined }); + return true; + } + + private async block(signal: AbortSignal): Promise { + const active = this.compacting; + if (active) { + signal.addEventListener('abort', () => { + if (this.compacting === active) { + this.cancel(); + } + }); + this.agent.emitEvent({ + type: 'compaction.blocked', + turnId: this.agent.turn.currentId, + }); + await active.promise; + } + } + + private async compactionWorker( + signal: AbortSignal, + data: Readonly, + ): Promise { + const startedAt = Date.now(); + let tokensBeforeForError = 0; + let retryCountForTelemetry = 0; + try { + const originalHistory = [...this.agent.context.history]; + const tokensBefore = this.agent.context.tokenCount; + tokensBeforeForError = tokensBefore; + const compactedCount = this.computeCompactableCount(originalHistory); + if (compactedCount === 0) { + this.markCanceled(); + return undefined; + } + signal.throwIfAborted(); + await this.triggerPreCompactHook(data, tokensBefore, signal); + signal.throwIfAborted(); + + const model = this.agent.config.model; + const messages = [ + ...project(originalHistory.slice(0, compactedCount)), + { + role: 'user', + content: [ + { + type: 'text', + text: COMPACTION_INSTRUCTION(data.instruction), + }, + ], + toolCalls: [], + } satisfies Message, + ]; + const { response, retryCount } = await this.generateCompactionResponse({ + messages, + signal, + onRetry: (count) => { + retryCountForTelemetry = count; + }, + }); + if (response.usage !== null) { + this.agent.usage.record(model, response.usage); + } + + const summary = + typeof response.message.content === 'string' + ? response.message.content + : response.message.content + .map((part) => (part.type === 'text' ? part.text : '')) + .join(''); + + const newHistory = this.agent.context.history; + for (let i = 0; i < originalHistory.length; i++) { + if (newHistory[i] !== originalHistory[i]) { + // History changed during compaction, likely due to undo + this.cancel(); + return undefined; + } + } + + const recent = originalHistory.slice(compactedCount); + const tokensAfter = estimateTokens(summary) + estimateTokensForMessages(project(recent)); + + const result: CompactionResult = { + summary, + compactedCount, + tokensBefore, + tokensAfter, + }; + + this.complete(result, response.usage ?? undefined, retryCount); + this.agent.context.applyCompaction(result); + this.triggerPostCompactHook(data, result); + } catch (error) { + if (!isAbortError(error)) { + this.agent.log.error('compaction failed', { + code: isKimiError(error) ? error.code : undefined, + error, + }); + this.markCanceled(); + const payload = + isKimiError(error) && error.code === ErrorCodes.AUTH_LOGIN_REQUIRED + ? toKimiErrorPayload(error) + : makeErrorPayload(ErrorCodes.COMPACTION_FAILED, String(error)); + this.agent.emitEvent({ + type: 'error', + ...payload, + }); + this.agent.telemetry.track('compaction_failed', { + trigger_type: compactionTelemetryTrigger(data.source, data.instruction), + before_tokens: tokensBeforeForError, + duration_ms: Date.now() - startedAt, + retry_count: retryCountForTelemetry, + error_type: error instanceof Error ? error.name : 'Unknown', + }); + } + } + } + + private async generateCompactionResponse({ + messages, + signal, + onRetry, + }: { + readonly messages: Message[]; + readonly signal: AbortSignal; + readonly onRetry?: ((retryCount: number) => void) | undefined; + }): Promise<{ readonly response: GenerateResult; readonly retryCount: number }> { + const maxAttempts = + this.agent.providerManager?.config.loopControl?.maxRetriesPerStep ?? + DEFAULT_MAX_RETRY_ATTEMPTS; + const delays = retryBackoffDelays(maxAttempts); + let retryCount = 0; + + // Clamp the completion budget against the compaction input. Compaction + // is triggered when context is already near full, so an unbounded + // default cap is most at risk of either exceeding the model limit or + // returning empty `content` on reasoning models. The cloned provider + // is local to this call and never persisted back to agent state. + const completionBudget = resolveCompletionBudget({ + reservedContextSize: + this.agent.providerManager?.config.loopControl?.reservedContextSize, + }); + const effectiveProvider = applyCompletionBudget({ + provider: this.agent.config.provider, + budget: completionBudget, + capability: this.agent.config.modelCapabilities, + messages, + systemPrompt: this.agent.config.systemPrompt, + tools: this.agent.tools.loopTools, + }); + + for (let attempt = 1; ; attempt += 1) { + try { + const response = await this.agent.generate( + effectiveProvider, + this.agent.config.systemPrompt, + [...this.agent.tools.loopTools], + messages, + undefined, + { signal }, + ); + return { response, retryCount }; + } catch (error) { + if (attempt >= maxAttempts || !isRetryableCompactionError(error)) { + throw error; + } + retryCount += 1; + onRetry?.(retryCount); + await sleepForRetry(delays[attempt - 1] ?? 0, signal); + } + } + } + + get compactedHistory(): readonly CompactedHistory[] { + return this._compactedHistory; + } + + private async triggerPreCompactHook( + data: Readonly, + tokenCount: number, + signal: AbortSignal, + ): Promise { + await this.agent.hooks?.trigger('PreCompact', { + matcherValue: data.source, + signal, + inputData: { + trigger: data.source, + tokenCount, + }, + }); + } + + private triggerPostCompactHook( + data: Readonly, + result: CompactionResult, + ): void { + void this.agent.hooks?.fireAndForgetTrigger('PostCompact', { + matcherValue: data.source, + inputData: { + trigger: data.source, + estimatedTokenCount: result.tokensAfter, + }, + }); + } + + private computeCompactableCount(history: readonly Message[]): number { + return sliceCompleteMessages( + history, + this.strategy.computeCompactCount(history, this.maxContextSize), + ); + } +} + +export const COMPACTION_INSTRUCTION = (customInstruction = ''): string => + renderPrompt(compactionInstructionTemplate, { customInstruction }); + +function compactionTelemetryTrigger( + trigger: CompactionBeginData['source'] | undefined, + instruction: string | undefined, +): CompactionTelemetryTrigger { + if (trigger === undefined) return 'unknown'; + if (trigger === 'manual' && instruction !== undefined && instruction.length > 0) { + return 'manual-with-prompt'; + } + return trigger; +} + +function isRetryableCompactionError(error: unknown): boolean { + if (error instanceof APIConnectionError || error instanceof APITimeoutError) { + return true; + } + if (error instanceof APIEmptyResponseError) { + return true; + } + if (!(error instanceof APIStatusError)) return false; + const statusCode = (error as { readonly statusCode?: unknown }).statusCode; + return typeof statusCode === 'number' && [429, 500, 502, 503, 504].includes(statusCode); +} diff --git a/packages/agent-core/src/agent/compaction/index.ts b/packages/agent-core/src/agent/compaction/index.ts new file mode 100644 index 000000000..d8da8bbd9 --- /dev/null +++ b/packages/agent-core/src/agent/compaction/index.ts @@ -0,0 +1,3 @@ +export * from './full'; +export * from './config'; +export * from './types'; diff --git a/packages/agent-core/src/agent/compaction/render-messages.ts b/packages/agent-core/src/agent/compaction/render-messages.ts new file mode 100644 index 000000000..81a298a7d --- /dev/null +++ b/packages/agent-core/src/agent/compaction/render-messages.ts @@ -0,0 +1,111 @@ +import type { Message } from '@moonshot-ai/kosong'; + +export function renderMessagesToText(messages: readonly Message[]): string { + return messages.map((message, index) => renderMessageToText(message, index)).join('\n\n'); +} + +function renderMessageToText(message: Message, index: number): string { + const header = [`message ${String(index + 1)}`, `role=${message.role}`]; + if (message.name !== undefined) { + header.push(`name=${JSON.stringify(message.name)}`); + } + if (message.toolCallId !== undefined) { + header.push(`toolCallId=${JSON.stringify(message.toolCallId)}`); + } + if (message.partial === true) { + header.push('partial=true'); + } + + const lines = [`--- ${header.join(' ')} ---`]; + if (message.content.length === 0) { + lines.push('[empty content]'); + } else { + lines.push(...message.content.map(renderContentPartToText)); + } + + if (message.toolCalls.length > 0) { + lines.push('tool calls:'); + for (const toolCall of message.toolCalls) { + lines.push(renderToolCallToText(toolCall)); + } + } + + return lines.join('\n'); +} + +function renderContentPartToText(part: Message['content'][number]): string { + switch (part.type) { + case 'text': + return renderBlock('text', part.text); + case 'think': + return renderBlock('think', part.think); + case 'image_url': + return renderMediaPart('image_url', part.imageUrl.url, part.imageUrl.id); + case 'audio_url': + return renderMediaPart('audio_url', part.audioUrl.url, part.audioUrl.id); + case 'video_url': + return renderMediaPart('video_url', part.videoUrl.url, part.videoUrl.id); + default: + return renderBlock('content', stringifyJsonish(part)); + } +} + +function renderToolCallToText(toolCall: Message['toolCalls'][number]): string { + const lines = [ + `- ${toolCall.id}: ${toolCall.function.name}`, + renderBlock('arguments', renderToolCallArguments(toolCall.function.arguments)), + ]; + + if (toolCall.extras !== undefined) { + lines.push(renderBlock('extras', stringifyJsonish(toolCall.extras))); + } + + return lines.join('\n'); +} + +function renderToolCallArguments(args: string | null): string { + if (args === null) return 'null'; + + try { + return stringifyJsonish(JSON.parse(args)); + } catch { + return args; + } +} + +function renderMediaPart(type: string, url: string, id?: string | undefined): string { + if (id === undefined) return `${type}: ${url}`; + return `${type}: ${url} (id=${id})`; +} + +function renderBlock(label: string, value: string): string { + return `${label}:\n${indentBlock(value)}`; +} + +function indentBlock(value: string): string { + if (value.length === 0) return ' '; + return value + .split('\n') + .map((line) => ` ${line}`) + .join('\n'); +} + +function stringifyJsonish(value: unknown): string { + const seen = new WeakSet(); + const replacer = (_key: string, nested: unknown): unknown => { + if (typeof nested === 'bigint') return `${nested.toString()}n`; + if (typeof nested === 'function') return `[Function ${nested.name || 'anonymous'}]`; + if (typeof nested === 'symbol') return nested.toString(); + if (nested !== null && typeof nested === 'object') { + if (seen.has(nested)) return '[Circular]'; + seen.add(nested); + } + return nested; + }; + + try { + return JSON.stringify(value, replacer, 2) ?? String(value); + } catch { + return String(value); + } +} diff --git a/packages/agent-core/src/agent/compaction/types.ts b/packages/agent-core/src/agent/compaction/types.ts new file mode 100644 index 000000000..3936faef7 --- /dev/null +++ b/packages/agent-core/src/agent/compaction/types.ts @@ -0,0 +1,11 @@ +export interface CompactionResult { + summary: string; + compactedCount: number; + tokensBefore: number; + tokensAfter: number; +} + +export interface CompactionBeginData { + instruction?: string; + source: 'manual' | 'auto'; +} diff --git a/packages/agent-core/src/agent/config/index.ts b/packages/agent-core/src/agent/config/index.ts new file mode 100644 index 000000000..18a33d14b --- /dev/null +++ b/packages/agent-core/src/agent/config/index.ts @@ -0,0 +1,134 @@ +import { + createProvider, + UNKNOWN_CAPABILITY, + type ChatProvider, + type ModelCapability, + type ProviderConfig, +} from '@moonshot-ai/kosong'; + +import type { Agent } from '..'; +import type { ResolvedRuntimeProvider } from '../../providers/runtime-provider'; +import type { AgentConfigData, AgentConfigUpdateData } from './types'; +import { resolveThinkingEffort, type ThinkingEffort } from './thinking'; + +export * from './types'; +export { resolveThinkingEffort, type ThinkingEffort } from './thinking'; + +export class ConfigState { + private _cwd: string = ''; + private _modelAlias: string | undefined; + private _profileName: string | undefined; + private _thinkingLevel: ThinkingEffort = 'off'; + private _systemPrompt: string = ''; + + constructor(protected readonly agent: Agent) {} + + update(input: AgentConfigUpdateData): void { + const changed = { ...input }; + if (Object.keys(changed).length === 0) return; + + if (changed.thinkingLevel !== undefined) { + changed.thinkingLevel = resolveThinkingEffort( + changed.thinkingLevel, + this.agent.providerManager?.config.thinking, + ); + } + this.agent.records.logRecord({ + type: 'config.update', + ...changed, + }); + this.agent.replayBuilder.push({ + type: 'config_updated', + config: changed, + }); + if (changed.cwd !== undefined) this._cwd = changed.cwd; + if (Object.hasOwn(changed, 'modelAlias')) { + this._modelAlias = changed.modelAlias ?? undefined; + } + if (Object.hasOwn(changed, 'profileName')) this._profileName = changed.profileName; + if (changed.thinkingLevel !== undefined) + this._thinkingLevel = changed.thinkingLevel as ThinkingEffort; + if (changed.systemPrompt !== undefined) this._systemPrompt = changed.systemPrompt; + if (this.hasProvider && (changed.cwd !== undefined || Object.hasOwn(changed, 'modelAlias'))) { + this.agent.tools.initializeBuiltinTools(); + } + this.agent.emitStatusUpdated(); + } + + data(): AgentConfigData { + const resolved = this.tryResolvedProviderConfig(); + return { + cwd: this.cwd, + provider: resolved?.provider, + modelAlias: this._modelAlias, + modelCapabilities: resolved?.modelCapabilities ?? UNKNOWN_CAPABILITY, + profileName: this.profileName, + thinkingLevel: this.thinkingLevel, + systemPrompt: this.systemPrompt, + }; + } + + get cwd(): string { + return this._cwd; + } + + get hasModel(): boolean { + return this._modelAlias !== undefined; + } + + get hasProvider(): boolean { + return this.tryResolvedProviderConfig() !== undefined; + } + + get providerConfig(): ProviderConfig { + const provider = this.resolvedProviderConfig?.provider; + if (provider === undefined) { + throw new Error('Provider not set'); + } + return provider; + } + + get provider(): ChatProvider { + return createProvider(this.providerConfig); + } + + get model(): string { + if (this._modelAlias === undefined) { + throw new Error('Model not set'); + } + return this._modelAlias; + } + + get modelAlias(): string | undefined { + return this._modelAlias; + } + + get thinkingLevel(): ThinkingEffort { + return this._thinkingLevel; + } + + get profileName(): string | undefined { + return this._profileName; + } + + get systemPrompt(): string { + return this._systemPrompt; + } + + get modelCapabilities(): ModelCapability { + return this.tryResolvedProviderConfig()?.modelCapabilities ?? UNKNOWN_CAPABILITY; + } + + private get resolvedProviderConfig(): ResolvedRuntimeProvider | undefined { + if (this._modelAlias === undefined) return undefined; + return this.agent.providerManager?.resolveProviderConfigForModel(this._modelAlias); + } + + private tryResolvedProviderConfig(): ResolvedRuntimeProvider | undefined { + try { + return this.resolvedProviderConfig; + } catch { + return undefined; + } + } +} diff --git a/packages/agent-core/src/agent/config/thinking.ts b/packages/agent-core/src/agent/config/thinking.ts new file mode 100644 index 000000000..f6a7d924a --- /dev/null +++ b/packages/agent-core/src/agent/config/thinking.ts @@ -0,0 +1,31 @@ +import type { ThinkingEffort } from '@moonshot-ai/kosong'; + +import type { ThinkingConfig } from '../../config/schema'; + +export type { ThinkingEffort }; + +const DEFAULT_THINKING_EFFORT: ThinkingEffort = 'high'; + +const THINKING_EFFORTS = new Set(['low', 'medium', 'high', 'xhigh', 'max']); + +export function resolveThinkingEffort( + requested: string | undefined, + defaults: ThinkingConfig | undefined, +): ThinkingEffort { + const configEffort = parseEffort(defaults?.effort) ?? DEFAULT_THINKING_EFFORT; + const normalized = requested?.trim().toLowerCase(); + if (!normalized) { + if (defaults?.mode === 'off') return 'off'; + return configEffort; + } + if (normalized === 'off') return 'off'; + if (normalized === 'on') return configEffort; + return parseEffort(normalized) ?? configEffort; +} + +function parseEffort(value: string | undefined): ThinkingEffort | undefined { + const normalized = value?.trim().toLowerCase(); + return normalized !== undefined && THINKING_EFFORTS.has(normalized as ThinkingEffort) + ? (normalized as ThinkingEffort) + : undefined; +} diff --git a/packages/agent-core/src/agent/config/types.ts b/packages/agent-core/src/agent/config/types.ts new file mode 100644 index 000000000..fc7a785f5 --- /dev/null +++ b/packages/agent-core/src/agent/config/types.ts @@ -0,0 +1,19 @@ +import type { ModelCapability, ProviderConfig } from '@moonshot-ai/kosong'; + +export interface AgentConfigData { + cwd: string; + provider?: ProviderConfig; + modelAlias?: string; + modelCapabilities: ModelCapability; + profileName?: string; + thinkingLevel: string; + systemPrompt: string; +} + +export type AgentConfigUpdateData = Partial<{ + cwd: string; + modelAlias: string; + profileName: string; + thinkingLevel: string; + systemPrompt: string; +}>; diff --git a/packages/agent-core/src/agent/context/complete-slice.ts b/packages/agent-core/src/agent/context/complete-slice.ts new file mode 100644 index 000000000..258cdf90e --- /dev/null +++ b/packages/agent-core/src/agent/context/complete-slice.ts @@ -0,0 +1,55 @@ +import type { Message } from '@moonshot-ai/kosong'; + +export function sliceCompleteMessages( + messages: readonly Message[], + requestedEnd: number, +): number { + let normalized = Math.max(0, Math.min(messages.length, requestedEnd)); + + for (let i = 0; i < messages.length; i += 1) { + const message = messages[i]; + if (message?.role !== 'assistant' || message.toolCalls.length === 0) continue; + + const end = findToolExchangeEnd(messages, i); + if (end === undefined) { + if (normalized > i) { + normalized = includePromptForAssistant(messages, i); + } + continue; + } + + if (normalized > i && normalized < end) { + normalized = includePromptForAssistant(messages, i); + } + } + + return normalized; +} + +function findToolExchangeEnd( + messages: readonly Message[], + assistantIndex: number, +): number | undefined { + const assistant = messages[assistantIndex]; + if (assistant?.role !== 'assistant') return undefined; + + const pending = new Set(assistant.toolCalls.map((call) => call.id)); + if (pending.size === 0) return assistantIndex + 1; + + for (let i = assistantIndex + 1; i < messages.length; i += 1) { + const message = messages[i]; + if (message?.role !== 'tool') return undefined; + if (message.toolCallId !== undefined) { + pending.delete(message.toolCallId); + } + if (pending.size === 0) return i + 1; + } + + return undefined; +} + +function includePromptForAssistant(messages: readonly Message[], assistantIndex: number): number { + const previous = messages[assistantIndex - 1]; + if (previous?.role === 'user') return assistantIndex - 1; + return assistantIndex; +} diff --git a/packages/agent-core/src/agent/context/index.ts b/packages/agent-core/src/agent/context/index.ts new file mode 100644 index 000000000..fd9141ed2 --- /dev/null +++ b/packages/agent-core/src/agent/context/index.ts @@ -0,0 +1,268 @@ +import { createToolMessage, type ContentPart, type Message } from '@moonshot-ai/kosong'; + +import type { Agent } from '..'; +import type { ExecutableToolResult, LoopRecordedEvent } from '../../loop'; +import { estimateTokensForMessages } from '../../utils/tokens'; +import type { CompactionResult } from '../compaction'; +import { project } from './projector'; +import { + USER_PROMPT_ORIGIN, + type AgentContextData, + type ContextMessage, + type PromptOrigin, +} from './types'; + +export * from './types'; + +const TOOL_ERROR_STATUS = 'ERROR: Tool execution failed.'; +const TOOL_EMPTY_STATUS = 'Tool output is empty.'; +const TOOL_EMPTY_ERROR_STATUS = + 'ERROR: Tool execution failed. Tool output is empty.'; +const TOOL_OUTPUT_EMPTY_TEXT = 'Tool output is empty.'; + +export class ContextMemory { + private _history: ContextMessage[] = []; + private _tokenCount = 0; + private tokenCountCoveredMessageCount = 0; + private openSteps: Map = new Map(); + private pendingToolResultIds = new Set(); + private deferredMessages: ContextMessage[] = []; + + constructor(protected readonly agent: Agent) {} + + appendUserMessage( + content: readonly ContentPart[], + origin: PromptOrigin = USER_PROMPT_ORIGIN, + ): void { + this.appendMessage({ + role: 'user', + content: [...content], + toolCalls: [], + origin, + }); + } + + appendSystemReminder(content: string, origin: PromptOrigin): void { + const text = `\n${content}\n`; + this.appendMessage({ + role: 'user', + content: [{ type: 'text', text }], + toolCalls: [], + origin, + }); + } + + markLastUserPromptBlocked(hookEvent: string): void { + this.agent.records.logRecord({ + type: 'context.mark_last_user_prompt_blocked', + hookEvent, + }); + for (let i = this._history.length - 1; i >= 0; i--) { + const message = this._history[i]; + if (message?.role !== 'user' || message.origin?.kind !== 'user') continue; + this._history[i] = { + ...message, + origin: { ...message.origin, blockedByHook: hookEvent }, + }; + return; + } + } + + clear(): void { + this.agent.records.logRecord({ type: 'context.clear' }); + this._history = []; + this._tokenCount = 0; + this.tokenCountCoveredMessageCount = 0; + this.openSteps.clear(); + this.pendingToolResultIds.clear(); + this.deferredMessages = []; + this.agent.injection.onContextClear(); + this.agent.emitStatusUpdated(); + } + + applyCompaction(summary: CompactionResult): void { + this.agent.records.logRecord({ + type: 'context.apply_compaction', + ...summary, + }); + this._history = [ + { + role: 'assistant', + content: [{ type: 'text', text: summary.summary }], + toolCalls: [], + origin: { kind: 'compaction_summary' }, + }, + ...this._history.slice(summary.compactedCount), + ]; + this.openSteps.clear(); + this.flushDeferredMessagesIfToolExchangeClosed(); + this._tokenCount = summary.tokensAfter; + this.tokenCountCoveredMessageCount = this._history.length; + this.agent.injection.onContextCompacted(summary.compactedCount); + this.agent.emitStatusUpdated(); + } + + data(): AgentContextData { + return { + history: this.history, + tokenCount: this.tokenCount, + }; + } + + get tokenCount(): number { + return this._tokenCount; + } + + get tokenCountWithPending(): number { + const pendingMessages = this._history.slice(this.tokenCountCoveredMessageCount); + return this._tokenCount + estimateTokensForMessages(project(pendingMessages)); + } + + get history(): readonly ContextMessage[] { + return this._history; + } + + get messages(): Message[] { + return project(this.history); + } + + appendLoopEvent(event: LoopRecordedEvent): void { + this.agent.records.logRecord({ + type: 'context.append_loop_event', + event, + }); + switch (event.type) { + case 'step.begin': { + const message: ContextMessage = { + role: 'assistant', + content: [], + toolCalls: [], + }; + this.pushHistory(message); + this.openSteps.set(event.uuid, message); + return; + } + case 'step.end': { + const openStep = this.openSteps.get(event.uuid); + this.openSteps.delete(event.uuid); + if (event.usage !== undefined) { + const openStepIndex = openStep === undefined ? -1 : this._history.indexOf(openStep); + this._tokenCount = + event.usage.inputCacheRead + + event.usage.inputCacheCreation + + event.usage.inputOther + + event.usage.output; + this.tokenCountCoveredMessageCount = + openStepIndex === -1 ? this._history.length : openStepIndex + 1; + } + this.flushDeferredMessagesIfToolExchangeClosed(); + return; + } + case 'content.part': { + const openStep = this.openSteps.get(event.stepUuid); + if (openStep === undefined) { + throw new Error( + `Received content_part for unknown step_uuid '${event.stepUuid}' (no open step_begin)`, + ); + } + openStep.content.push(event.part); + return; + } + case 'tool.call': { + const openStep = this.openSteps.get(event.stepUuid); + if (openStep === undefined) { + throw new Error( + `Received tool_call for unknown step_uuid '${event.stepUuid}' (no open step_begin)`, + ); + } + openStep.toolCalls.push({ + type: 'function', + id: event.toolCallId, + function: { + name: event.name, + arguments: event.args === undefined ? null : JSON.stringify(event.args), + }, + }); + this.pendingToolResultIds.add(event.toolCallId); + return; + } + case 'tool.result': { + const message = createToolMessage(event.toolCallId, toolResultOutputForModel(event.result)); + this.pushHistory({ + ...message, + role: 'tool', + isError: event.result.isError, + }); + this.pendingToolResultIds.delete(event.toolCallId); + this.flushDeferredMessagesIfToolExchangeClosed(); + return; + } + } + } + + appendMessage(message: ContextMessage): void { + this.agent.records.logRecord({ + type: 'context.append_message', + message, + }); + if (this.hasOpenToolExchange()) { + this.deferredMessages.push(message); + return; + } + this.pushHistory(message); + } + + private flushDeferredMessagesIfToolExchangeClosed(): void { + if (this.pendingToolResultIds.size > 0 || this.deferredMessages.length === 0) { + return; + } + this.pushHistory(...this.deferredMessages); + this.deferredMessages = []; + } + + private hasOpenToolExchange(): boolean { + return this.pendingToolResultIds.size > 0; + } + + private pushHistory(...messages: ContextMessage[]): void { + this._history.push(...messages); + for (const message of messages) { + if (message.origin?.kind === 'background_task') { + this.agent.background.markDeliveredNotification(message.origin); + } + this.agent.replayBuilder.push({ + type: 'message', + message, + }); + } + } +} + +function toolResultOutputForModel(result: ExecutableToolResult): string | ContentPart[] { + const output = result.output; + if (typeof output === 'string') { + if (result.isError === true) { + if (output.length === 0) return TOOL_EMPTY_ERROR_STATUS; + if (output.trimStart().startsWith('ERROR:')) return output; + return `${TOOL_ERROR_STATUS}\n${output}`; + } + return isEmptyOutputText(output) ? TOOL_EMPTY_STATUS : output; + } + + if (output.length === 0) { + return [ + { + type: 'text', + text: result.isError === true ? TOOL_EMPTY_ERROR_STATUS : TOOL_EMPTY_STATUS, + }, + ]; + } + if (result.isError === true) { + return [{ type: 'text', text: TOOL_ERROR_STATUS }, ...output]; + } + return output; +} + +function isEmptyOutputText(output: string): boolean { + return output.length === 0 || output.trim() === TOOL_OUTPUT_EMPTY_TEXT; +} diff --git a/packages/agent-core/src/agent/context/notification-xml.ts b/packages/agent-core/src/agent/context/notification-xml.ts new file mode 100644 index 000000000..72e584b48 --- /dev/null +++ b/packages/agent-core/src/agent/context/notification-xml.ts @@ -0,0 +1,72 @@ +/** + * Notification XML rendering — produces the chat-history injection text + * shared between the live ContextMemory and the projector. + * + * Output shape: + * + * Title: ... + * Severity: ... + * + * (only when source_kind === 'background_task' and tail_output is non-empty) + * + * + * + * + * The opening-tag names (``) are + * load-bearing for the projector's `mergeAdjacentUserMessages` detector + * — rename requires updating the detector too. + */ + +export function renderNotificationXml(data: Record): string { + const id = stringAttr(data['id'], 'unknown'); + const category = stringAttr(data['category'], 'unknown'); + const type = stringAttr(data['type'], 'unknown'); + const sourceKind = stringAttr(data['source_kind'], 'unknown'); + const sourceId = stringAttr(data['source_id'], 'unknown'); + const title = typeof data['title'] === 'string' ? data['title'] : ''; + const severity = typeof data['severity'] === 'string' ? data['severity'] : ''; + const body = typeof data['body'] === 'string' ? data['body'] : ''; + + const lines: string[] = [ + ``, + ]; + if (title.length > 0) lines.push(`Title: ${title}`); + if (severity.length > 0) lines.push(`Severity: ${severity}`); + if (body.length > 0) lines.push(body); + + if (data['source_kind'] === 'background_task') { + const tailRaw = typeof data['tail_output'] === 'string' ? data['tail_output'] : ''; + if (tailRaw.length > 0) { + const truncated = truncateTailOutput(tailRaw, 20, 3000); + lines.push(''); + lines.push(truncated); + lines.push(''); + } + } + + lines.push(''); + return lines.join('\n'); +} + +/** + * Truncate tail output to at most `maxLines` lines and `maxChars` + * characters. Takes the *last* N lines, then trims from the front if + * the character budget is exceeded. + */ +function truncateTailOutput(raw: string, maxLines: number, maxChars: number): string { + const allLines = raw.split('\n'); + const tailLines = allLines.length > maxLines ? allLines.slice(-maxLines) : allLines; + let result = tailLines.join('\n'); + if (result.length > maxChars) { + result = result.slice(-maxChars); + } + return result; +} + +function stringAttr(value: unknown, fallback: string): string { + if (typeof value !== 'string' || value.length === 0) return fallback; + // Attribute boundary safety: escape `&` and `"`. Body-text `<` / `>` + // stay untouched — the injection target is an LLM-visible transcript + // where double-escaping would be noisier than literal punctuation. + return value.replaceAll('&', '&').replaceAll('"', '"'); +} diff --git a/packages/agent-core/src/agent/context/projector.ts b/packages/agent-core/src/agent/context/projector.ts new file mode 100644 index 000000000..3b5ac10a7 --- /dev/null +++ b/packages/agent-core/src/agent/context/projector.ts @@ -0,0 +1,173 @@ +import type { ContentPart, Message, TextPart } from '@moonshot-ai/kosong'; + +import { renderNotificationXml } from './notification-xml'; + +type ProjectableMessage = Message & { + readonly origin?: + | { + readonly kind: string; + readonly event?: string | undefined; + readonly blockedByHook?: string | undefined; + } + | undefined; +}; + +const TRANSCRIPT_ONLY_HOOK_RESULT_EVENTS = new Set(['UserPromptSubmit']); + +export interface EphemeralInjection { + kind: 'memory_recall' | 'system_reminder' | 'pending_notification'; + content: string | Record; + position?: 'before_user' | 'after_system'; +} + +export function project( + history: readonly ProjectableMessage[], + ephemeralInjections?: readonly EphemeralInjection[], +): Message[] { + // Keep partial or empty assistant placeholders away from providers. + // They can appear when a turn is aborted or errors before any content + // or tool call is appended. + const usable = history.filter((message) => { + if (isBlockedUserPrompt(message)) return false; + return ( + !isTranscriptOnlyHookResult(message) && + message.partial !== true && + !(message.role === 'assistant' && message.content.length === 0 && message.toolCalls.length === 0) + ); + }); + const merged = mergeAdjacentUserMessages(usable); + + const injectionMessages = ephemeralInjections?.map((injection) => renderInjection(injection)); + + // Ephemeral injections sit before the first history message + // (before_user) so things like system_reminder land right before the + // user turn they contextualise. + return injectionMessages ? [...injectionMessages, ...merged] : merged; +} + +function isTranscriptOnlyHookResult(message: ProjectableMessage): boolean { + return ( + message.origin?.kind === 'hook_result' && + TRANSCRIPT_ONLY_HOOK_RESULT_EVENTS.has(message.origin.event ?? '') + ); +} + +function isBlockedUserPrompt(message: ProjectableMessage): boolean { + return message.role === 'user' && message.origin?.blockedByHook === 'UserPromptSubmit'; +} + +/** + * Render an EphemeralInjection into a synthetic user message. System + * reminders and pending notifications use XML wrappers so the model can + * distinguish host annotations from genuine user text. `memory_recall` + * stays as free text. + * + * The merge-guard logic downstream (`mergeAdjacentUserMessages`) uses + * the `` opening tag to detect + * these messages, so the exact tag names are load-bearing for + * projector correctness — do not rename without also updating + * `isInjectionUserMessage` below. + */ +function renderInjection(injection: EphemeralInjection): Message { + const text = renderInjectionText(injection); + return { + role: 'user', + content: [{ type: 'text', text }], + toolCalls: [], + }; +} + +function renderInjectionText(injection: EphemeralInjection): string { + const { kind, content } = injection; + if (kind === 'pending_notification') { + // Production callers pass notification metadata, but accepting a + // string keeps older embedders from crashing on replay/projection. + if (typeof content === 'string') { + return `\n${content}\n`; + } + return renderNotificationXml(content); + } + if (kind === 'system_reminder') { + const body = typeof content === 'string' ? content : JSON.stringify(content); + return `\n${body}\n`; + } + const body = typeof content === 'string' ? content : JSON.stringify(content); + return body; +} + +/** + * Detect whether a user message was produced by the ephemeral injection + * pipeline (system_reminder or notification XML tag). Such messages + * must never be merged with an adjacent real user turn — doing so would + * smear the injection's XML wrapper into the user's actual prompt and + * confuse the LLM about where the system annotation ends. + * + */ +function isInjectionUserMessage(message: Message): boolean { + if (message.role !== 'user') return false; + const text = extractTextOnly(message); + // Cheap leading-fragment check — injections always have the opening + // tag at the start. We use `trimStart()` so leading whitespace + // doesn't defeat the check, and require `'` tag (no attributes) is not misidentified. + const trimmed = text.trimStart(); + if (trimmed.startsWith('')) return true; + if (trimmed.startsWith(' p.type !== 'text'), + ...b.content.filter((p) => p.type !== 'text'), + ]; + const mergedText: TextPart = { type: 'text', text: `${aText}\n\n${bText}` }; + const content: ContentPart[] = [mergedText, ...nonTextParts]; + return { + role: 'user', + content, + toolCalls: [], + }; +} + +function extractTextOnly(message: Message): string { + return message.content + .filter((p): p is TextPart => p.type === 'text') + .map((p) => p.text) + .join(''); +} + +function cloneMessage(message: Message): Message { + return { + role: message.role, + name: message.name, + content: message.content.map((p) => ({ ...p })) as ContentPart[], + toolCalls: message.toolCalls.map((tc) => ({ ...tc })), + toolCallId: message.toolCallId, + partial: message.partial, + }; +} diff --git a/packages/agent-core/src/agent/context/types.ts b/packages/agent-core/src/agent/context/types.ts new file mode 100644 index 000000000..b8d077009 --- /dev/null +++ b/packages/agent-core/src/agent/context/types.ts @@ -0,0 +1,78 @@ +import type { ContentPart, Message } from '@moonshot-ai/kosong'; + +import type { SkillSource } from '../../skill'; +import type { BackgroundTaskStatus } from '../../tools/background'; + +export interface UserPromptOrigin { + readonly kind: 'user'; + readonly blockedByHook?: string | undefined; +} + +export const USER_PROMPT_ORIGIN: UserPromptOrigin = { kind: 'user' }; + +export interface SkillActivationOrigin { + readonly kind: 'skill_activation'; + readonly activationId: string; + readonly skillName: string; + readonly skillArgs?: string | undefined; + readonly trigger: 'user-slash' | 'model-tool' | 'nested-skill'; + readonly skillType?: string | undefined; + readonly skillPath?: string | undefined; + readonly skillSource?: SkillSource | undefined; +} + +export interface InjectionOrigin { + readonly kind: 'injection'; + readonly variant: string; +} + +export interface CompactionSummaryOrigin { + readonly kind: 'compaction_summary'; +} + +export interface SystemTriggerOrigin { + readonly kind: 'system_trigger'; + readonly name: string; +} + +export interface BackgroundTaskOrigin { + readonly kind: 'background_task'; + readonly taskId: string; + readonly status: BackgroundTaskStatus; + readonly notificationId: string; +} + +export interface HookResultOrigin { + readonly kind: 'hook_result'; + readonly event: string; + readonly blocked?: boolean; +} + +export type PromptOrigin = + | UserPromptOrigin + | SkillActivationOrigin + | InjectionOrigin + | CompactionSummaryOrigin + | SystemTriggerOrigin + | BackgroundTaskOrigin + | HookResultOrigin; + +export type ContextMessage = Message & { + readonly origin?: PromptOrigin | undefined; + readonly isError?: boolean; +}; + +export interface UserMessageRecord { + content: readonly ContentPart[]; + origin: PromptOrigin; +} + +export interface SystemReminderRecord { + content: string; + origin: PromptOrigin; +} + +export interface AgentContextData { + history: readonly ContextMessage[]; + tokenCount: number; +} diff --git a/packages/agent-core/src/agent/hooks/engine.ts b/packages/agent-core/src/agent/hooks/engine.ts new file mode 100644 index 000000000..832ecd620 --- /dev/null +++ b/packages/agent-core/src/agent/hooks/engine.ts @@ -0,0 +1,186 @@ +import { runHook } from './runner'; +import type { + HookBlockDecision, + HookDef, + HookEngineOptions, + HookEngineTriggerArgs, + HookMatcherValue, + HookResult, +} from './types'; + +const DEFAULT_HOOK_TIMEOUT_SECONDS = 30; + +export class HookEngine { + private readonly byEvent = new Map(); + private readonly pendingTriggers = new Set>(); + + constructor( + hooks: readonly HookDef[] = [], + private readonly options: HookEngineOptions = {}, + ) { + for (const hook of hooks) { + const entries = this.byEvent.get(hook.event) ?? []; + entries.push(hook); + this.byEvent.set(hook.event, entries); + } + } + + get summary(): Record { + const result: Record = {}; + for (const [event, hooks] of this.byEvent.entries()) { + result[event] = hooks.length; + } + return result; + } + + trigger(event: string, args: HookEngineTriggerArgs = {}): Promise { + try { + return this.triggerInner(event, args).catch((): HookResult[] => []); + } catch { + return Promise.resolve([]); + } + } + + async triggerBlock( + event: string, + args: HookEngineTriggerArgs = {}, + ): Promise { + return blockDecision(event, await this.trigger(event, args)); + } + + fireAndForgetTrigger( + event: string, + args: HookEngineTriggerArgs = {}, + ): Promise { + let promise: Promise; + try { + promise = this.trigger(event, args).catch((): HookResult[] => []); + } catch { + promise = Promise.resolve([]); + } + this.pendingTriggers.add(promise); + void promise.finally(() => { + this.pendingTriggers.delete(promise); + }); + return promise; + } + + private async triggerInner( + event: string, + args: HookEngineTriggerArgs, + ): Promise { + const matcherValue = matcherValueText(args.matcherValue); + const inputData = toHookInputData({ + hookEventName: event, + sessionId: this.options.sessionId ?? '', + cwd: this.options.cwd ?? '', + ...args.inputData, + }); + const matched = this.matchingHooks(event, matcherValue); + if (matched.length === 0) return []; + + this.emitTriggered(event, matcherValue, matched.length); + const startedAt = Date.now(); + const results = await Promise.all( + matched.map((hook) => + runHook(hook.command, inputData, { + timeout: hook.timeout ?? DEFAULT_HOOK_TIMEOUT_SECONDS, + cwd: this.options.cwd === '' ? undefined : this.options.cwd, + signal: args.signal, + }), + ), + ); + const { action, reason } = aggregateResults(event, results); + this.emitResolved(event, matcherValue, action, reason, Date.now() - startedAt); + return results; + } + + private matchingHooks(event: string, matcherValue: string): HookDef[] { + const seenCommands = new Set(); + const matched: HookDef[] = []; + + for (const hook of this.byEvent.get(event) ?? []) { + if (!matches(hook.matcher ?? '', matcherValue)) continue; + if (seenCommands.has(hook.command)) continue; + seenCommands.add(hook.command); + matched.push(hook); + } + + return matched; + } + + private emitTriggered(event: string, target: string, count: number): void { + try { + this.options.onTriggered?.(event, target, count); + } catch {} + } + + private emitResolved( + event: string, + target: string, + action: string, + reason: string | undefined, + durationMs: number, + ): void { + try { + this.options.onResolved?.(event, target, action, reason, durationMs); + } catch {} + } +} + +function matches(pattern: string, value: string): boolean { + if (pattern.length === 0) return true; + try { + return new RegExp(pattern).test(value); + } catch { + return false; + } +} + +function matcherValueText(value: HookMatcherValue | undefined): string { + if (value === undefined) return ''; + if (typeof value === 'string') return value; + return value + .filter((part) => part.type === 'text') + .map((part) => part.text) + .join(' '); +} + +function aggregateResults( + event: string, + results: readonly HookResult[], +): { + readonly action: 'allow' | 'block'; + readonly reason?: string; +} { + const block = blockDecision(event, results); + if (block !== undefined) { + return { action: 'block', reason: block.reason }; + } + return { action: 'allow' }; +} + +function blockDecision( + event: string, + results: readonly HookResult[], +): HookBlockDecision | undefined { + const block = results.find((result) => result.action === 'block'); + if (block === undefined) return undefined; + const reason = block.reason?.trim(); + return { + block: true, + reason: reason === undefined || reason.length === 0 ? `Blocked by ${event} hook` : reason, + }; +} + +function toHookInputData(input: Record): Record { + const result: Record = {}; + for (const [key, value] of Object.entries(input)) { + result[camelToSnake(key)] = value; + } + return result; +} + +function camelToSnake(value: string): string { + return value.replaceAll(/[A-Z]/g, (ch) => `_${ch.toLowerCase()}`); +} diff --git a/packages/agent-core/src/agent/hooks/index.ts b/packages/agent-core/src/agent/hooks/index.ts new file mode 100644 index 000000000..4330aacc5 --- /dev/null +++ b/packages/agent-core/src/agent/hooks/index.ts @@ -0,0 +1,4 @@ +export * from './engine'; +export * from './runner'; +export * from './types'; +export * from './user-prompt'; diff --git a/packages/agent-core/src/agent/hooks/runner.ts b/packages/agent-core/src/agent/hooks/runner.ts new file mode 100644 index 000000000..4e3c30eda --- /dev/null +++ b/packages/agent-core/src/agent/hooks/runner.ts @@ -0,0 +1,228 @@ +import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process'; + +import { z } from 'zod'; + +import type { HookResult } from './types'; + +export interface RunHookOptions { + readonly timeout: number; + readonly cwd?: string; + readonly signal?: AbortSignal; +} + +const DEFAULT_TIMEOUT_SECONDS = 30; +const KILL_GRACE_MS = 100; +const OptionalStringSchema = z.preprocess( + (value) => { + if (value === undefined || value === null) return undefined; + if (typeof value === 'string') return value; + if (typeof value === 'number' || typeof value === 'boolean' || typeof value === 'bigint') { + return String(value); + } + return undefined; + }, + z.string().optional(), +); +const HookSpecificOutputSchema = z.preprocess( + (value) => (isRecord(value) ? value : undefined), + z + .looseObject({ + message: OptionalStringSchema, + permissionDecision: z.unknown().optional(), + permissionDecisionReason: OptionalStringSchema, + }) + .optional(), +); +const HookJsonOutputSchema = z.looseObject({ + message: OptionalStringSchema, + hookSpecificOutput: HookSpecificOutputSchema, +}); + +export async function runHook( + command: string, + input: Record, + options: RunHookOptions, +): Promise { + let child: ChildProcessWithoutNullStreams; + try { + child = spawn(command, { + shell: true, + cwd: options.cwd, + stdio: 'pipe', + detached: process.platform !== 'win32', + }); + } catch (error) { + return allowResult({ stderr: errorMessage(error) }); + } + + return new Promise((resolve) => { + let stdout = ''; + let stderr = ''; + let settled = false; + const timeoutMs = timeoutSeconds(options.timeout) * 1000; + + const cleanup = () => { + clearTimeout(timeout); + options.signal?.removeEventListener('abort', onAbort); + }; + + const settle = (result: HookResult): void => { + if (settled) return; + settled = true; + cleanup(); + resolve(result); + }; + + const timeout = setTimeout(() => { + killProcess(child); + settle(allowResult({ stdout, stderr, timedOut: true })); + }, timeoutMs); + + const onAbort = (): void => { + killProcess(child); + settle(allowResult({ stdout, stderr })); + }; + + options.signal?.addEventListener('abort', onAbort, { once: true }); + if (options.signal?.aborted === true) { + onAbort(); + return; + } + + child.stdout.setEncoding('utf8'); + child.stderr.setEncoding('utf8'); + child.stdout.on('data', (chunk: string) => { + stdout += chunk; + }); + child.stderr.on('data', (chunk: string) => { + stderr += chunk; + }); + child.on('error', (error) => { + settle(allowResult({ stdout, stderr: stderr + errorMessage(error) })); + }); + child.on('close', (code) => { + settle(resultFromExitCode(code ?? 0, stdout, stderr)); + }); + + child.stdin.on('error', () => {}); + child.stdin.end(JSON.stringify(input)); + }); +} + +function timeoutSeconds(timeout: number): number { + return Number.isFinite(timeout) && timeout > 0 ? timeout : DEFAULT_TIMEOUT_SECONDS; +} + +function resultFromExitCode(exitCode: number, stdout: string, stderr: string): HookResult { + if (exitCode === 2) { + const message = stderr.trim(); + return { + action: 'block', + message, + reason: message, + stdout, + stderr, + exitCode, + }; + } + + const structured = exitCode === 0 ? structuredOutput(stdout) : undefined; + if (structured?.action === 'block') { + return { + action: 'block', + message: structured.message ?? structured.reason, + reason: structured.reason, + stdout, + stderr, + exitCode, + structuredOutput: structured.structuredOutput, + }; + } + + return allowResult({ + message: structured?.message, + stdout, + stderr, + exitCode, + structuredOutput: structured?.structuredOutput, + }); +} + +function structuredOutput( + stdout: string, +): { action?: 'block'; reason?: string; message?: string; structuredOutput: true } | undefined { + const text = stdout.trim(); + if (text.length === 0) return undefined; + + try { + const parsed = JSON.parse(text) as unknown; + const output = HookJsonOutputSchema.safeParse(parsed); + if (!output.success) return undefined; + + const { message, hookSpecificOutput } = output.data; + const result = { + message: message ?? hookSpecificOutput?.message, + structuredOutput: true as const, + }; + if (hookSpecificOutput?.permissionDecision !== 'deny') { + return result; + } + return { + action: 'block', + message: result.message, + reason: hookSpecificOutput.permissionDecisionReason, + structuredOutput: true, + }; + } catch { + return undefined; + } +} + +function allowResult(input: { + readonly message?: string; + readonly stdout?: string; + readonly stderr?: string; + readonly exitCode?: number; + readonly timedOut?: boolean; + readonly structuredOutput?: boolean; +}): HookResult { + return { + action: 'allow', + message: input.message, + stdout: input.stdout, + stderr: input.stderr, + exitCode: input.exitCode, + timedOut: input.timedOut, + structuredOutput: input.structuredOutput, + }; +} + +function killProcess(child: ChildProcessWithoutNullStreams): void { + tryKillProcess(child, 'SIGTERM'); + const killTimer = setTimeout(() => { + tryKillProcess(child, 'SIGKILL'); + }, KILL_GRACE_MS); + killTimer.unref(); +} + +function tryKillProcess(child: ChildProcessWithoutNullStreams, signal: NodeJS.Signals): void { + try { + if (process.platform !== 'win32' && child.pid !== undefined) { + process.kill(-child.pid, signal); + } else { + child.kill(signal); + } + } catch { + try { + child.kill(signal); + } catch {} + } +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} diff --git a/packages/agent-core/src/agent/hooks/types.ts b/packages/agent-core/src/agent/hooks/types.ts new file mode 100644 index 000000000..f1ee9ce2b --- /dev/null +++ b/packages/agent-core/src/agent/hooks/types.ts @@ -0,0 +1,67 @@ +import type { ContentPart } from '@moonshot-ai/kosong'; + +export const HOOK_EVENT_TYPES = [ + 'PreToolUse', + 'PostToolUse', + 'PostToolUseFailure', + 'UserPromptSubmit', + 'Stop', + 'StopFailure', + 'SessionStart', + 'SessionEnd', + 'SubagentStart', + 'SubagentStop', + 'PreCompact', + 'PostCompact', + 'Notification', +] as const; + +export type HookEventType = (typeof HOOK_EVENT_TYPES)[number]; + +export interface HookDef { + readonly event: HookEventType; + readonly matcher?: string; + readonly command: string; + readonly timeout?: number; +} + +export interface HookResult { + readonly action: 'allow' | 'block'; + readonly message?: string; + readonly reason?: string; + readonly stdout?: string; + readonly stderr?: string; + readonly exitCode?: number; + readonly timedOut?: boolean; + readonly structuredOutput?: boolean; +} + +export interface HookBlockDecision { + readonly block: true; + readonly reason: string; +} + +export type HookMatcherValue = string | readonly ContentPart[]; + +export interface HookEngineTriggerArgs { + readonly matcherValue?: HookMatcherValue; + readonly inputData?: Record; + readonly signal?: AbortSignal; +} + +export type HookTriggeredCallback = (event: string, target: string, count: number) => void; + +export type HookResolvedCallback = ( + event: string, + target: string, + action: string, + reason: string | undefined, + durationMs: number, +) => void; + +export interface HookEngineOptions { + readonly cwd?: string; + readonly sessionId?: string; + readonly onTriggered?: HookTriggeredCallback; + readonly onResolved?: HookResolvedCallback; +} diff --git a/packages/agent-core/src/agent/hooks/user-prompt.ts b/packages/agent-core/src/agent/hooks/user-prompt.ts new file mode 100644 index 000000000..1b81c9f61 --- /dev/null +++ b/packages/agent-core/src/agent/hooks/user-prompt.ts @@ -0,0 +1,66 @@ +import type { HookResult } from './types'; + +export function renderHookResult(event: string, message: string): string { + return `\n${message}\n`; +} + +export interface RenderedHookResult { + readonly event: string; + readonly message: string; + readonly text: string; +} + +export function renderUserPromptHookResult( + results: readonly HookResult[] | undefined, +): RenderedHookResult | undefined { + const messages = + results + ?.filter((result) => result.action !== 'block') + ?.map(userPromptHookMessage) + .filter(isNonEmptyString) ?? + []; + if (messages.length === 0) return undefined; + const displayMessage = messages.join('\n\n'); + return { + event: 'UserPromptSubmit', + message: displayMessage, + text: messages.map((message) => renderHookResult('UserPromptSubmit', message)).join('\n'), + }; +} + +export function renderUserPromptHookBlockResult( + results: readonly HookResult[] | undefined, +): RenderedHookResult | undefined { + const block = results?.find((result) => result.action === 'block'); + if (block === undefined) return undefined; + const message = block.message?.trim(); + if (message !== undefined && message.length > 0) { + return { + event: 'UserPromptSubmit', + message, + text: renderHookResult('UserPromptSubmit', message), + }; + } + const reason = block.reason?.trim(); + const result = + reason === undefined || reason.length === 0 ? 'Blocked by UserPromptSubmit hook' : reason; + return { + event: 'UserPromptSubmit', + message: result, + text: renderHookResult('UserPromptSubmit', result), + }; +} + +function userPromptHookMessage(result: HookResult): string | undefined { + if (result.timedOut === true || (result.exitCode !== undefined && result.exitCode !== 0)) { + return undefined; + } + const message = result.message?.trim(); + if (message !== undefined && message.length > 0) return message; + const stdout = result.stdout?.trim(); + return stdout === undefined || stdout.length === 0 ? undefined : stdout; +} + +function isNonEmptyString(value: string | undefined): value is string { + return value !== undefined && value.length > 0; +} diff --git a/packages/agent-core/src/agent/index.ts b/packages/agent-core/src/agent/index.ts new file mode 100644 index 000000000..d502ae344 --- /dev/null +++ b/packages/agent-core/src/agent/index.ts @@ -0,0 +1,518 @@ +import { createHash } from 'node:crypto'; +import { join } from 'node:path'; + +import { ErrorCodes, KimiError, makeErrorPayload } from '#/errors'; +import { log } from '#/logging/logger'; +import type { Logger } from '#/logging/types'; +import type { AgentAPI, AgentEvent, SDKAgentRPC, UsageStatus } from '#/rpc'; +import { + generate, + type ChatProvider, + type Message, + type Tool, +} from '@moonshot-ai/kosong'; + +import type { McpConnectionManager } from '../mcp'; +import { + resolveSystemPromptCwd, + type PreparedSystemPromptContext, + type ResolvedAgentProfile, +} from '../profile'; +import type { ProviderManager } from '../providers/provider-manager'; +import { withProviderRequestAuth } from '../providers/request-auth'; +import type { RuntimeConfig } from '../runtime-types'; +import type { SessionSubagentHost } from '../session/subagent-host'; +import type { SkillRegistry } from '../skill'; +import { noopTelemetryClient, type TelemetryClient } from '../telemetry'; +import { + estimateTokens, + estimateTokensForMessages, + estimateTokensForTools, +} from '../utils/tokens'; +import type { PromisableMethods } from '../utils/types'; +import { BackgroundManager } from './background'; +import { FullCompaction, type CompactionStrategy } from './compaction'; +import { ConfigState } from './config'; +import { ContextMemory } from './context'; +import { HookEngine } from './hooks'; +import { InjectionManager } from './injection/manager'; +import { PermissionManager, type PermissionManagerOptions } from './permission'; +import { PlanMode } from './plan'; +import { + AgentRecords, + FileSystemAgentRecordPersistence, + restoreAgentRecord, + type AgentRecord, + type AgentRecordPersistence, +} from './records'; +import { ReplayBuilder } from './replay'; +import { SkillManager } from './skill'; +import { ToolManager } from './tool/index'; +import { TurnFlow } from './turn'; +import { + GENERATE_REQUEST_LOG_CONTEXT, + type GenerateOptionsWithRequestLog, +} from './turn/kosong-llm'; +import { UsageRecorder } from './usage'; + +export type { AgentRecord, AgentRecordPersistence } from './records'; +export type { BuiltinTool, ToolInfo, ToolSource, UserToolRegistration } from './tool'; + +export type AgentType = 'main' | 'sub' | 'independent'; + +export interface AgentConfig { + readonly runtime: RuntimeConfig; + readonly homedir?: string; + readonly skills?: SkillRegistry; + readonly rpc: SDKAgentRPC; + readonly persistence?: AgentRecordPersistence; + readonly type?: AgentType; + readonly generate?: typeof generate; + readonly compactionStrategy?: CompactionStrategy; + readonly providerManager?: ProviderManager | undefined; + readonly sessionId?: string; + readonly subagentHost?: SessionSubagentHost | undefined; + readonly mcp?: McpConnectionManager; + readonly hookEngine?: HookEngine; + readonly backgroundMaxRunningTasks?: number; + readonly backgroundSessionDir?: string; + readonly permission?: PermissionManagerOptions | undefined; + readonly onRecord?: ((record: AgentRecord) => void) | undefined; + /** Parent logger; the agent appends its own ctx (agentId already bound by session). */ + readonly log?: Logger; + readonly telemetry?: TelemetryClient | undefined; +} + +export class Agent { + readonly runtime: RuntimeConfig; + readonly homedir?: string; + readonly skills?: SkillManager; + readonly rawGenerate: typeof generate; + readonly rpc: SDKAgentRPC; + readonly telemetry: TelemetryClient; + readonly providerManager: ProviderManager | undefined; + readonly subagentHost: SessionSubagentHost | undefined; + readonly mcp: McpConnectionManager | undefined; + readonly hooks: HookEngine | undefined; + + readonly type: AgentType; + readonly records: AgentRecords; + readonly fullCompaction: FullCompaction; + readonly context: ContextMemory; + readonly config: ConfigState; + readonly turn: TurnFlow; + readonly injection: InjectionManager; + readonly permission: PermissionManager; + readonly planMode: PlanMode; + readonly usage: UsageRecorder; + readonly tools: ToolManager; + readonly background: BackgroundManager; + readonly replayBuilder: ReplayBuilder; + readonly log: Logger; + + private lastLlmConfigLogSignature?: string; + + constructor(config: AgentConfig) { + this.log = config.log ?? log; + this.runtime = config.runtime; + this.homedir = config.homedir; + if (config.skills !== undefined) { + this.skills = new SkillManager(this, config.skills); + } + this.rawGenerate = config.generate ?? generate; + this.providerManager = + config.sessionId === undefined + ? config.providerManager + : config.providerManager?.withPromptCacheKey(config.sessionId); + this.subagentHost = config.subagentHost; + this.mcp = config.mcp; + this.hooks = config.hookEngine; + + this.type = config.type ?? 'main'; + + this.rpc = config.rpc; + this.telemetry = config.telemetry ?? noopTelemetryClient; + this.records = new AgentRecords( + (record) => { + restoreAgentRecord(this, record); + }, + config.persistence ?? + (config.homedir + ? new FileSystemAgentRecordPersistence(join(config.homedir, 'wire.jsonl'), { + onError: (error) => { + this.emitRecordsWriteError(error); + }, + }) + : undefined), + ); + this.records.onRecord = config.onRecord; + this.records.onError = (error, record) => { + this.emitRecordsWriteError(error, record); + }; + this.fullCompaction = new FullCompaction(this, config.compactionStrategy); + this.context = new ContextMemory(this); + this.config = new ConfigState(this); + this.turn = new TurnFlow(this); + this.injection = new InjectionManager(this); + this.permission = new PermissionManager(this, config.permission); + this.planMode = new PlanMode(this); + this.usage = new UsageRecorder(this); + this.tools = new ToolManager(this); + this.background = new BackgroundManager(this, { + maxRunningTasks: config.backgroundMaxRunningTasks, + sessionDir: config.backgroundSessionDir, + }); + this.replayBuilder = new ReplayBuilder(this); + } + + get generate(): typeof generate { + return async (provider, systemPrompt, tools, history, callbacks, options) => { + if (options?.auth !== undefined) { + this.logLlmRequest(provider, systemPrompt, tools, history, options); + return this.rawGenerate(provider, systemPrompt, tools, history, callbacks, options); + } + const modelAlias = this.config.modelAlias; + const resolveAuth = + modelAlias === undefined + ? undefined + : this.providerManager?.createAuthResolverForModel(modelAlias, { + log: this.log, + }); + return withProviderRequestAuth(resolveAuth, (auth) => { + const requestOptions = auth === undefined ? options : { ...options, auth }; + this.logLlmRequest(provider, systemPrompt, tools, history, requestOptions); + return this.rawGenerate(provider, systemPrompt, tools, history, callbacks, requestOptions); + }); + }; + } + + private logLlmRequest( + provider: ChatProvider, + systemPrompt: string, + tools: readonly Tool[], + history: readonly Message[], + options: Parameters[5], + ): void { + const context = buildLlmRequestContext(options); + const configMetadata = buildLlmConfigMetadata( + provider, + this.config.modelAlias, + systemPrompt, + tools, + ); + this.logLlmConfigIfChanged( + context, + configMetadata, + buildLlmConfigSignature(configMetadata, systemPrompt, tools), + ); + this.log.info('llm request', { + ...context, + ...buildLlmRequestMetadata(systemPrompt, tools, history), + }); + } + + private logLlmConfigIfChanged( + context: LlmRequestContextFields, + metadata: LlmConfigMetadata, + signature: string, + ): void { + if (signature === this.lastLlmConfigLogSignature) return; + this.lastLlmConfigLogSignature = signature; + this.log.info('llm config', { + ...context, + ...metadata, + }); + } + + useProfile(profile: ResolvedAgentProfile, context?: PreparedSystemPromptContext): void { + const cwd = context?.cwd ?? resolveSystemPromptCwd(this.runtime.kaos, this.config.cwd); + const systemPrompt = profile.systemPrompt({ + osEnv: this.runtime.osEnv, + cwd, + skills: this.skills?.registry, + cwdListing: context?.cwdListing, + agentsMd: context?.agentsMd, + }); + this.config.update({ profileName: profile.name, systemPrompt }); + this.tools.setActiveTools(profile.tools); + } + + async resume(): Promise { + await this.records.replay(); + await this.background.loadFromDisk(); + await this.background.reconcile(); + this.turn.finishResume(); + } + + get rpcMethods(): PromisableMethods { + return { + prompt: (payload) => { + this.turn.prompt(payload.input); + }, + steer: (payload) => { + this.telemetry.track('input_steer', { parts: payload.input.length }); + this.turn.steer(payload.input); + }, + cancel: (payload) => { + if (this.turn.hasActiveTurn) { + this.telemetry.track('cancel', { from: 'streaming' }); + } + this.turn.cancel(payload.turnId); + }, + setThinking: (payload) => { + const wasEnabled = this.config.thinkingLevel !== 'off'; + this.config.update({ thinkingLevel: payload.level }); + const enabled = this.config.thinkingLevel !== 'off'; + if (enabled !== wasEnabled) { + this.telemetry.track('thinking_toggle', { enabled }); + } + }, + setPermission: (payload) => { + const wasYolo = this.permission.mode === 'yolo'; + const wasAuto = this.permission.mode === 'auto'; + this.permission.setMode(payload.mode); + const enabled = this.permission.mode === 'yolo'; + if (enabled !== wasYolo) { + this.telemetry.track('yolo_toggle', { enabled }); + } + const afkEnabled = this.permission.mode === 'auto'; + if (afkEnabled !== wasAuto) { + this.telemetry.track('afk_toggle', { enabled: afkEnabled }); + } + }, + setModel: async (payload) => { + const previous = this.config.modelAlias; + const resolved = await this.providerManager?.resolveProviderForModel(payload.model); + if (resolved === undefined) { + throw new Error('Runtime provider model cannot be empty'); + } + this.config.update({ + modelAlias: resolved.modelName, + }); + if (previous !== resolved.modelName) { + this.telemetry.track('model_switch', { model: resolved.modelName }); + } + return { + model: resolved.modelName, + providerName: resolved.providerName, + }; + }, + getModel: () => { + return this.config.modelAlias ?? ''; + }, + enterPlan: async () => { + await this.planMode.enter(); + }, + cancelPlan: (payload) => { + this.planMode.cancel(payload.id); + }, + clearPlan: () => this.planMode.clear(), + beginCompaction: (payload) => { + this.fullCompaction.begin({ source: 'manual', instruction: payload.instruction }); + }, + cancelCompaction: () => { + if (this.fullCompaction.isCompacting) { + this.telemetry.track('cancel', { from: 'compacting' }); + } + this.fullCompaction.cancel(); + }, + registerTool: (payload) => { + this.tools.registerUserTool(payload); + }, + unregisterTool: (payload) => { + this.tools.unregisterUserTool(payload.name); + }, + setActiveTools: (payload) => { + this.tools.setActiveTools(payload.names); + }, + stopBackground: (payload) => { + void this.background.stop(payload.taskId, payload.reason); + }, + clearContext: () => { + this.context.clear(); + }, + activateSkill: (payload) => { + if (this.skills === undefined) { + throw new KimiError(ErrorCodes.SKILL_NOT_FOUND, `Skill "${payload.name}" was not found`); + } + this.skills.activate(payload); + }, + getBackgroundOutput: (payload) => this.background.readOutput(payload.taskId, payload.tail), + getBackgroundOutputPath: (payload) => this.background.getOutputPath(payload.taskId), + getContext: () => this.context.data(), + getConfig: () => this.config.data(), + getPermission: () => this.permission.data(), + getPlan: () => this.planMode.data(), + getUsage: () => this.usage.data(), + getTools: () => this.tools.data(), + getBackground: (payload) => this.background.list(payload.activeOnly ?? false, payload.limit), + }; + } + + emitEvent(event: AgentEvent): void { + if (this.records.restoring) return; + void this.rpc.emitEvent(event); + } + + emitStatusUpdated(): void { + if (this.records.restoring) return; + if (!this.config.hasModel) return; + + const contextTokens = this.context.tokenCount; + const maxContextTokens = this.config.modelCapabilities.max_context_tokens; + const contextUsage = + maxContextTokens !== undefined && maxContextTokens > 0 + ? contextTokens / maxContextTokens + : undefined; + const usage: UsageStatus | undefined = this.usage.status(); + const model = this.config.model; + + this.emitEvent({ + type: 'agent.status.updated', + model, + contextTokens, + maxContextTokens, + contextUsage, + planMode: this.planMode.isActive, + permission: this.permission.mode, + usage, + }); + } + + private emitRecordsWriteError(error: unknown, record?: AgentRecord | undefined): void { + const message = error instanceof Error ? error.message : String(error); + this.log.error('wire record persist failed', { + agentHomedir: this.homedir, + recordType: record?.type, + error, + }); + this.emitEvent({ + type: 'error', + ...makeErrorPayload( + ErrorCodes.RECORDS_WRITE_FAILED, + `Failed to write agent records: ${message}`, + { + details: { recordType: record?.type }, + }, + ), + }); + } +} + +interface LlmRequestContextFields { + turnId?: string; + step?: number; + attempt?: number; + maxAttempts?: number; +} + +interface LlmRequestMetadata { + estimatedInputTokens: number; + messageCount: number; + toolCallCount: number; + partialMessageCount?: number; +} + +/** + * Fields that identify an LLM configuration for deduplication. + * Keep this interface simple and avoid dynamic keys — the shape is + * serialized with `JSON.stringify` to produce a stable signature in + * `logLlmConfigIfChanged`. + */ +interface LlmConfigMetadata { + provider: string; + model: string; + modelAlias?: string; + thinkingEffort?: string; + systemPromptChars: number; + toolCount: number; +} + +function buildLlmRequestContext(options: Parameters[5]): LlmRequestContextFields { + const context = requestLogContext(options); + if (context === undefined) return {}; + + const fields: LlmRequestContextFields = { + turnId: context.turnId, + step: context.step, + }; + if ( + context.attempt !== undefined && + context.maxAttempts !== undefined && + context.attempt > 1 + ) { + fields.attempt = context.attempt; + fields.maxAttempts = context.maxAttempts; + } + return fields; +} + +function buildLlmRequestMetadata( + systemPrompt: string, + tools: readonly Tool[], + history: readonly Message[], +): LlmRequestMetadata { + let toolCallCount = 0; + let partialMessageCount = 0; + + for (const message of history) { + if (message.partial === true) partialMessageCount += 1; + toolCallCount += message.toolCalls.length; + } + + const estimatedInputTokens = + estimateTokens(systemPrompt) + + estimateTokensForMessages([...history]) + + estimateTokensForTools(tools); + + const metadata: LlmRequestMetadata = { + estimatedInputTokens, + messageCount: history.length, + toolCallCount, + }; + if (partialMessageCount > 0) { + metadata.partialMessageCount = partialMessageCount; + } + return metadata; +} + +function buildLlmConfigMetadata( + provider: ChatProvider, + modelAlias: string | undefined, + systemPrompt: string, + tools: readonly Tool[], +): LlmConfigMetadata { + return { + provider: provider.name, + model: provider.modelName, + modelAlias, + thinkingEffort: provider.thinkingEffort ?? undefined, + systemPromptChars: systemPrompt.length, + toolCount: tools.length, + }; +} + +function buildLlmConfigSignature( + metadata: LlmConfigMetadata, + systemPrompt: string, + tools: readonly Tool[], +): string { + const toolsForSignature = tools.map(({ name, description, parameters }) => ({ + name, + description, + parameters, + })); + return JSON.stringify({ + ...metadata, + systemPromptHash: fingerprint(systemPrompt), + toolsHash: fingerprint(JSON.stringify(toolsForSignature)), + }); +} + +function fingerprint(content: string): string { + return createHash('sha256').update(content).digest('hex'); +} + +function requestLogContext(options: Parameters[5]) { + return (options as GenerateOptionsWithRequestLog | undefined)?.[GENERATE_REQUEST_LOG_CONTEXT]; +} diff --git a/packages/agent-core/src/agent/injection/injector.ts b/packages/agent-core/src/agent/injection/injector.ts new file mode 100644 index 000000000..1084e32ed --- /dev/null +++ b/packages/agent-core/src/agent/injection/injector.ts @@ -0,0 +1,33 @@ +import type { Agent } from '..'; + +export abstract class DynamicInjector { + protected injectedAt: number | null = null; + + constructor(protected readonly agent: Agent) {} + + onContextClear(): void { + this.injectedAt = null; + } + + onContextCompacted(compactedCount: number): void { + if (this.injectedAt !== null) { + const newInjectedAt = this.injectedAt - compactedCount + 1; + this.injectedAt = newInjectedAt >= 0 ? newInjectedAt : null; + } + } + + async inject(): Promise { + const injection = await this.getInjection(); + if (injection) { + this.injectedAt = this.agent.context.history.length; + this.agent.context.appendSystemReminder(injection, { + kind: 'injection', + variant: this.injectionVariant, + }); + } + } + + protected abstract readonly injectionVariant: string; + + protected abstract getInjection(): string | Promise | undefined; +} diff --git a/packages/agent-core/src/agent/injection/manager.ts b/packages/agent-core/src/agent/injection/manager.ts new file mode 100644 index 000000000..ffc1fcf01 --- /dev/null +++ b/packages/agent-core/src/agent/injection/manager.ts @@ -0,0 +1,34 @@ +import type { Agent } from '..'; +import type { DynamicInjector } from './injector'; +import { PermissionModeInjector } from './permission-mode'; +import { PlanModeInjector } from './plan-mode'; + +export class InjectionManager { + private readonly injectors: DynamicInjector[]; + + constructor(protected readonly agent: Agent) { + this.injectors = [new PlanModeInjector(agent), new PermissionModeInjector(agent)]; + } + + async inject(): Promise { + for (const injector of this.injectors) { + await injector.inject(); + } + } + + onContextClear(): void { + for (const injector of this.injectors) { + injector.onContextClear(); + } + } + + onContextCompacted(compactedCount: number): void { + for (const injector of this.injectors) { + try { + injector.onContextCompacted(compactedCount); + } catch { + continue; + } + } + } +} diff --git a/packages/agent-core/src/agent/injection/permission-mode.ts b/packages/agent-core/src/agent/injection/permission-mode.ts new file mode 100644 index 000000000..638ed6760 --- /dev/null +++ b/packages/agent-core/src/agent/injection/permission-mode.ts @@ -0,0 +1,30 @@ +import type { PermissionMode } from '../permission'; +import { DynamicInjector } from './injector'; + +const AUTO_MODE_ENTER_REMINDER = [ + 'Auto permission mode is active. Tool approvals will be handled automatically while this mode remains enabled.', + ' - Continue normally without pausing for approval prompts.', + ' - Do NOT call AskUserQuestion while auto mode is active. Make a reasonable decision and continue without asking the user.', +].join('\n'); + +const AUTO_MODE_EXIT_REMINDER = [ + 'Auto permission mode is no longer active. Tool approvals and permission checks are back to the current mode.', + ' - Continue normally, but expect approval prompts or denials when a tool requires them.', +].join('\n'); + +export class PermissionModeInjector extends DynamicInjector { + protected override readonly injectionVariant = 'permission_mode'; + private lastMode: PermissionMode | undefined; + + getInjection(): string | undefined { + const mode = this.agent.permission.mode; + const previousMode = this.lastMode; + + if (mode === previousMode) return undefined; + + this.lastMode = mode; + if (mode === 'auto') return AUTO_MODE_ENTER_REMINDER; + if (previousMode === 'auto') return AUTO_MODE_EXIT_REMINDER; + return undefined; + } +} diff --git a/packages/agent-core/src/agent/injection/plan-mode.ts b/packages/agent-core/src/agent/injection/plan-mode.ts new file mode 100644 index 000000000..846d7b582 --- /dev/null +++ b/packages/agent-core/src/agent/injection/plan-mode.ts @@ -0,0 +1,181 @@ +import type { PlanFilePath } from '../plan'; +import { DynamicInjector } from './injector'; + +const PLAN_MODE_DEDUP_MIN_TURNS = 2; +const PLAN_MODE_FULL_REFRESH_TURNS = 5; + +/** + * Plan-mode reminder variants. + * + * `reentry` is used once when a restored planning session already has plan + * content. `full` is used for the first reminder and periodic refreshes. + * `sparse` keeps the read-only invariant visible between full reminders. + */ +export type PlanModeVariant = 'full' | 'sparse' | 'reentry'; + +export class PlanModeInjector extends DynamicInjector { + protected override readonly injectionVariant = 'plan_mode'; + private wasActive = false; + + override onContextClear(): void { + super.onContextClear(); + this.wasActive = this.agent.planMode.isActive; + } + + override async getInjection(): Promise { + const { isActive, planFilePath } = this.agent.planMode; + if (!isActive) { + if (!this.wasActive) { + return undefined; + } + this.wasActive = false; + this.injectedAt = null; + return exitReminder(); + } + if (!this.wasActive) { + this.injectedAt = null; + this.wasActive = true; + if (await this.hasCurrentPlanContent()) { + return reentryReminder(planFilePath); + } + } + const variant = this.getVariant(); + if (variant === null) return undefined; + + return variant === 'full' + ? fullReminder(planFilePath) + : variant === 'sparse' + ? sparseReminder(planFilePath) + : reentryReminder(planFilePath); + } + + protected getVariant(): PlanModeVariant | null { + if (this.injectedAt === null) return 'full'; + const history = this.agent.context.history; + let assistantTurnsSince = 0; + for (let i = this.injectedAt + 1; i < history.length; i++) { + const msg = history[i]; + if (msg === undefined) continue; + if (msg.role === 'assistant') { + assistantTurnsSince += 1; + continue; + } + if (msg.role === 'user') { + return 'full'; + } + } + if (assistantTurnsSince >= PLAN_MODE_FULL_REFRESH_TURNS) return 'full'; + if (assistantTurnsSince >= PLAN_MODE_DEDUP_MIN_TURNS) return 'sparse'; + return null; + } + + private async hasCurrentPlanContent(): Promise { + try { + const data = await this.agent.planMode.data(); + return data !== null && data.content.trim().length > 0; + } catch { + return false; + } + } +} +function withPlanFileFooter(body: string, planFilePath: PlanFilePath): string { + if (planFilePath === null || planFilePath.length === 0) return body; + return `${body}\n\nPlan file: ${planFilePath}`; +} + +function fullReminder(planFilePath: PlanFilePath): string { + if (planFilePath === null || planFilePath.length === 0) { + return inlineFullReminder(); + } + + const body = `Plan mode is active. You MUST NOT make any edits (with the exception of the current plan file) or otherwise make changes to the system unless a tool request is explicitly approved. Prefer read-only tools. Use Bash only when needed; Bash follows the normal permission mode and rules. This supersedes any other instructions you have received. + +Workflow: + 1. Understand — explore the codebase with Glob, Grep, Read. + 2. Design — converge on the best approach; consider trade-offs but aim for a single recommendation. + 3. Review — re-read key files to verify understanding. + 4. Write Plan — modify the plan file with Write or Edit. Use Write if the plan file does not exist yet. + 5. Exit — call ExitPlanMode for user approval. + +## Handling multiple approaches +Keep it focused: at most 2-3 meaningfully different approaches. Do NOT pad with minor variations — if one approach is clearly superior, just propose that one. +When the best approach depends on user preferences, constraints, or context you don't have, use AskUserQuestion to clarify first. This helps you write a better, more targeted plan rather than dumping multiple options for the user to sort through. +When you do include multiple approaches in the plan, you MUST pass them as the \`options\` parameter when calling ExitPlanMode, so the user can select which approach to execute at approval time. +NEVER write multiple approaches in the plan and call ExitPlanMode without the \`options\` parameter — the user will only see the default approval controls with no way to choose a specific approach. + +AskUserQuestion is for clarifying missing requirements or user preferences that affect the plan. +Never ask about plan approval via text or AskUserQuestion. +Your turn must end with either AskUserQuestion (to clarify requirements or preferences) or ExitPlanMode (to request plan approval). Do NOT end your turn any other way. +Do NOT use AskUserQuestion to ask about plan approval or reference "the plan" — the user cannot see the plan until you call ExitPlanMode.`; + return withPlanFileFooter(body, planFilePath); +} + +function sparseReminder(planFilePath: PlanFilePath): string { + if (planFilePath === null || planFilePath.length === 0) { + return inlineSparseReminder(); + } + + const body = `Plan mode still active (see full instructions earlier). Prefer read-only tools except the current plan file. Use Write or Edit to modify the plan file. If it does not exist yet, create it with Write first. Use Bash only when needed; Bash follows the normal permission mode and rules. Use AskUserQuestion to clarify user preferences when it helps you write a better plan. If the plan has multiple approaches, pass options to ExitPlanMode so the user can choose. End turns with AskUserQuestion (for clarifications) or ExitPlanMode (for approval). Never ask about plan approval via text or AskUserQuestion.`; + return withPlanFileFooter(body, planFilePath); +} + +function reentryReminder(planFilePath: PlanFilePath): string { + if (planFilePath === null || planFilePath.length === 0) { + return inlineReentryReminder(); + } + + const body = `Plan mode is active. You MUST NOT make any edits (with the exception of the current plan file) or otherwise make changes to the system unless a tool request is explicitly approved. Prefer read-only tools. Use Bash only when needed; Bash follows the normal permission mode and rules. This supersedes any other instructions you have received. + +## Re-entering Plan Mode +A plan file from a previous planning session already exists. +Before proceeding: + 1. Read the existing plan file to understand what was previously planned. + 2. Evaluate the user's current request against that plan. + 3. If different task: replace the old plan with a fresh one. If same task: update the existing plan. + 4. You may use Write or Edit to modify the plan file. If the file does not exist yet, create it with Write first. + 5. Use AskUserQuestion to clarify missing requirements or user preferences that affect the plan. + 6. Always edit the plan file before calling ExitPlanMode. + +Your turn must end with either AskUserQuestion (to clarify requirements) or ExitPlanMode (to request plan approval).`; + return withPlanFileFooter(body, planFilePath); +} + +function inlineFullReminder(): string { + return `Plan mode is active. You MUST NOT make any edits or otherwise make changes to the system unless a tool request is explicitly approved. Prefer read-only tools. Use Bash only when needed; Bash follows the normal permission mode and rules. This supersedes any other instructions you have received. + +Workflow: + 1. Understand — explore the codebase with Glob, Grep, Read. + 2. Design — converge on the best approach; consider trade-offs but aim for a single recommendation. + 3. Review — re-read key files to verify understanding. + 4. Wait for the host to provide a plan file path, write the plan there, then call ExitPlanMode. + +## Handling multiple approaches +Keep it focused: at most 2-3 meaningfully different approaches. Do NOT pad with minor variations — if one approach is clearly superior, just propose that one. +When the best approach depends on user preferences, constraints, or context you don't have, use AskUserQuestion to clarify first. +When you do include multiple approaches in the plan, you MUST pass them as the \`options\` parameter when calling ExitPlanMode, so the user can select which approach to execute at approval time. + +AskUserQuestion is for clarifying missing requirements or user preferences that affect the plan. +Never ask about plan approval via text or AskUserQuestion. +Your turn must end with either AskUserQuestion (to clarify requirements or preferences) or ExitPlanMode (to request plan approval). Do NOT end your turn any other way.`; +} + +function inlineSparseReminder(): string { + return `Plan mode still active (see full instructions earlier). Read-only; no plan file path is available in this host. Wait for the host to provide a plan file path before calling ExitPlanMode. Use AskUserQuestion to clarify user preferences when it helps you write a better plan. If the plan has multiple approaches, pass options to ExitPlanMode so the user can choose. End turns with AskUserQuestion (for clarifications) or ExitPlanMode (for approval).`; +} + +function inlineReentryReminder(): string { + return `Plan mode is active. You MUST NOT make any edits or otherwise make changes to the system unless a tool request is explicitly approved. Prefer read-only tools. Use Bash only when needed; Bash follows the normal permission mode and rules. This supersedes any other instructions you have received. + +## Re-entering Plan Mode +No plan file path is available in this host. +Before proceeding: + 1. Re-evaluate the user request and any existing conversation context. + 2. Use AskUserQuestion to clarify missing requirements or user preferences that affect the plan. + 3. Wait for the host to provide a plan file path, write the revised plan there, then call ExitPlanMode. + +Your turn must end with either AskUserQuestion (to clarify requirements) or ExitPlanMode (to request plan approval).`; +} + +function exitReminder(): string { + return `Plan mode is no longer active. The read-only and plan-file-only restrictions from plan mode no longer apply. Continue with the approved plan using the normal tool and permission rules.`; +} diff --git a/packages/agent-core/src/agent/permission/action-label.ts b/packages/agent-core/src/agent/permission/action-label.ts new file mode 100644 index 000000000..ad1565de4 --- /dev/null +++ b/packages/agent-core/src/agent/permission/action-label.ts @@ -0,0 +1,142 @@ +/** + * describeApprovalAction — coarse action label for approve_for_session. + * + * The label is the key used by `auto_approve_actions` set (session-level + * approve-for-session cache). It must be **coarse** enough that the user + * pressing "approve for session" on one request also unlocks *semantically + * equivalent* future requests — otherwise approve-for-session degrades + * into approve-once. + * + * Derivation priority: + * 1. `ApprovalDisplay.kind` mapping (the display already carries the + * semantic classification the UI renders): + * command → "run command" + * diff → "edit file" + * file_write → "write file" + * task_stop → "stop background task" + * generic → tool-name fallback + * 2. Hard-coded toolName → action map for tools that emit `generic` + * display. + * 3. Last resort: `call `. + */ + +import type { ToolInputDisplay } from '../../tools/display/schemas'; + +/** + * Hard-coded toolName → action label map. Consulted as a fallback so + * tools that carry `generic` displays still get a sensible label. Keys + * match the tool names currently wired in + * `packages/agent-core/src/tools/`. + */ +const TOOL_NAME_TO_ACTION: Readonly> = { + Bash: 'run command', + Shell: 'run command', + BackgroundRun: 'run background command', + BackgroundStop: 'stop background task', + Write: 'edit file', + Edit: 'edit file', + StrReplace: 'edit file', +}; + +/** Inverse table — action label → the representative tool-name pattern. */ +const ACTION_TO_PATTERN: Readonly> = { + 'run command': 'Bash', + 'run command in plan mode': null, + 'run background command': 'BackgroundRun', + 'stop background task': 'BackgroundStop', + 'edit file': 'Write', + 'edit file outside of working directory': 'Write', + 'write file': 'Write', +}; + +export function describeApprovalAction( + toolName: string, + _args: unknown, + display: ToolInputDisplay, + override?: string, +): string { + // Highest priority: explicit override from a hook. + if (override !== undefined && override.length > 0) { + return override; + } + + // Display-driven derivation: the display kind already captures the + // coarse semantic class the UI renders. + switch (display.kind) { + case 'command': + return 'run command'; + case 'diff': + return 'edit file'; + case 'file_io': + switch (display.operation) { + case 'write': + return 'write file'; + case 'edit': + return 'edit file'; + case 'read': + return 'read file'; + case 'glob': + return 'list files'; + case 'grep': + return 'search files'; + } + break; + case 'task_stop': + return 'stop background task'; + case 'plan_review': + return 'review plan'; + case 'agent_call': + return 'spawn agent'; + case 'skill_call': + return 'invoke skill'; + case 'url_fetch': + return 'fetch URL'; + case 'search': + return 'search'; + case 'todo_list': + return 'update todo list'; + case 'background_task': + return 'run background task'; + case 'generic': + // fall through to tool-name map + break; + } + + const mapped = TOOL_NAME_TO_ACTION[toolName]; + if (mapped !== undefined) return mapped; + + // MCP tool naming: `mcp____` gets a coarse + // `"call MCP tool: :"` label so one approve-for-session + // click unlocks every future call to the same server+tool combo. The + // server name is preserved to prevent cross-server privilege escalation. + if (toolName.startsWith('mcp__')) { + const rest = toolName.slice('mcp__'.length); + const sep = rest.indexOf('__'); + if (sep >= 0) { + const serverName = rest.slice(0, sep); + const innerTool = rest.slice(sep + 2); + if (innerTool.length > 0) { + return `call MCP tool: ${serverName}:${innerTool}`; + } + } + } + + return `call ${toolName}`; +} + +/** + * Inverse mapping from an approve_for_session action label to the + * permission-rule pattern that should gate future same-action calls. + * + * When no entry matches, fall back to the concrete tool name. A `null` + * table entry means the action should be cached by action label only, + * without creating a broad PermissionRule. + */ +export function actionToRulePattern( + action: string, + fallbackToolName: string, +): string | undefined { + const mapped = ACTION_TO_PATTERN[action]; + if (mapped !== undefined) return mapped ?? undefined; + return fallbackToolName; +} diff --git a/packages/agent-core/src/agent/permission/check-rules.ts b/packages/agent-core/src/agent/permission/check-rules.ts new file mode 100644 index 000000000..484cc11eb --- /dev/null +++ b/packages/agent-core/src/agent/permission/check-rules.ts @@ -0,0 +1,59 @@ +import { isDefaultAutoAllowTool } from '../../tools/policies/default-permissions'; +import { matchesRule } from './matches-rule'; +import type { PermissionPathMatchOptions } from './path-glob-match'; +import type { PermissionMode, PermissionRule, PermissionRuleDecision } from './types'; + +export interface CheckRulesResult { + readonly decision: PermissionRuleDecision; + /** Rule that produced `decision`. `undefined` for mode/default auto-allow. */ + readonly matchedRule?: PermissionRule | undefined; +} + +export function checkMatchingRules( + rules: readonly PermissionRule[], + toolName: string, + toolInput: unknown, + mode: PermissionMode, + pathOptions?: PermissionPathMatchOptions, +): CheckRulesResult | undefined { + // Priority 1: deny wins in every mode. + for (const rule of rules) { + if (rule.decision === 'deny' && matchesRule(rule, toolName, toolInput, pathOptions)) { + return { decision: 'deny', matchedRule: rule }; + } + } + + const askRule = firstMatchingRule(rules, 'ask', toolName, toolInput, pathOptions); + const allowRule = firstMatchingRule(rules, 'allow', toolName, toolInput, pathOptions); + if (askRule === undefined && allowRule === undefined) return undefined; + + if (isDefaultAutoAllowTool(toolName)) { + return { decision: 'allow' }; + } + + // Mode overlay: yolo treats everything non-deny as allow. + if (mode === 'yolo' || mode === 'auto') { + return { decision: 'allow' }; + } + + // Priority 2: ask before allow so unresolved ambiguity defers to the user. + if (askRule !== undefined) return { decision: 'ask', matchedRule: askRule }; + + // Priority 3: explicit allow. + return { decision: 'allow', matchedRule: allowRule }; +} + +function firstMatchingRule( + rules: readonly PermissionRule[], + decision: PermissionRuleDecision, + toolName: string, + toolInput: unknown, + pathOptions?: PermissionPathMatchOptions, +): PermissionRule | undefined { + for (const rule of rules) { + if (rule.decision === decision && matchesRule(rule, toolName, toolInput, pathOptions)) { + return rule; + } + } + return undefined; +} diff --git a/packages/agent-core/src/agent/permission/index.ts b/packages/agent-core/src/agent/permission/index.ts new file mode 100644 index 000000000..7628d8405 --- /dev/null +++ b/packages/agent-core/src/agent/permission/index.ts @@ -0,0 +1,340 @@ +import type { Agent } from '..'; +import type { PrepareToolExecutionResult, ToolExecutionHookContext } from '../../loop'; +import type { TelemetryPropertyValue } from '../../telemetry'; +import { isDefaultAutoAllowTool } from '../../tools/policies/default-permissions'; +import type { ToolInputDisplay } from '../../tools/display'; +import { actionToRulePattern, describeApprovalAction } from './action-label'; +import { checkMatchingRules, type CheckRulesResult } from './check-rules'; +import type { PermissionPathMatchOptions } from './path-glob-match'; +import { createBuiltinPermissionPolicies } from './policies'; +import type { PermissionPolicy, PermissionPolicyResult } from './policy'; +import type { + PermissionApprovalResultRecord, + PermissionData, + PermissionMode, + PermissionRule, +} from './types'; +export * from './policy'; +export * from './types'; + +type ApprovalTelemetryMode = 'manual' | 'yolo' | 'afk' | 'auto_session' | 'cancelled'; + +export interface PermissionManagerOptions { + readonly initialRules?: readonly PermissionRule[] | undefined; + readonly policies?: readonly PermissionPolicy[] | undefined; + readonly parent?: PermissionManager | undefined; +} + +export class PermissionManager { + rules: PermissionRule[] = []; + private modeOverride: PermissionMode | undefined; + private readonly parent: PermissionManager | undefined; + private readonly sessionApprovedActions = new Set(); + private readonly policies: readonly PermissionPolicy[]; + + constructor( + protected readonly agent: Agent, + options: PermissionManagerOptions = {}, + ) { + this.rules = [...(options.initialRules ?? [])]; + this.parent = options.parent; + this.policies = options.policies ?? createBuiltinPermissionPolicies(); + } + + get mode(): PermissionMode { + return this.modeOverride ?? this.parent?.mode ?? 'manual'; + } + + set mode(mode: PermissionMode) { + this.modeOverride = mode; + } + + data(): PermissionData { + return { + mode: this.mode, + rules: this.effectiveRules(), + }; + } + + setMode(mode: PermissionMode): void { + this.agent.records.logRecord({ + type: 'permission.set_mode', + mode, + }); + this.agent.replayBuilder.push({ + type: 'permission_updated', + mode, + }); + this.modeOverride = mode; + this.agent.emitStatusUpdated(); + } + + recordApprovalResult(record: PermissionApprovalResultRecord): void { + this.agent.records.logRecord({ + type: 'permission.record_approval_result', + ...record, + }); + this.agent.replayBuilder.push({ + type: 'approval_result', + record, + }); + if (record.result.decision !== 'approved' || record.result.scope !== 'session') { + return; + } + if (this.sessionApprovedActions.has(record.action)) return; + + const pattern = actionToRulePattern(record.action, record.toolName); + this.sessionApprovedActions.add(record.action); + if (pattern === undefined) return; + + const rule: PermissionRule = { + decision: 'allow', + scope: 'session-runtime', + pattern, + reason: `approve_for_session: ${record.action}`, + }; + if (!this.hasRule(rule)) { + this.rules.push(rule); + } + } + + async beforeToolCall( + context: ToolExecutionHookContext, + ): Promise { + const name = context.toolCall.function.name; + const args = context.args; + + const mode = this.mode; + const { decision, matchedRule } = this.checkPermission(name, args, mode); + if (decision === 'deny') { + return { + block: true, + reason: this.formatMessage(name, matchedRule?.reason), + }; + } + + const policyResult = await this.evaluatePolicies(context, matchedRule); + if (policyResult !== undefined) { + return this.permissionPolicyResultToPrepare(policyResult, context); + } + + if (mode === 'auto') { + if (this.wouldAskInManualMode(name, args)) { + this.trackToolApproved(name, 'afk'); + } + return undefined; + } + if (mode === 'yolo') { + if (this.wouldAskInManualMode(name, args)) { + this.trackToolApproved(name, 'yolo'); + } + return undefined; + } + + if (decision === 'allow') { + if (matchedRule?.scope === 'session-runtime') { + this.trackToolApproved(name, 'auto_session', 'session'); + } + return undefined; + } + + // decision === 'ask' → bounce through ApprovalRuntime. + return this.requestToolApproval(context); + } + + private async requestToolApproval( + context: ToolExecutionHookContext, + options: { + readonly action?: string | undefined; + readonly display?: ToolInputDisplay | undefined; + } = {}, + ): Promise { + const { signal } = context; + const id = context.toolCall.id; + const name = context.toolCall.function.name; + const args = context.args; + const display = + options.display ?? ({ + kind: 'generic', + summary: `Approve ${name}`, + detail: args, + } satisfies ToolInputDisplay); + const action = options.action ?? describeApprovalAction(name, args, display); + if (this.sessionApprovedActions.has(action)) { + this.trackToolApproved(name, 'auto_session', 'session'); + return undefined; + } + + const result = await this.agent.rpc.requestApproval( + { + turnId: Number(context.turnId), + toolCallId: id, + toolName: name, + action, + display, + }, + { signal }, + ); + this.recordApprovalResult({ + turnId: Number(context.turnId), + toolCallId: id, + toolName: name, + action, + result, + }); + + if (result.decision === 'approved') { + this.trackToolApproved( + name, + approvalTelemetryMode(this.mode), + result.scope === 'session' ? 'session' : 'once', + ); + return undefined; + } + + this.agent.telemetry.track('tool_rejected', { + tool_name: name, + approval_mode: + result.decision === 'cancelled' ? 'cancelled' : approvalTelemetryMode(this.mode), + decision: result.decision, + has_feedback: result.feedback !== undefined && result.feedback.length > 0, + }); + + return { + block: true, + reason: this.formatApprovalRejectionMessage(name, result), + }; + } + + private async evaluatePolicies( + context: ToolExecutionHookContext, + matchedRule: PermissionRule | undefined, + ): Promise { + for (const policy of this.policies) { + const result = await policy.evaluate({ + agent: this.agent, + mode: this.mode, + toolCallContext: context, + matchedRule, + recordApprovalResult: (record) => { + this.recordApprovalResult(record); + }, + }); + if (result !== undefined) return result; + } + return undefined; + } + + private checkPermission( + toolName: string, + toolInput: unknown, + mode: PermissionMode = this.mode, + ): CheckRulesResult { + const matched = this.checkMatchingPermissionRules(toolName, toolInput, mode); + if (matched !== undefined) return matched; + if (isDefaultAutoAllowTool(toolName)) return { decision: 'allow' }; + if (mode === 'yolo' || mode === 'auto') return { decision: 'allow' }; + return { decision: 'ask' }; + } + + private checkMatchingPermissionRules( + toolName: string, + toolInput: unknown, + mode: PermissionMode, + ): CheckRulesResult | undefined { + return ( + checkMatchingRules(this.rules, toolName, toolInput, mode, this.pathMatchOptions()) ?? + this.parent?.checkMatchingPermissionRules(toolName, toolInput, mode) + ); + } + + private effectiveRules(): PermissionRule[] { + return [...this.rules, ...(this.parent?.effectiveRules() ?? [])]; + } + + private wouldAskInManualMode(toolName: string, toolInput: unknown): boolean { + return this.checkPermission(toolName, toolInput, 'manual').decision === 'ask'; + } + + private permissionPolicyResultToPrepare( + result: PermissionPolicyResult, + context: ToolExecutionHookContext, + ): Promise | PrepareToolExecutionResult | undefined { + switch (result.kind) { + case 'allow': + return result.executionMetadata === undefined + ? undefined + : { executionMetadata: result.executionMetadata }; + case 'ask': + return this.requestToolApproval(context, result); + case 'result': + return result.result; + } + } + + private hasRule(target: PermissionRule): boolean { + return this.rules.some((rule) => { + return ( + rule.decision === target.decision && + rule.scope === target.scope && + rule.pattern === target.pattern && + rule.reason === target.reason + ); + }); + } + + protected formatMessage(toolName: string, reason?: string): string { + const suffix = reason !== undefined && reason.length > 0 ? ` Reason: ${reason}` : ''; + if (this.agent.type === 'sub') { + return `Tool "${toolName}" was denied.${suffix} Try a different approach — don't retry the same call, don't attempt to bypass the restriction.`; + } + return `Tool "${toolName}" was denied by permission rule.${suffix}`; + } + + protected formatApprovalRejectionMessage( + toolName: string, + result: { decision: 'approved' | 'rejected' | 'cancelled'; feedback?: string }, + ): string { + const suffix = + result.feedback !== undefined && result.feedback.length > 0 + ? ` Reason: ${result.feedback}` + : ''; + const prefix = + result.decision === 'cancelled' + ? `Tool "${toolName}" was not run because the approval request was cancelled.` + : `Tool "${toolName}" was not run because the user rejected the approval request.`; + if (this.agent.type === 'sub') { + return `${prefix}${suffix} Try a different approach — don't retry the same call, don't attempt to bypass the restriction.`; + } + return `${prefix}${suffix}`; + } + + private pathMatchOptions(): PermissionPathMatchOptions { + return { + cwd: this.agent.config.cwd, + pathClass: this.agent.runtime.kaos.pathClass(), + homeDir: this.agent.runtime.kaos.gethome(), + }; + } + + private trackToolApproved( + toolName: string, + approvalMode: Exclude, + scope?: 'once' | 'session', + ): void { + const properties: Record = { + tool_name: toolName, + approval_mode: approvalMode, + }; + if (scope !== undefined) { + properties['scope'] = scope; + } + this.agent.telemetry.track('tool_approved', properties); + } +} + +function approvalTelemetryMode( + mode: PermissionMode, +): Extract { + return mode === 'auto' ? 'afk' : mode; +} diff --git a/packages/agent-core/src/agent/permission/matches-rule.ts b/packages/agent-core/src/agent/permission/matches-rule.ts new file mode 100644 index 000000000..546567b7c --- /dev/null +++ b/packages/agent-core/src/agent/permission/matches-rule.ts @@ -0,0 +1,125 @@ +/** + * matchesRule — pure function that decides whether a PermissionRule + * applies to a given tool call. + * + * Contract: + * - No side effects, no `this`, no IO, no exceptions. + * - Deterministic: same `(rule, toolName, args)` → same result. + * - Returns boolean only; decision semantics (deny/ask/allow) are a + * caller concern (see `check-rules.ts`). + */ + +import picomatch from 'picomatch'; + +import { parsePattern } from './parse-pattern'; +import { globMatch, pathGlobMatch, type PermissionPathMatchOptions } from './path-glob-match'; +import type { PermissionRule } from './types'; + +type ArgFieldKind = 'generic' | 'path'; + +interface ArgField { + readonly value: string; + readonly kind: ArgFieldKind; +} + +/** + * Tool-specific argument field convention. When a rule uses an arg + * pattern (`Read(./src/**)`), we extract the listed field from the tool + * call args and match the glob against its value. + * + * Unknown tools fall back to `undefined`, which means "arg pattern + * cannot match" — rules with an arg pattern on an unknown tool will + * never fire. Rules without an arg pattern (`UnknownTool`) still match + * on name alone. + */ +function extractArgField(toolName: string, args: unknown): ArgField | undefined { + if (args === null || typeof args !== 'object') return undefined; + const rec = args as Record; + + switch (toolName) { + case 'Bash': + case 'Shell': + case 'Background': + return typeof rec['command'] === 'string' + ? { value: rec['command'], kind: 'generic' } + : undefined; + case 'Read': + case 'Write': + case 'Edit': + case 'ReadMediaFile': + return typeof rec['path'] === 'string' ? { value: rec['path'], kind: 'path' } : undefined; + case 'Grep': + case 'Glob': + return typeof rec['pattern'] === 'string' + ? { value: rec['pattern'], kind: 'generic' } + : undefined; + case 'Task': + case 'Agent': + return typeof rec['subagent_type'] === 'string' + ? { value: rec['subagent_type'], kind: 'generic' } + : undefined; + default: + return undefined; + } +} + +/** + * Decide whether a single rule matches a specific tool call. + * + * Algorithm: + * 1. Parse `rule.pattern` into `{toolName, argPattern?}`. + * 2. If parsed toolName is `*`, skip name check; otherwise compare with + * glob semantics so `mcp__github__*` matches `mcp__github__list`. + * 3. If the rule has no argPattern, name match → rule fires. + * 4. Otherwise extract the tool-specific field value and match against + * the glob. Handle the leading `!` negation prefix by flipping the + * final boolean. + */ +export function matchesRule( + rule: PermissionRule, + toolName: string, + args: unknown, + pathOptions?: PermissionPathMatchOptions, +): boolean { + let parsed; + try { + parsed = parsePattern(rule.pattern); + } catch { + // Malformed patterns never match. The loader is responsible for + // surfacing DSL errors at load time; matcher stays total. + return false; + } + + // 1. Tool-name match (support `*` wildcard + glob-style tool names) + const nameGlob = parsed.toolName; + if (nameGlob !== '*' && !picomatch.isMatch(toolName, nameGlob)) return false; + + // 2. No arg pattern → name match is enough + if (parsed.argPattern === undefined) return true; + + // 3. Arg pattern — resolve negation and glob-match the field + const rawPattern = parsed.argPattern; + const negated = rawPattern.startsWith('!'); + const positivePattern = negated ? rawPattern.slice(1) : rawPattern; + + const fieldValue = extractArgField(toolName, args); + if (fieldValue === undefined) { + // No extractable field → positive pattern cannot match; negation + // semantics here mean "the field is not in the disallowed set", + // which by missing-field convention we treat as a non-match to + // avoid accidentally firing deny rules on malformed args. + return false; + } + + // If the field is a path, use `pathGlobMatch` which handles normalization. + const hit = + fieldValue.kind === 'path' + ? pathGlobMatch(fieldValue.value, positivePattern, { + pathOptions, + conservativeCaseFold: rule.decision === 'deny' && !negated, + }) + : globMatch(fieldValue.value, positivePattern); + return negated ? !hit : hit; +} + +export type { PermissionPathMatchOptions } from './path-glob-match'; diff --git a/packages/agent-core/src/agent/permission/parse-pattern.ts b/packages/agent-core/src/agent/permission/parse-pattern.ts new file mode 100644 index 000000000..9b76ebd28 --- /dev/null +++ b/packages/agent-core/src/agent/permission/parse-pattern.ts @@ -0,0 +1,50 @@ +/** + * DSL parser for PermissionRule `pattern` strings. + * + * Grammar: + * pattern := toolName ( "(" argPattern ")" )? + * toolName := identifier characters (e.g. `Bash`, `mcp__github__*`) + * argPattern := any string (may start with `!` for negation) + * + * Examples: + * "Write" → { toolName: "Write" } + * "Read(/etc/**)" → { toolName: "Read", argPattern: "/etc/**" } + * "Bash(!rm *)" → { toolName: "Bash", argPattern: "!rm *" } + * "mcp__github__*" → { toolName: "mcp__github__*" } + */ + +export interface ParsedPattern { + readonly toolName: string; + readonly argPattern?: string | undefined; +} + +/** + * Parse a DSL pattern. Throws on malformed input (missing closing paren, + * empty tool name). The parser is the single source of truth for DSL + * syntax and is exercised by table-driven tests. + */ +export function parsePattern(pattern: string): ParsedPattern { + const trimmed = pattern.trim(); + if (trimmed.length === 0) { + throw new Error('permission pattern: empty string'); + } + + const openIdx = trimmed.indexOf('('); + if (openIdx === -1) { + return { toolName: trimmed }; + } + + if (!trimmed.endsWith(')')) { + throw new Error(`permission pattern: missing closing paren in "${pattern}"`); + } + + const toolName = trimmed.slice(0, openIdx); + const argPattern = trimmed.slice(openIdx + 1, -1); + if (toolName.length === 0) { + throw new Error(`permission pattern: empty tool name in "${pattern}"`); + } + // Empty arg pattern (`Read()`) is treated as "toolName only" — it + // matches every call to that tool. This aligns with the intuition + // that writing `Read()` is an odd but non-fatal way of saying `Read`. + return { toolName, argPattern: argPattern.length > 0 ? argPattern : undefined }; +} diff --git a/packages/agent-core/src/agent/permission/path-glob-match.ts b/packages/agent-core/src/agent/permission/path-glob-match.ts new file mode 100644 index 000000000..5cecc179e --- /dev/null +++ b/packages/agent-core/src/agent/permission/path-glob-match.ts @@ -0,0 +1,156 @@ +import * as posixPath from 'node:path/posix'; +import * as win32Path from 'node:path/win32'; + +import picomatch from 'picomatch'; + +import { canonicalizePath, type PathClass } from '../../tools/policies/path-access'; + +export interface PermissionPathMatchOptions { + readonly cwd?: string; + readonly pathClass?: PathClass; + readonly homeDir?: string; + readonly caseInsensitivePaths?: boolean; +} + +interface PathMatchSemantics { + readonly pathClass: PathClass; + readonly path: typeof posixPath; +} + +/** + * Match ordinary string fields, like command text or search patterns. + * `*` and `**` work as wildcards, but the value is not treated as a file path. + */ +export function globMatch(value: string, pattern: string, options?: { nocase?: boolean }): boolean { + if (picomatch.isMatch(value, pattern, options)) return true; + + const normalizedValue = stripLeadingDotSlash(value); + const normalizedPattern = stripLeadingDotSlash(pattern); + if (normalizedValue === value && normalizedPattern === pattern) return false; + return picomatch.isMatch(normalizedValue, normalizedPattern, options); +} + +function stripLeadingDotSlash(value: string): string { + return value.startsWith('./') ? value.slice(2) : value; +} + +/** + * Match file path fields, like Read/Write/Edit `path`. + * Also compares normalized forms, so `./a`, `dir/../a`, and Windows + * separator or case variants can match the same rule. + */ +export function pathGlobMatch( + value: string, + pattern: string, + options: { + readonly pathOptions?: PermissionPathMatchOptions; + readonly conservativeCaseFold: boolean; + }, +): boolean { + const semantics = pathMatchSemantics(value, pattern, options.pathOptions); + const nocase = + options.pathOptions?.caseInsensitivePaths ?? + (semantics.pathClass === 'win32' || options.conservativeCaseFold); + + if (globMatch(value, pattern, { nocase })) return true; + + for (const valueVariant of pathVariants(value, semantics, options.pathOptions)) { + for (const patternVariant of pathVariants(pattern, semantics, options.pathOptions)) { + if (globMatch(valueVariant, patternVariant, { nocase })) return true; + } + } + return false; +} + +/** + * Build equivalent spellings for one path string before glob matching: + * the original text, a leading `./` or `.\` form without that prefix, + * the canonical absolute path when possible, and slash-form Windows paths. + * + * Example: with cwd `/repo`, `./src/../secret.txt` adds both + * `src/../secret.txt` and `/repo/secret.txt`. On Windows, + * `C:\repo\secret.txt` also adds `C:/repo/secret.txt`. + */ +function pathVariants( + value: string, + semantics: PathMatchSemantics, + pathOptions: PermissionPathMatchOptions | undefined, +): string[] { + const variants = new Set(); + addPathVariant(variants, value, semantics.pathClass); + addPathVariant(variants, stripLeadingDotPath(value, semantics.pathClass), semantics.pathClass); + + const canonical = canonicalizePathPattern(value, semantics, pathOptions); + if (canonical !== undefined) addPathVariant(variants, canonical, semantics.pathClass); + return Array.from(variants); +} + +function canonicalizePathPattern( + value: string, + semantics: PathMatchSemantics, + pathOptions: PermissionPathMatchOptions | undefined, +): string | undefined { + const expanded = expandUserPath(value, semantics, pathOptions?.homeDir); + const cwd = pathOptions?.cwd ?? defaultCwdForPath(expanded, semantics); + if (cwd === undefined) return undefined; + try { + return canonicalizePath(expanded, cwd, semantics.pathClass); + } catch { + return undefined; + } +} + +function expandUserPath( + value: string, + semantics: PathMatchSemantics, + homeDir: string | undefined, +): string { + if (homeDir === undefined) return value; + if (value === '~') return homeDir; + if (value.startsWith('~/') || (semantics.pathClass === 'win32' && value.startsWith('~\\'))) { + return semantics.path.join(homeDir, value.slice(2)); + } + return value; +} + +function defaultCwdForPath(value: string, semantics: PathMatchSemantics): string | undefined { + if (!semantics.path.isAbsolute(value)) return undefined; + return semantics.path.parse(value).root; +} + +function pathMatchSemantics( + value: string, + pattern: string, + pathOptions: PermissionPathMatchOptions | undefined, +): PathMatchSemantics { + // Production callers pass the active Kaos path class. The fallback keeps + // the pure matcher useful for tests and direct helper calls. + const pathClass = + pathOptions?.pathClass ?? + ([value, pattern].some((candidate) => { + return ( + /^[A-Za-z]:(?:[\\/]|$)/.test(candidate) || + candidate.startsWith('\\\\') || + candidate.includes('\\') + ); + }) + ? 'win32' + : 'posix'); + return { + pathClass, + path: pathClass === 'win32' ? win32Path : posixPath, + }; +} + +function addPathVariant(variants: Set, value: string, pathClass: PathClass): void { + variants.add(value); + // Picomatch treats backslashes as escape syntax in some cases; add a + // slash-separated Win32 variant so nocase and globs behave predictably. + if (pathClass === 'win32') variants.add(value.replaceAll('\\', '/')); +} + +function stripLeadingDotPath(value: string, pathClass: PathClass): string { + if (value.startsWith('./')) return value.slice(2); + if (pathClass === 'win32' && value.startsWith('.\\')) return value.slice(2); + return value; +} diff --git a/packages/agent-core/src/agent/permission/policies/ask-user-question.ts b/packages/agent-core/src/agent/permission/policies/ask-user-question.ts new file mode 100644 index 000000000..a2edcc461 --- /dev/null +++ b/packages/agent-core/src/agent/permission/policies/ask-user-question.ts @@ -0,0 +1,17 @@ +import type { PermissionPolicy } from '../policy'; + +export const AskUserQuestionAutoPermissionPolicy: PermissionPolicy = { + name: 'auto.ask-user-question', + evaluate({ mode, toolCallContext }) { + if (mode !== 'auto') return undefined; + if (toolCallContext.toolCall.function.name !== 'AskUserQuestion') return undefined; + return { + kind: 'result', + result: { + block: true, + reason: + 'AskUserQuestion is disabled while auto permission mode is active. Make a reasonable decision and continue without asking the user.', + }, + }; + }, +}; diff --git a/packages/agent-core/src/agent/permission/policies/default-git-cwd-write.ts b/packages/agent-core/src/agent/permission/policies/default-git-cwd-write.ts new file mode 100644 index 000000000..656f2089b --- /dev/null +++ b/packages/agent-core/src/agent/permission/policies/default-git-cwd-write.ts @@ -0,0 +1,127 @@ +import * as posixPath from 'node:path/posix'; + +import type { Kaos } from '@moonshot-ai/kaos'; + +import { + DEFAULT_WORKSPACE_ACCESS_POLICY, + isWithinDirectory, + resolvePathAccess, +} from '../../../tools/policies/path-access'; +import { isSensitiveFile } from '../../../tools/policies/sensitive'; +import { + findGitWorkTreeMarker, + type GitWorkTreeMarker, +} from '../../../tools/support/git-worktree'; +import type { PermissionPolicy } from '../policy'; + +const AUTO_REASON = 'default_git_cwd_write'; +const S_IFMT = 0o170000; +const S_IFLNK = 0o120000; + +export function createDefaultGitCwdWritePolicy(): PermissionPolicy { + // Cache positive marker lookups only. A session that starts in a non-git + // directory and later `git init`s should pick up the new work tree on the + // next call; negative results pay one extra stat per call, which is + // acceptable. + const cache = new Map(); + + return { + name: 'default.git-cwd-write', + async evaluate({ agent, mode, matchedRule, toolCallContext }) { + if (mode !== 'manual') return undefined; + if (matchedRule !== undefined) return undefined; + + const toolName = toolCallContext.toolCall.function.name; + if (toolName !== 'Write' && toolName !== 'Edit') return undefined; + + const kaos = agent.runtime.kaos; + const pathClass = kaos.pathClass(); + if (pathClass !== 'posix') return undefined; + + const cwd = agent.config.cwd; + if (cwd.length === 0) return undefined; + + const path = readStringField(toolCallContext.args, 'path'); + if (path === undefined) return undefined; + + let access; + try { + access = resolvePathAccess( + path, + cwd, + { workspaceDir: cwd, additionalDirs: [] }, + { + operation: 'write', + pathClass, + homeDir: kaos.gethome(), + policy: DEFAULT_WORKSPACE_ACCESS_POLICY, + }, + ); + } catch { + return undefined; + } + if (access.outsideWorkspace) return undefined; + + const marker = cache.get(cwd) ?? (await findGitWorkTreeMarker(kaos, cwd)); + if (marker === null) return undefined; + cache.set(cwd, marker); + + if (isGitControlPath(access.path, cwd, marker)) return undefined; + if (isSensitiveFile(access.path.toLowerCase(), 'posix')) return undefined; + if (await hasSymlinkInPath(kaos, cwd, access.path)) return undefined; + + agent.telemetry.track('tool_approved', { + tool_name: toolName, + approval_mode: 'manual', + auto_reason: AUTO_REASON, + }); + return { kind: 'allow' }; + }, + }; +} + +function readStringField(args: unknown, key: string): string | undefined { + if (args === null || typeof args !== 'object') return undefined; + const value = (args as Record)[key]; + return typeof value === 'string' ? value : undefined; +} + +function isGitControlPath(targetPath: string, cwd: string, marker: GitWorkTreeMarker): boolean { + const foldedTarget = targetPath.toLowerCase(); + return ( + posixPath.relative(cwd.toLowerCase(), foldedTarget).split(posixPath.sep).includes('.git') || + isWithinDirectory(foldedTarget, marker.dotGitPath.toLowerCase(), 'posix') || + isWithinDirectory(foldedTarget, marker.controlDirPath.toLowerCase(), 'posix') + ); +} + +async function hasSymlinkInPath(kaos: Kaos, cwd: string, targetPath: string): Promise { + const relative = posixPath.relative(cwd, targetPath); + const parts = [cwd]; + + let current = cwd; + for (const part of relative.split(posixPath.sep)) { + if (part.length === 0 || part === '.') continue; + current = posixPath.join(current, part); + parts.push(current); + } + + for (let index = 0; index < parts.length; index += 1) { + try { + const stat = await kaos.stat(parts[index]!, { followSymlinks: false }); + if ((stat.stMode & S_IFMT) === S_IFLNK) return true; + } catch (error) { + return !(index === parts.length - 1 && isFileNotFoundError(error)); + } + } + return false; +} + +function isFileNotFoundError(error: unknown): boolean { + if (error === null || typeof error !== 'object') return false; + if ((error as { name?: unknown }).name === 'KaosFileNotFoundError') return true; + const code = (error as { code?: unknown }).code; + if (code === 'ENOENT' || code === 'ENOTDIR' || code === 2) return true; + const message = error instanceof Error ? error.message : ''; + return message.includes('ENOENT') || message.includes('ENOTDIR'); +} diff --git a/packages/agent-core/src/agent/permission/policies/index.ts b/packages/agent-core/src/agent/permission/policies/index.ts new file mode 100644 index 000000000..07459feb2 --- /dev/null +++ b/packages/agent-core/src/agent/permission/policies/index.ts @@ -0,0 +1,19 @@ +import type { PermissionPolicy } from '../policy'; +import { AskUserQuestionAutoPermissionPolicy } from './ask-user-question'; +import { createDefaultGitCwdWritePolicy } from './default-git-cwd-write'; +import { createPlanPermissionPolicies } from './plan'; +import { YoloOutsideWorkspacePermissionPolicy } from './yolo-workspace-access'; + +export function createBuiltinPermissionPolicies(): readonly PermissionPolicy[] { + return [ + ...createPlanPermissionPolicies(), + YoloOutsideWorkspacePermissionPolicy, + createDefaultGitCwdWritePolicy(), + AskUserQuestionAutoPermissionPolicy, + ]; +} + +export { AskUserQuestionAutoPermissionPolicy } from './ask-user-question'; +export { createDefaultGitCwdWritePolicy } from './default-git-cwd-write'; +export { createPlanPermissionPolicies } from './plan'; +export { YoloOutsideWorkspacePermissionPolicy } from './yolo-workspace-access'; diff --git a/packages/agent-core/src/agent/permission/policies/plan.ts b/packages/agent-core/src/agent/permission/policies/plan.ts new file mode 100644 index 000000000..774799227 --- /dev/null +++ b/packages/agent-core/src/agent/permission/policies/plan.ts @@ -0,0 +1,324 @@ +import type { ExecutableToolResult } from '../../../loop'; +import type { PermissionPolicy, PermissionPolicyContext, PermissionPolicyResult } from '../policy'; +import type { ApprovalResponse } from '../types'; + +interface ExitPlanModeOption { + readonly label: string; + readonly description: string; +} + +interface ExitPlanModeExecutionMetadata { + readonly selectedOption?: ExitPlanModeOption | undefined; + readonly planTelemetrySubmitted: true; + readonly planTelemetryResolved: true; +} + +export const EnterPlanModePermissionPolicy: PermissionPolicy = { + name: 'plan.enter-plan-mode', + evaluate({ toolCallContext }) { + if (toolCallContext.toolCall.function.name !== 'EnterPlanMode') return undefined; + return { kind: 'allow' }; + }, +}; + +export const ExitPlanModePermissionPolicy: PermissionPolicy = { + name: 'plan.exit-plan-mode', + async evaluate(context) { + if (context.toolCallContext.toolCall.function.name !== 'ExitPlanMode') return undefined; + if (context.mode === 'auto') return { kind: 'allow' }; + + const review = await resolveExitPlanModeReview(context); + if (review === null) return { kind: 'allow' }; + + const action = exitPlanModeAction(review.options); + context.agent.telemetry.track('plan_submitted', { + has_options: review.options !== undefined, + }); + let result: ApprovalResponse; + try { + result = await context.agent.rpc.requestApproval( + { + turnId: Number(context.toolCallContext.turnId), + toolCallId: context.toolCallContext.toolCall.id, + toolName: 'ExitPlanMode', + action, + display: { + kind: 'plan_review', + plan: review.plan, + path: review.path, + options: review.options, + }, + }, + { signal: context.toolCallContext.signal }, + ); + } catch (error) { + const message = error instanceof Error ? error.message : 'Plan approval failed.'; + return { + kind: 'result', + result: { + syntheticResult: { + isError: true, + output: `Plan approval failed: ${message}`, + }, + }, + }; + } + + context.recordApprovalResult({ + turnId: Number(context.toolCallContext.turnId), + toolCallId: context.toolCallContext.toolCall.id, + toolName: 'ExitPlanMode', + action, + result, + }); + + trackExitPlanModeResolution(context, result); + return exitPlanModeApprovalResult(context, result, review.options); + }, +}; + +export const PlanModeGuardPermissionPolicy: PermissionPolicy = { + name: 'plan.mode-guard', + evaluate({ agent, toolCallContext }) { + if (!agent.planMode.isActive) return undefined; + + const name = toolCallContext.toolCall.function.name; + const args = toolCallContext.args; + + if (name === 'Write' || name === 'Edit') { + const path = readStringField(args, 'path'); + if (path === undefined) return undefined; + const planFilePath = agent.planMode.planFilePath; + if (planFilePath !== null && path === planFilePath) return { kind: 'allow' }; + return { + kind: 'result', + result: { + block: true, + reason: + `Plan mode is active. You may only write to the current plan file: ${planFilePath ?? '(no plan file selected yet)'}. ` + + 'Call ExitPlanMode to exit plan mode before editing other files.', + }, + }; + } + + if (name === 'TaskStop') { + return { + kind: 'result', + result: { + block: true, + reason: + 'TaskStop is not available in plan mode. ' + + 'Call ExitPlanMode to exit plan mode before stopping a background task.', + }, + }; + } + + return undefined; + }, +}; + +export function createPlanPermissionPolicies(): readonly PermissionPolicy[] { + return [ + EnterPlanModePermissionPolicy, + ExitPlanModePermissionPolicy, + PlanModeGuardPermissionPolicy, + ]; +} + +async function resolveExitPlanModeReview(context: PermissionPolicyContext): Promise<{ + readonly plan: string; + readonly path?: string | undefined; + readonly options?: readonly ExitPlanModeOption[] | undefined; +} | null> { + if (!context.agent.planMode.isActive) return null; + + let data: Awaited>; + try { + data = await context.agent.planMode.data(); + } catch { + return null; + } + if (data === null || data.content.trim().length === 0) return null; + + return { + plan: data.content, + path: data.path, + options: exitPlanModeOptions(context.toolCallContext.args), + }; +} + +function exitPlanModeApprovalResult( + context: PermissionPolicyContext, + result: ApprovalResponse, + options: readonly ExitPlanModeOption[] | undefined, +): PermissionPolicyResult { + if (result.decision === 'approved') { + const selected = selectedExitPlanModeOption(options, result.selectedLabel); + return { + kind: 'allow', + executionMetadata: exitPlanModeExecutionMetadata(selected), + }; + } + + if (result.decision === 'cancelled') { + return { + kind: 'result', + result: { + syntheticResult: { + isError: false, + output: 'Plan approval dismissed. Plan mode remains active.', + }, + }, + }; + } + + if (result.selectedLabel === 'Reject and Exit') { + const failed = exitPlanModeForRejectedPlan(context); + return { + kind: 'result', + result: { + syntheticResult: + failed ?? { + isError: true, + stopTurn: true, + output: 'Plan rejected by user. Plan mode deactivated.', + }, + }, + }; + } + + const feedback = result.feedback ?? ''; + if (result.selectedLabel === 'Revise' || feedback.length > 0) { + return { + kind: 'result', + result: { + syntheticResult: { + isError: false, + output: + feedback.length > 0 + ? `User rejected the plan. Feedback:\n\n${feedback}` + : 'User requested revisions. Plan mode remains active.', + }, + }, + }; + } + + return { + kind: 'result', + result: { + syntheticResult: { + isError: true, + stopTurn: true, + output: 'Plan rejected by user. Plan mode remains active.', + }, + }, + }; +} + +function exitPlanModeExecutionMetadata( + selectedOption: ExitPlanModeOption | undefined, +): ExitPlanModeExecutionMetadata { + return { + selectedOption, + planTelemetrySubmitted: true, + planTelemetryResolved: true, + }; +} + +function trackExitPlanModeResolution( + context: PermissionPolicyContext, + result: ApprovalResponse, +): void { + const selectedLabel = result.selectedLabel ?? ''; + const normalizedSelectedLabel = normalizeOptionLabel(selectedLabel); + const feedback = result.feedback ?? ''; + const hasFeedback = feedback.length > 0; + + if (result.decision === 'cancelled') { + context.agent.telemetry.track('plan_resolved', { outcome: 'dismissed' }); + return; + } + + if (result.decision === 'approved') { + if (selectedLabel.length > 0) { + context.agent.telemetry.track('plan_resolved', { + outcome: 'approved', + chosen_option: selectedLabel, + }); + return; + } + context.agent.telemetry.track('plan_resolved', { outcome: 'approved' }); + return; + } + + if (normalizedSelectedLabel === 'reject and exit') { + context.agent.telemetry.track('plan_resolved', { outcome: 'rejected_and_exited' }); + return; + } + + if (normalizedSelectedLabel === 'revise' || hasFeedback) { + context.agent.telemetry.track('plan_resolved', { + outcome: 'revise', + has_feedback: hasFeedback, + }); + return; + } + + context.agent.telemetry.track('plan_resolved', { outcome: 'rejected' }); +} + +function exitPlanModeForRejectedPlan( + context: PermissionPolicyContext, +): ExecutableToolResult | undefined { + try { + context.agent.planMode.exit(); + } catch (error) { + const message = error instanceof Error ? error.message : 'Failed to exit plan mode.'; + return { + isError: true, + output: `Failed to exit plan mode: ${message}`, + }; + } +} + +function exitPlanModeOptions(args: unknown): readonly ExitPlanModeOption[] | undefined { + if (args === null || typeof args !== 'object') return undefined; + const options = (args as { readonly options?: unknown }).options; + if (!Array.isArray(options) || options.length < 2) return undefined; + const parsed: ExitPlanModeOption[] = []; + for (const option of options) { + if (option === null || typeof option !== 'object') return undefined; + const label = (option as { readonly label?: unknown }).label; + if (typeof label !== 'string') return undefined; + // `description` is optional in the ExitPlanMode schema (defaults to ''), + // so an option that omits it is still valid. + const description = (option as { readonly description?: unknown }).description; + if (description !== undefined && typeof description !== 'string') return undefined; + parsed.push({ label, description: description ?? '' }); + } + return parsed; +} + +function selectedExitPlanModeOption( + options: readonly ExitPlanModeOption[] | undefined, + label: string | undefined, +): ExitPlanModeOption | undefined { + if (options === undefined || label === undefined) return undefined; + return options.find((option) => option.label === label); +} + +function exitPlanModeAction(options: readonly ExitPlanModeOption[] | undefined): string { + return options !== undefined && options.length >= 2 + ? 'Review plan and choose an option' + : 'Review plan'; +} + +function normalizeOptionLabel(label: string): string { + return label.trim().toLowerCase(); +} + +function readStringField(args: unknown, key: string): string | undefined { + if (args === null || typeof args !== 'object') return undefined; + const value = (args as Record)[key]; + return typeof value === 'string' ? value : undefined; +} diff --git a/packages/agent-core/src/agent/permission/policies/yolo-workspace-access.ts b/packages/agent-core/src/agent/permission/policies/yolo-workspace-access.ts new file mode 100644 index 000000000..b95b9542c --- /dev/null +++ b/packages/agent-core/src/agent/permission/policies/yolo-workspace-access.ts @@ -0,0 +1,74 @@ +import type { ToolInputDisplay } from '../../../tools/display'; +import { + DEFAULT_WORKSPACE_ACCESS_POLICY, + resolvePathAccess, + type PathAccessOperation, +} from '../../../tools/policies/path-access'; +import type { PermissionPolicy } from '../policy'; + +type FileInputDisplayOperation = Extract['operation']; + +const FILE_ACCESS_TOOLS: Readonly< + Record +> = { + Read: ['read', 'read'], + ReadMediaFile: ['read', 'read'], + Write: ['write', 'write'], + Edit: ['write', 'edit'], + Grep: ['search', 'grep'], +}; + +export const YoloOutsideWorkspacePermissionPolicy: PermissionPolicy = { + name: 'yolo.outside-workspace', + evaluate({ agent, mode, toolCallContext }) { + if (mode !== 'yolo') return undefined; + + const toolName = toolCallContext.toolCall.function.name; + const toolAccess = FILE_ACCESS_TOOLS[toolName]; + if (toolAccess === undefined) return undefined; + const [operation, displayOperation] = toolAccess; + + const rawPath = readStringField(toolCallContext.args, 'path'); + if (rawPath === undefined) return undefined; + + let access; + try { + access = resolvePathAccess( + rawPath, + agent.config.cwd, + { + workspaceDir: agent.config.cwd, + additionalDirs: agent.skills?.registry.getSkillRoots() ?? [], + }, + { + operation, + pathClass: agent.runtime.kaos.pathClass(), + homeDir: agent.runtime.kaos.gethome(), + policy: { + ...DEFAULT_WORKSPACE_ACCESS_POLICY, + checkSensitive: toolName !== 'Grep', + }, + }, + ); + } catch { + return undefined; + } + + if (!access.outsideWorkspace) return undefined; + return { + kind: 'ask', + display: { + kind: 'file_io', + operation: displayOperation, + path: access.path, + detail: `Outside workspace: ${agent.config.cwd}`, + }, + }; + }, +}; + +function readStringField(args: unknown, key: string): string | undefined { + if (args === null || typeof args !== 'object') return undefined; + const value = (args as Record)[key]; + return typeof value === 'string' ? value : undefined; +} diff --git a/packages/agent-core/src/agent/permission/policy.ts b/packages/agent-core/src/agent/permission/policy.ts new file mode 100644 index 000000000..5baf7776e --- /dev/null +++ b/packages/agent-core/src/agent/permission/policy.ts @@ -0,0 +1,42 @@ +import type { Agent } from '..'; +import type { PrepareToolExecutionResult, ToolExecutionHookContext } from '../../loop'; +import type { ToolInputDisplay } from '../../tools/display'; +import type { PermissionApprovalResultRecord, PermissionMode, PermissionRule } from './types'; + +export interface PermissionPolicyContext { + readonly agent: Agent; + readonly mode: PermissionMode; + readonly toolCallContext: ToolExecutionHookContext; + /** + * The rule matched by `checkPermission()`, if any. + * + * Policies that want to defer to user-defined rules (e.g. a default-allow + * policy that should not override an explicit `ask`/`deny` rule) inspect + * this to decide whether to fire. `undefined` means the decision came + * from the built-in default permission table rather than a user rule. + */ + readonly matchedRule: PermissionRule | undefined; + readonly recordApprovalResult: (record: PermissionApprovalResultRecord) => void; +} + +export type PermissionPolicyResult = + | { + readonly kind: 'allow'; + readonly executionMetadata?: unknown; + } + | { + readonly kind: 'result'; + readonly result: PrepareToolExecutionResult; + } + | { + readonly kind: 'ask'; + readonly action?: string | undefined; + readonly display?: ToolInputDisplay | undefined; + }; + +export interface PermissionPolicy { + readonly name: string; + evaluate( + context: PermissionPolicyContext, + ): PermissionPolicyResult | undefined | Promise; +} diff --git a/packages/agent-core/src/agent/permission/types.ts b/packages/agent-core/src/agent/permission/types.ts new file mode 100644 index 000000000..df51e55fc --- /dev/null +++ b/packages/agent-core/src/agent/permission/types.ts @@ -0,0 +1,60 @@ +import type { ToolInputDisplay } from '../../tools/display'; + +export type PermissionRuleDecision = 'allow' | 'deny' | 'ask'; + +/** + * Rule provenance. `session-runtime` is the value used by the runtime + * "approve for session" path; `turn-override`, `project`, and `user` + * are reserved for static-loaded rules surfaced by external callers. + */ +export type PermissionRuleScope = 'turn-override' | 'session-runtime' | 'project' | 'user'; + +/** + * Top-level user-facing permission posture. Controls how non-deny rules + * are treated when the closure is constructed. Independent of rule + * merging: deny rules always fire regardless of mode. + * + * - `manual` — rule set drives decision; unmatched tool calls ask + * - `yolo` — only deny rules can block; everything else allows + * - `auto` — caller may bypass rule checks entirely + */ +export type PermissionMode = 'manual' | 'yolo' | 'auto'; + +/** + * A single permission rule. `pattern` is the DSL form (`Read(/etc/**)`, + * `Bash(rm *)`, or bare `Write`). See `parse-pattern.ts` for the parser + * and `matches-rule.ts` for the matcher. + */ +export interface PermissionRule { + readonly decision: PermissionRuleDecision; + readonly scope: PermissionRuleScope; + readonly pattern: string; + readonly reason?: string | undefined; +} + +export interface ApprovalRequest { + toolCallId: string; + toolName: string; + action: string; + display: ToolInputDisplay; +} + +export interface ApprovalResponse { + decision: 'approved' | 'rejected' | 'cancelled'; + scope?: 'session'; + feedback?: string; + selectedLabel?: string; +} + +export interface PermissionApprovalResultRecord { + readonly turnId: number; + readonly toolCallId: string; + readonly toolName: string; + readonly action: string; + readonly result: ApprovalResponse; +} + +export interface PermissionData { + mode: PermissionMode; + rules: PermissionRule[]; +} diff --git a/packages/agent-core/src/agent/plan/index.ts b/packages/agent-core/src/agent/plan/index.ts new file mode 100644 index 000000000..e2508e8af --- /dev/null +++ b/packages/agent-core/src/agent/plan/index.ts @@ -0,0 +1,146 @@ +import { randomUUID } from 'node:crypto'; +import { dirname, join } from 'node:path'; + +import type { Agent } from '..'; +import { generateHeroSlug } from '../../utils/hero-slug'; + +export type PlanData = null | { + id: string; + content: string; + path: string; +}; +export type PlanFilePath = string | null; + +export class PlanMode { + protected _isActive = false; + protected _planId: null | string = null; + protected _planFilePath: PlanFilePath = null; + + constructor(protected readonly agent: Agent) {} + + createPlanId(): string { + return generateHeroSlug(randomUUID(), new Set()); + } + + async enter(id = this.createPlanId(), createFile = false, emitStatus = true): Promise { + if (this._isActive) { + throw new Error('Already in plan mode'); + } + + this._isActive = true; + this._planId = id; + this._planFilePath = null; + + let enterRecorded = false; + try { + const planFilePath = this.planFilePathFor(id); + this._planFilePath = planFilePath; + await this.ensurePlanDirectory(planFilePath); + this.agent.records.logRecord({ type: 'plan_mode.enter', id }); + enterRecorded = true; + if (createFile) { + await this.writeEmptyPlanFile(planFilePath); + } + } catch (error) { + if (enterRecorded) { + this.cancel(id); + } else { + this._isActive = false; + this._planId = null; + this._planFilePath = null; + } + throw error; + } + + if (emitStatus) this.agent.emitStatusUpdated(); + } + + restoreEnter({ id }: { readonly id: string }): void { + this.agent.replayBuilder.push({ + type: 'plan_updated', + enabled: true, + }); + + this._isActive = true; + this._planId = id; + this._planFilePath = this.planFilePathFor(id); + } + + cancel(id?: string): void { + this.agent.records.logRecord({ type: 'plan_mode.cancel', id }); + this.agent.replayBuilder.push({ + type: 'plan_updated', + enabled: false, + }); + this._isActive = false; + this._planId = null; + this._planFilePath = null; + this.agent.emitStatusUpdated(); + } + + async clear(): Promise { + if (!this._planFilePath) return; + await this.writeEmptyPlanFile(this._planFilePath); + } + + exit(id?: string): void { + this.agent.records.logRecord({ type: 'plan_mode.exit', id }); + this.agent.replayBuilder.push({ + type: 'plan_updated', + enabled: false, + }); + this._isActive = false; + this._planId = null; + this._planFilePath = null; + this.agent.emitStatusUpdated(); + } + + get isActive() { + return this._isActive; + } + + get planFilePath(): PlanFilePath { + return this._planFilePath; + } + + async data(): Promise { + if (!this._planId || !this._planFilePath) return null; + let content = ''; + try { + content = await this.agent.runtime.kaos.readText(this._planFilePath); + } catch (error) { + if (!isMissingFileError(error)) throw error; + } + return { + id: this._planId, + content, + path: this._planFilePath, + }; + } + + private async writeEmptyPlanFile(path: string): Promise { + await this.ensurePlanDirectory(path); + await this.agent.runtime.kaos.writeText(path, ''); + } + + private async ensurePlanDirectory(path: string): Promise { + await this.agent.runtime.kaos.mkdir(dirname(path), { + parents: true, + existOk: true, + }); + } + + private planFilePathFor(id: string): string { + const plansDir = + this.agent.homedir === undefined + ? join(this.agent.config.cwd || this.agent.runtime.kaos.getcwd(), 'plan') + : join(this.agent.homedir, 'plans'); + return join(plansDir, `${id}.md`); + } +} + +function isMissingFileError(error: unknown): boolean { + if (error === null || typeof error !== 'object') return false; + const code = (error as { readonly code?: unknown }).code; + return code === 'ENOENT'; +} diff --git a/packages/agent-core/src/agent/records/index.ts b/packages/agent-core/src/agent/records/index.ts new file mode 100644 index 000000000..29de0cfa3 --- /dev/null +++ b/packages/agent-core/src/agent/records/index.ts @@ -0,0 +1,139 @@ +import type { Agent } from '..'; +import type { AgentRecord, AgentRecordPersistence } from './types'; + +export * from './types'; +export { FileSystemAgentRecordPersistence } from './wire-file'; +export type { FileSystemAgentRecordPersistenceOptions } from './wire-file'; + +// Contract: restore MUST NOT emit UI events, call the LLM, execute tools, or +// touch the filesystem in a way that triggers external side effects. Each case +// should reproduce the in-memory state the live handler left behind, nothing more. +export function restoreAgentRecord(agent: Agent, input: AgentRecord): void { + switch (input.type) { + case 'turn.prompt': + agent.turn.restorePrompt(); + return; + case 'turn.steer': + agent.turn.restoreSteer(input.input, input.origin); + return; + case 'turn.cancel': + agent.turn.cancel(input.turnId); + return; + case 'background.stop': + return; + case 'config.update': + agent.config.update(input); + return; + case 'permission.set_mode': + agent.permission.setMode(input.mode); + return; + case 'permission.record_approval_result': + agent.permission.recordApprovalResult(input); + return; + case 'usage.record': + agent.usage.record(input.model, input.usage, 'session'); + return; + case 'full_compaction.begin': + agent.fullCompaction.begin(input); + return; + case 'full_compaction.cancel': + agent.fullCompaction.cancel(); + return; + case 'full_compaction.complete': + agent.fullCompaction.complete(input); + return; + case 'plan_mode.enter': + agent.planMode.restoreEnter(input); + return; + case 'plan_mode.cancel': + agent.planMode.cancel(input.id); + return; + case 'plan_mode.exit': + agent.planMode.exit(input.id); + return; + case 'context.append_message': + agent.context.appendMessage(input.message); + return; + case 'context.mark_last_user_prompt_blocked': + agent.context.markLastUserPromptBlocked(input.hookEvent); + return; + case 'context.append_loop_event': + agent.context.appendLoopEvent(input.event); + return; + case 'context.clear': + agent.context.clear(); + return; + case 'context.apply_compaction': + agent.context.applyCompaction(input); + return; + case 'tools.register_user_tool': + agent.tools.registerUserTool(input); + return; + case 'tools.unregister_user_tool': + agent.tools.unregisterUserTool(input.name); + return; + case 'tools.set_active_tools': + agent.tools.setActiveTools(input.names); + return; + case 'tools.update_store': + agent.tools.updateStore(input.key, input.value); + return; + } +} + +export class AgentRecords { + private readonly records: AgentRecord[] = []; + private _restoring = false; + onRecord?: (record: AgentRecord) => void; + onError?: (error: unknown, record: AgentRecord) => void; + + constructor( + private readonly restoreRecord: (record: AgentRecord) => void, + private readonly persistence?: AgentRecordPersistence, + ) {} + + get restoring() { + return this._restoring; + } + + logRecord(record: AgentRecord): void { + if (this._restoring) return; + const stamped: AgentRecord = + record.time !== undefined ? record : { ...record, time: Date.now() }; + this.records.push(stamped); + this.onRecord?.(stamped); + void this.persistence?.append(stamped).catch((error) => { + this.onError?.(error, stamped); + }); + } + + restore(record: AgentRecord): void { + this._restoring = true; + try { + this.restoreRecord(record); + } finally { + this._restoring = false; + } + } + + async replay(): Promise { + if (!this.persistence) throw new Error('No persistence provided for AgentRecords'); + for await (const record of this.persistence.read()) { + this.records.push(record); + this._restoring = true; + try { + this.restoreRecord(record); + } finally { + this._restoring = false; + } + } + } + + snapshot(): readonly AgentRecord[] { + return [...this.records]; + } + + async flush(): Promise { + await this.persistence?.flush(); + } +} diff --git a/packages/agent-core/src/agent/records/types.ts b/packages/agent-core/src/agent/records/types.ts new file mode 100644 index 000000000..83442af43 --- /dev/null +++ b/packages/agent-core/src/agent/records/types.ts @@ -0,0 +1,91 @@ +import type { ContentPart, TokenUsage } from '@moonshot-ai/kosong'; + +import type { LoopRecordedEvent } from '../../loop'; +import type { ToolStoreUpdate } from '../../tools/store'; +import type { CompactionBeginData, CompactionResult } from '../compaction'; +import type { AgentConfigUpdateData } from '../config'; +import type { ContextMessage, PromptOrigin } from '../context'; +import type { PermissionApprovalResultRecord, PermissionMode } from '../permission'; +import type { UserToolRegistration } from '../tool'; +import type { UsageRecordScope } from '../usage'; + +export interface AgentRecordEvents { + 'turn.prompt': { + input: readonly ContentPart[]; + origin: PromptOrigin; + }; + 'turn.steer': { + input: readonly ContentPart[]; + origin: PromptOrigin; + }; + 'turn.cancel': { turnId?: number }; + + 'config.update': AgentConfigUpdateData; + + 'permission.set_mode': { + mode: PermissionMode; + }; + 'permission.record_approval_result': PermissionApprovalResultRecord; + + 'full_compaction.begin': CompactionBeginData; + + 'plan_mode.enter': { + id: string; + }; + 'plan_mode.cancel': { + id?: string; + }; + 'plan_mode.exit': { + id?: string; + }; + + 'tools.register_user_tool': UserToolRegistration; + 'tools.unregister_user_tool': { + name: string; + }; + 'tools.set_active_tools': { + names: readonly string[]; + }; + + 'background.stop': { + taskId: string; + }; + + 'usage.record': { + model: string; + usage: TokenUsage; + usageScope?: UsageRecordScope | undefined; + }; + + 'full_compaction.cancel': {}; + 'full_compaction.complete': CompactionResult; + + 'context.append_message': { message: ContextMessage }; + 'context.mark_last_user_prompt_blocked': { hookEvent: string }; + 'context.append_loop_event': { event: LoopRecordedEvent }; + 'context.clear': {}; + 'context.apply_compaction': CompactionResult; + + 'tools.update_store': ToolStoreUpdate; +} + +export type AgentRecord = { + [K in keyof AgentRecordEvents]: Readonly & { + readonly type: K; + readonly time?: number; + }; +}[keyof AgentRecordEvents]; + +export type AgentRecordOf = Extract< + AgentRecord, + { readonly type: K } +>; + +export const AGENT_WIRE_PROTOCOL_VERSION = '1.0'; + +export interface AgentRecordPersistence { + read(): AsyncIterable; + append(input: AgentRecord): Promise; + flush(): Promise; + close(): Promise; +} diff --git a/packages/agent-core/src/agent/records/wire-file.ts b/packages/agent-core/src/agent/records/wire-file.ts new file mode 100644 index 000000000..d16fe6216 --- /dev/null +++ b/packages/agent-core/src/agent/records/wire-file.ts @@ -0,0 +1,193 @@ +import { mkdir, open, readFile, stat } from 'node:fs/promises'; +import { dirname } from 'node:path'; + +import { syncDir } from '../../utils/fs'; +import { + AGENT_WIRE_PROTOCOL_VERSION, + type AgentRecord, + type AgentRecordPersistence, +} from './types'; + +interface AgentWireMetadata { + readonly type: 'metadata'; + readonly protocol_version: string; + readonly created_at: number; +} + +type WireFileRecord = AgentRecord | AgentWireMetadata; + +export interface FileSystemAgentRecordPersistenceOptions { + readonly onError?: ((error: unknown) => void) | undefined; +} + +class AsyncSerialQueue { + private tail: Promise = Promise.resolve(); + + run(task: () => Promise): Promise { + const next = this.tail.then(task, task); + this.tail = next.catch(() => { + /* swallow so a rejected task does not poison the chain */ + }); + return next; + } +} + +// Single-writer per file: the "is file empty?" check before emitting the +// header is racy across processes, but multi-writer wire.jsonl is unsupported. +export class FileSystemAgentRecordPersistence implements AgentRecordPersistence { + private readonly queue = new AsyncSerialQueue(); + private readonly pending: WireFileRecord[] = []; + private closed = false; + private closing = false; + private directorySynced = false; + private drainScheduled = false; + private lastBackgroundError: Error | undefined; + private headerPromise: Promise | undefined; + + constructor( + private readonly filePath: string, + private readonly options: FileSystemAgentRecordPersistenceOptions = {}, + ) {} + + async *read(): AsyncIterable { + await this.flush(); + + let text: string; + try { + text = await readFile(this.filePath, 'utf8'); + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code === 'ENOENT') return; + throw error; + } + + const lines = text.split('\n'); + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + if (line === undefined || line.length === 0) continue; + + let parsed: WireFileRecord; + try { + parsed = JSON.parse(line) as WireFileRecord; + } catch (parseError) { + // Tolerate a truncated trailing line — last write may have crashed + // mid-flush; everything before is still well-formed. + if (i === lines.length - 1) continue; + throw new Error( + `wire.jsonl: corrupted line ${i + 1} in ${this.filePath}: ${String(parseError)}`, + { cause: parseError }, + ); + } + if (parsed.type === 'metadata') continue; + yield parsed; + } + } + + async append(input: AgentRecord): Promise { + if (this.closed || this.closing) { + throw new Error('FileSystemAgentRecordPersistence: append on closed persistence'); + } + await this.ensureHeader(); + if (this.closed || this.closing) { + throw new Error('FileSystemAgentRecordPersistence: append on closed persistence'); + } + this.pending.push(input); + this.scheduleDrain(); + } + + async flush(): Promise { + await this.headerPromise; + try { + await this.queue.run(async () => { + while (this.pending.length > 0 && !this.closed) { + await this.drainBatch(); + } + }); + } catch (error) { + this.options.onError?.(error); + throw error; + } + + if (this.lastBackgroundError !== undefined) { + const error = this.lastBackgroundError; + this.lastBackgroundError = undefined; + throw error; + } + } + + async close(): Promise { + if (this.closed) return; + this.closing = true; + try { + await this.flush(); + this.closed = true; + } catch (error) { + this.closing = false; + throw error; + } + } + + private ensureHeader(): Promise { + this.headerPromise ??= this.writeHeaderIfNeeded(); + return this.headerPromise; + } + + private async writeHeaderIfNeeded(): Promise { + let isEmpty = true; + try { + const stats = await stat(this.filePath); + isEmpty = stats.size === 0; + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code !== 'ENOENT') throw error; + } + if (!isEmpty) return; + + this.pending.unshift({ + type: 'metadata', + protocol_version: AGENT_WIRE_PROTOCOL_VERSION, + created_at: Date.now(), + }); + } + + private scheduleDrain(): void { + if (this.drainScheduled || this.closed) return; + this.drainScheduled = true; + queueMicrotask(() => { + this.drainScheduled = false; + if (this.closed || this.pending.length === 0) return; + this.queue + .run(async () => { + while (this.pending.length > 0 && !this.closed) { + await this.drainBatch(); + } + }) + .catch((error) => { + this.lastBackgroundError = error as Error; + this.options.onError?.(error); + }); + }); + } + + private async drainBatch(): Promise { + if (this.pending.length === 0) return; + + const batch = this.pending.splice(0); + const lines = batch.map((e) => JSON.stringify(e) + '\n'); + + await mkdir(dirname(this.filePath), { recursive: true }); + + const fh = await open(this.filePath, 'a'); + try { + await fh.appendFile(lines.join(''), 'utf8'); + await fh.sync(); + } finally { + await fh.close(); + } + + if (!this.directorySynced) { + await syncDir(dirname(this.filePath)); + this.directorySynced = true; + } + } +} diff --git a/packages/agent-core/src/agent/replay/index.ts b/packages/agent-core/src/agent/replay/index.ts new file mode 100644 index 000000000..0196e6225 --- /dev/null +++ b/packages/agent-core/src/agent/replay/index.ts @@ -0,0 +1,18 @@ +import type { Agent } from '..'; +import type { AgentReplayRecord } from '../..'; + +export class ReplayBuilder { + protected readonly records: AgentReplayRecord[] = []; + + constructor(public readonly agent: Agent) {} + + push(record: AgentReplayRecord): void { + if (this.agent.records.restoring) { + this.records.push(record); + } + } + + buildResult(): readonly AgentReplayRecord[] { + return this.records; + } +} diff --git a/packages/agent-core/src/agent/skill/index.ts b/packages/agent-core/src/agent/skill/index.ts new file mode 100644 index 000000000..7d698b7eb --- /dev/null +++ b/packages/agent-core/src/agent/skill/index.ts @@ -0,0 +1,72 @@ +import { randomUUID } from 'node:crypto'; + +import type { ActivateSkillPayload } from '#/rpc'; +import type { ContentPart } from '@moonshot-ai/kosong'; + +import type { Agent } from '..'; +import { ErrorCodes, KimiError } from '#/errors'; +import { isUserActivatableSkillType, type SkillRegistry } from '../../skill'; +import type { SkillActivationOrigin } from '../context'; + +export class SkillManager { + constructor( + protected readonly agent: Agent, + public readonly registry: SkillRegistry, + ) {} + + activate(input: ActivateSkillPayload): void { + const skill = this.registry.getSkill(input.name); + if (skill === undefined) { + throw new KimiError(ErrorCodes.SKILL_NOT_FOUND, `Skill "${input.name}" was not found`); + } + if (!isUserActivatableSkillType(skill.metadata.type)) { + throw new KimiError(ErrorCodes.SKILL_TYPE_UNSUPPORTED, `Skill "${skill.name}" cannot be activated by the user`); + } + + this.recordActivation( + { + kind: 'skill_activation', + activationId: randomUUID(), + skillName: skill.name, + trigger: 'user-slash', + skillType: skill.metadata.type, + skillPath: skill.path, + skillSource: skill.source, + skillArgs: input.args, + }, + [ + { + type: 'text', + text: this.registry.renderSkillPrompt(skill, input.args ?? ''), + }, + ], + ); + } + + recordActivation( + origin: SkillActivationOrigin, + input?: readonly ContentPart[] | undefined, + ): void { + this.agent.emitEvent({ + type: 'skill.activated', + activationId: origin.activationId, + skillName: origin.skillName, + trigger: origin.trigger, + skillArgs: origin.skillArgs, + skillPath: origin.skillPath, + skillSource: origin.skillSource, + }); + this.agent.telemetry.track('skill_invoked', { + skill_name: origin.skillName, + trigger: origin.trigger, + }); + if (origin.skillType === 'flow') { + this.agent.telemetry.track('flow_invoked', { + flow_name: origin.skillName, + }); + } + if (input !== undefined) { + this.agent.turn.prompt(input, origin); + } + } +} diff --git a/packages/agent-core/src/agent/tool/index.ts b/packages/agent-core/src/agent/tool/index.ts new file mode 100644 index 000000000..614b8ff25 --- /dev/null +++ b/packages/agent-core/src/agent/tool/index.ts @@ -0,0 +1,421 @@ +import { uniq } from '@antfu/utils'; +import type { ChatProvider, Tool } from '@moonshot-ai/kosong'; + +import type { Agent } from '..'; +import { globMatch } from '../permission/path-glob-match'; +import { makeErrorPayload } from '../../errors'; +import type { ExecutableTool } from '../../loop'; +import { createMcpAuthTool } from '../../mcp/auth-tool'; +import type { McpConnectionManager, McpServerEntry } from '../../mcp'; +import { mcpResultToExecutableOutput } from '../../mcp/output'; +import { isMcpToolName, qualifyMcpToolName } from '../../mcp/tool-naming'; +import type { MCPClient } from '../../mcp/types'; +import { DEFAULT_AGENT_PROFILES } from '../../profile'; +import { withProviderRequestAuth } from '../../providers/request-auth'; +import { extendWorkspaceWithSkillRoots } from '../../skill'; +import * as b from '../../tools/builtin'; +import type { ToolStore, ToolStoreData, ToolStoreKey } from '../../tools/store'; +import type { + BuiltinTool, + McpServerRegistrationResult, + McpToolCollision, + ToolInfo, + UserToolRegistration, +} from './types'; + +export * from './types'; + +interface McpToolEntry { + readonly tool: ExecutableTool; + readonly serverName: string; +} + +export class ToolManager { + protected builtinTools: Map = new Map(); + protected readonly userTools: Map = new Map(); + protected readonly mcpTools: Map = new Map(); + /** server name → list of qualified tool names registered for that server. */ + protected readonly mcpToolsByServer: Map = new Map(); + protected enabledTools: Set = new Set(); + /** Glob patterns (e.g. `mcp__*`, `mcp__github__*`) gating which MCP tools the profile exposes. */ + private mcpAccessPatterns: string[] = []; + protected readonly store: Partial = {}; + private mcpToolStatusUnsubscribe: (() => void) | undefined; + + constructor(protected readonly agent: Agent) { + this.attachMcpTools(); + } + + protected get toolStore(): ToolStore { + return { + get: (key) => this.store[key], + set: (key, value) => { + this.updateStore(key, value); + }, + }; + } + + attachMcpTools(): void { + const mcp = this.agent.mcp; + if (mcp === undefined) return; + if (this.mcpToolStatusUnsubscribe !== undefined) return; + for (const entry of mcp.list()) { + if (entry.status === 'connected') { + this.registerConnectedMcpServer(mcp, entry); + } else if (entry.status === 'needs-auth') { + this.registerNeedsAuthMcpServer(mcp, entry); + } + } + this.mcpToolStatusUnsubscribe = mcp.onStatusChange((entry) => { + this.handleMcpServerStatusChange(mcp, entry); + }); + } + + updateStore(key: K, value: ToolStoreData[K]): void { + this.agent.records.logRecord({ + type: 'tools.update_store', + key, + value, + }); + this.store[key] = value; + } + + registerUserTool(input: UserToolRegistration): void { + this.agent.records.logRecord({ + type: 'tools.register_user_tool', + ...input, + }); + const { name, description, parameters } = input; + const tool: ExecutableTool = { + name, + description, + parameters, + resolveExecution: (args) => { + return { + execute: async (context) => { + return this.agent.rpc.toolCall( + { + turnId: Number(context.turnId), + toolCallId: context.toolCallId, + args, + }, + { signal: context.signal }, + ); + }, + }; + }, + }; + this.userTools.set(name, tool); + this.enabledTools.add(name); + } + + unregisterUserTool(name: string): void { + this.agent.records.logRecord({ + type: 'tools.unregister_user_tool', + name, + }); + this.userTools.delete(name); + this.enabledTools.delete(name); + } + + registerMcpServer( + serverName: string, + client: MCPClient, + tools: readonly Tool[], + enabledTools?: ReadonlySet, + ): McpServerRegistrationResult { + this.unregisterMcpServer(serverName); + const qualifiedNames: string[] = []; + const collisions: McpToolCollision[] = []; + const seenInThisCall = new Map(); + for (const tool of tools) { + if (enabledTools !== undefined && !enabledTools.has(tool.name)) continue; + const qualified = qualifyMcpToolName(serverName, tool.name); + const firstInThisCall = seenInThisCall.get(qualified); + if (firstInThisCall !== undefined) { + collisions.push({ + qualified, + toolName: tool.name, + collidesWith: { kind: 'same_server', toolName: firstInThisCall }, + }); + continue; + } + const existingEntry = this.mcpTools.get(qualified); + if (existingEntry !== undefined) { + collisions.push({ + qualified, + toolName: tool.name, + collidesWith: { kind: 'other_server', serverName: existingEntry.serverName }, + }); + continue; + } + seenInThisCall.set(qualified, tool.name); + const wrapped: ExecutableTool = { + name: qualified, + description: tool.description, + parameters: tool.parameters, + resolveExecution: (args) => { + return { + execute: async (context) => { + // `args` has already been JSON-parsed and schema-validated by + // the loop's preflight (`loop/tool-call.ts`), so the MCP + // client gets a plain object directly. + const result = await client.callTool( + tool.name, + (args ?? {}) as Record, + context.signal, + ); + return mcpResultToExecutableOutput(result, qualified); + }, + }; + }, + }; + this.mcpTools.set(qualified, { tool: wrapped, serverName }); + qualifiedNames.push(qualified); + } + this.mcpToolsByServer.set(serverName, qualifiedNames); + return { registered: qualifiedNames, collisions }; + } + + unregisterMcpServer(serverName: string): boolean { + const existing = this.mcpToolsByServer.get(serverName); + if (existing === undefined) return false; + for (const qualified of existing) { + this.mcpTools.delete(qualified); + } + this.mcpToolsByServer.delete(serverName); + return true; + } + + private handleMcpServerStatusChange(mcp: McpConnectionManager, entry: McpServerEntry): void { + if (entry.status === 'connected') { + this.registerConnectedMcpServer(mcp, entry); + return; + } + if (entry.status === 'needs-auth') { + this.registerNeedsAuthMcpServer(mcp, entry); + return; + } + if (entry.status === 'failed') { + this.unregisterMcpServer(entry.name); + this.agent.emitEvent({ + type: 'tool.list.updated', + reason: 'mcp.failed', + serverName: entry.name, + }); + return; + } + if (entry.status === 'disabled' || entry.status === 'pending') { + const removed = this.unregisterMcpServer(entry.name); + if (removed) { + this.agent.emitEvent({ + type: 'tool.list.updated', + reason: 'mcp.disconnected', + serverName: entry.name, + }); + } + } + } + + private registerNeedsAuthMcpServer(mcp: McpConnectionManager, entry: McpServerEntry): void { + // Replace whatever tools (real or synthetic) were registered before; a + // server flipping to needs-auth means previous tokens were invalidated. + this.unregisterMcpServer(entry.name); + const oauthService = mcp.oauthService; + const serverUrl = mcp.getHttpServerUrl(entry.name); + if (oauthService === undefined || serverUrl === undefined) { + // Misconfiguration: a server reached needs-auth without the manager + // owning an OAuth service or being HTTP. Treat it as a no-op so the + // existing failure error message keeps the user informed. + return; + } + const tool = createMcpAuthTool({ + serverName: entry.name, + serverUrl, + oauthService, + reconnect: async () => { + await mcp.reconnect(entry.name); + }, + }); + this.mcpTools.set(tool.name, { tool, serverName: entry.name }); + this.mcpToolsByServer.set(entry.name, [tool.name]); + // The synthetic auth tool is now in the tool list; surface it the same way + // a real toolset would show up so the model picks it up. + this.agent.emitEvent({ + type: 'tool.list.updated', + reason: 'mcp.connected', + serverName: entry.name, + }); + } + + private registerConnectedMcpServer(mcp: McpConnectionManager, entry: McpServerEntry): void { + const resolved = mcp.resolved(entry.name); + if (resolved === undefined) return; + const result = this.registerMcpServer( + entry.name, + resolved.client, + resolved.tools, + resolved.enabledNames, + ); + this.emitMcpToolCollisions(entry.name, result.collisions); + this.agent.emitEvent({ + type: 'tool.list.updated', + reason: 'mcp.connected', + serverName: entry.name, + }); + } + + private emitMcpToolCollisions(serverName: string, collisions: readonly McpToolCollision[]): void { + if (collisions.length === 0) return; + const summary = collisions + .map((c) => + c.collidesWith.kind === 'same_server' + ? `"${c.toolName}" -> ${c.qualified} (collides with "${c.collidesWith.toolName}" from the same server)` + : `"${c.toolName}" -> ${c.qualified} (collides with server "${c.collidesWith.serverName}")`, + ) + .join('; '); + this.agent.emitEvent({ + type: 'error', + ...makeErrorPayload( + 'mcp.tool_name_collision', + `MCP server "${serverName}" registered ${collisions.length} tool name` + + `${collisions.length === 1 ? '' : 's'} ` + + `that collide with existing qualified names; the losing tools were dropped: ${summary}`, + { details: { serverName, collisions: collisions as readonly unknown[] } }, + ), + }); + } + + setActiveTools(names: readonly string[]): void { + this.agent.records.logRecord({ + type: 'tools.set_active_tools', + names, + }); + // MCP entries are glob patterns gated separately; the rest are exact + // builtin/user tool names. The split keeps every caller on one string[]. + this.enabledTools = new Set(names.filter((name) => !isMcpToolName(name))); + this.mcpAccessPatterns = names.filter((name) => isMcpToolName(name)); + } + + private isMcpToolEnabled(name: string): boolean { + return this.mcpAccessPatterns.some((pattern) => globMatch(name, pattern)); + } + + *toolInfos(): Iterable { + for (const tool of this.builtinTools.values()) { + yield { + name: tool.name, + description: tool.description, + active: this.enabledTools.has(tool.name), + source: 'builtin', + }; + } + for (const tool of this.userTools.values()) { + yield { + name: tool.name, + description: tool.description, + active: this.enabledTools.has(tool.name), + source: 'user', + }; + } + for (const entry of this.mcpTools.values()) { + yield { + name: entry.tool.name, + description: entry.tool.description, + active: this.isMcpToolEnabled(entry.tool.name), + source: 'mcp', + }; + } + } + + data(): readonly ToolInfo[] { + return Array.from(this.toolInfos()); + } + + storeData(): Readonly> { + return { ...this.store }; + } + + initializeBuiltinTools(): void { + const { + runtime: { kaos, osEnv, urlFetcher, webSearcher }, + config: { cwd, provider, modelCapabilities }, + background, + } = this.agent; + const videoUploader = this.createVideoUploader(provider); + const workspace = extendWorkspaceWithSkillRoots( + { + workspaceDir: cwd, + additionalDirs: [], + }, + this.agent.skills?.registry.getSkillRoots() ?? [], + ); + const allowBackground = + this.enabledTools.has('TaskList') && + this.enabledTools.has('TaskOutput') && + this.enabledTools.has('TaskStop'); + this.builtinTools = new Map( + [ + new b.ReadTool(kaos, workspace), + new b.WriteTool(kaos, workspace), + new b.EditTool(kaos, workspace), + new b.GrepTool(kaos, workspace), + new b.GlobTool(kaos, workspace), + new b.BashTool(kaos, cwd, osEnv, background, { + allowBackground, + }), + (modelCapabilities.image_in || modelCapabilities.video_in) && + new b.ReadMediaFileTool(kaos, workspace, modelCapabilities, videoUploader), + new b.EnterPlanModeTool(this.agent), + new b.ExitPlanModeTool(this.agent), + new b.AskUserQuestionTool(this.agent), + new b.TodoListTool(this.toolStore), + new b.TaskListTool(background), + new b.TaskOutputTool(background), + new b.TaskStopTool(background), + this.agent.skills !== undefined && + this.agent.skills.registry.listInvocableSkills().length > 0 && + new b.SkillTool(this.agent), + this.agent.subagentHost && + new b.AgentTool( + this.agent.subagentHost, + background, + DEFAULT_AGENT_PROFILES['agent']?.subagents, + { + allowBackground, + log: this.agent.log, + }, + ), + webSearcher && new b.WebSearchTool(webSearcher), + urlFetcher && new b.FetchURLTool(urlFetcher), + ] + .filter((tool) => !!tool) + .map((tool) => [tool.name, tool] as const), + ); + } + + private createVideoUploader(provider: ChatProvider): b.VideoUploader | undefined { + const uploadVideo = provider.uploadVideo?.bind(provider); + if (uploadVideo === undefined) return undefined; + + const modelAlias = this.agent.config.modelAlias; + const resolveAuth = + modelAlias === undefined + ? undefined + : this.agent.providerManager?.createAuthResolverForModel(modelAlias, { + log: this.agent.log, + }); + return (input) => withProviderRequestAuth(resolveAuth, (auth) => uploadVideo(input, { auth })); + } + + get loopTools(): readonly ExecutableTool[] { + const mcpNames = [...this.mcpTools.keys()].filter((name) => this.isMcpToolEnabled(name)); + return uniq([...this.enabledTools, ...mcpNames]) + .toSorted((a, b) => a.localeCompare(b)) + .map( + (name) => + this.userTools.get(name) ?? this.mcpTools.get(name)?.tool ?? this.builtinTools.get(name), + ) + .filter((tool) => !!tool); + } +} diff --git a/packages/agent-core/src/agent/tool/types.ts b/packages/agent-core/src/agent/tool/types.ts new file mode 100644 index 000000000..08a822d85 --- /dev/null +++ b/packages/agent-core/src/agent/tool/types.ts @@ -0,0 +1,31 @@ +import type { ExecutableTool } from '../../loop'; + +export type ToolSource = 'builtin' | 'user' | 'mcp'; + +export type BuiltinTool = ExecutableTool; + +export interface UserToolRegistration { + readonly name: string; + readonly description: string; + readonly parameters: Record; +} + +export interface ToolInfo { + readonly name: string; + readonly description: string; + readonly active: boolean; + readonly source: ToolSource; +} + +export interface McpToolCollision { + readonly qualified: string; + readonly toolName: string; + readonly collidesWith: + | { readonly kind: 'same_server'; readonly toolName: string } + | { readonly kind: 'other_server'; readonly serverName: string }; +} + +export interface McpServerRegistrationResult { + readonly registered: readonly string[]; + readonly collisions: readonly McpToolCollision[]; +} diff --git a/packages/agent-core/src/agent/turn/canonical-args.ts b/packages/agent-core/src/agent/turn/canonical-args.ts new file mode 100644 index 000000000..2753af2f0 --- /dev/null +++ b/packages/agent-core/src/agent/turn/canonical-args.ts @@ -0,0 +1,28 @@ +/** + * JSON canonicalization used by tool-call telemetry and dedup. + * Recursively sorts object keys so semantically-equal args produce identical keys. + */ +export function canonicalTelemetryArgs(args: unknown): string { + const json = JSON.stringify(sortJsonValue(args)); + return json ?? String(args); +} + +function sortJsonValue(value: unknown): unknown { + if (Array.isArray(value)) { + return value.map(sortJsonValue); + } + if (!isPlainRecord(value)) { + return value; + } + const out: Record = {}; + for (const key of Object.keys(value).toSorted()) { + out[key] = sortJsonValue(value[key]); + } + return out; +} + +export function isPlainRecord(value: unknown): value is Record { + if (value === null || typeof value !== 'object') return false; + const proto = Object.getPrototypeOf(value); + return proto === Object.prototype || proto === null; +} diff --git a/packages/agent-core/src/agent/turn/index.ts b/packages/agent-core/src/agent/turn/index.ts new file mode 100644 index 000000000..1b14b2a3f --- /dev/null +++ b/packages/agent-core/src/agent/turn/index.ts @@ -0,0 +1,858 @@ +import { createHash } from 'node:crypto'; + +import { + APIConnectionError, + APIContextOverflowError, + APIEmptyResponseError, + APIStatusError, + APITimeoutError, + inputTotal, + type ContentPart, + type TokenUsage, +} from '@moonshot-ai/kosong'; + +import type { Agent } from '..'; +import { + ErrorCodes, + type KimiErrorPayload, + isKimiError, + makeErrorPayload, + toKimiErrorPayload, +} from '#/errors'; +import { isAbortError, isMaxStepsExceededError } from '../../loop/errors'; +import { + createLoopEventDispatcher, + runTurn, + type ExecutableToolResult, + type LoopEvent, + type LoopRecordedEvent, + type LoopTurnInterruptedEvent, + type LoopTurnStopReason, +} from '../../loop/index'; +import type { AgentEvent, TurnEndedEvent } from '../../rpc'; +import type { TelemetryPropertyValue } from '../../telemetry'; +import { abortable } from '../../utils/abort'; +import { resolveCompletionBudget } from '../../utils/completion-budget'; +import { USER_PROMPT_ORIGIN, type PromptOrigin } from '../context'; +import { renderUserPromptHookBlockResult, renderUserPromptHookResult } from '../hooks'; +import { canonicalTelemetryArgs, isPlainRecord } from './canonical-args'; +import { KosongLLM } from './kosong-llm'; +import { ToolCallDeduplicator } from './tool-dedup'; + +interface ActiveTurn { + controller: AbortController; + promise: Promise; +} + +interface BufferedSteer { + readonly input: readonly ContentPart[]; + readonly origin: PromptOrigin; +} + +export interface TurnEndResult { + readonly event: TurnEndedEvent; + readonly stopReason?: LoopTurnStopReason; +} + +const LLM_NOT_SET_MESSAGE = 'LLM not set, send "/login" to login'; + +export class TurnFlow { + private steerBuffer: BufferedSteer[] = []; + private turnId = -1; + private activeTurn: 'resuming' | ActiveTurn | null = null; + private readonly toolCallStartedAt = new Map(); + private readonly toolCallDupType = new Map(); + private readonly stepToolCallKeys = new Map>(); + private readonly telemetryModeByTurn = new Map(); + private readonly currentStepByTurn = new Map(); + private readonly interruptedTelemetryTurnIds = new Set(); + private readonly stepFailureByTurn = new Map(); + private currentStep = 0; + + constructor(protected readonly agent: Agent) {} + + // Returns the new turnId, or null if the turn was marked as resuming. + prompt(input: readonly ContentPart[], origin: PromptOrigin = USER_PROMPT_ORIGIN): number | null { + this.agent.records.logRecord({ + type: 'turn.prompt', + input, + origin, + }); + return this.launch(input, origin); + } + + // Returns the new turnId, or null if the input was buffered as a steer + // message or the turn was marked as resuming. + steer(input: readonly ContentPart[], origin: PromptOrigin = USER_PROMPT_ORIGIN): number | null { + this.agent.records.logRecord({ + type: 'turn.steer', + input, + origin, + }); + if (this.activeTurn) { + this.steerBuffer.push({ input, origin }); + return null; + } + return this.launch(input, origin); + } + + private launch(input: readonly ContentPart[], origin: PromptOrigin): number | null { + if (this.activeTurn) { + this.agent.emitEvent({ + type: 'error', + ...makeErrorPayload( + 'turn.agent_busy', + `Cannot launch a new turn while another turn (ID ${this.turnId}) is active`, + { details: { turnId: this.turnId } }, + ), + }); + return null; + } + + this.turnId += 1; + this.currentStep = 0; + this.stepToolCallKeys.clear(); + this.toolCallDupType.clear(); + const telemetryMode = this.telemetryMode(); + this.telemetryModeByTurn.set(this.turnId, telemetryMode); + this.currentStepByTurn.set(this.turnId, 0); + this.agent.telemetry.track('turn_started', { mode: telemetryMode }); + this.agent.fullCompaction.resetForTurn(); + this.agent.usage.beginTurn(); + this.agent.emitEvent({ + type: 'turn.started', + turnId: this.turnId, + origin, + }); + this.agent.context.appendUserMessage(input, origin); + const controller = new AbortController(); + const promise = this.turnWorker(this.turnId, input, origin, controller.signal); + this.activeTurn = { controller, promise }; + return this.turnId; + } + + restorePrompt(): void { + if (this.activeTurn) { + return; + } + this.turnId += 1; + this.activeTurn = 'resuming'; + } + + restoreSteer(input: readonly ContentPart[], origin: PromptOrigin): void { + if (this.activeTurn) { + this.steerBuffer.push({ input, origin }); + return; + } + this.turnId += 1; + this.activeTurn = 'resuming'; + } + + cancel(turnId?: number): void { + this.agent.records.logRecord({ type: 'turn.cancel', turnId }); + if (turnId !== undefined && turnId !== this.currentId) { + return; // Ignore cancel for non-active turn + } + this.abortTurn(); + this.agent.subagentHost?.cancelAll(); + } + + get currentId() { + return this.turnId; + } + + get hasActiveTurn(): boolean { + return this.activeTurn !== null && this.activeTurn !== 'resuming'; + } + + waitForCurrentTurn(signal?: AbortSignal | undefined): Promise { + const active = this.activeTurn; + if (active === null || active === 'resuming') { + return Promise.reject(new Error('No active turn')); + } + signal?.throwIfAborted(); + if (signal === undefined) return active.promise; + + const turnId = this.currentId; + const onAbort = (): void => { + this.agent.turn.cancel(turnId); + }; + signal.addEventListener('abort', onAbort, { once: true }); + + return abortable(active.promise, signal).finally(() => { + signal.removeEventListener('abort', onAbort); + }); + } + + private abortTurn() { + if (this.activeTurn !== 'resuming') { + this.activeTurn?.controller.abort(); + } + this.activeTurn = null; + } + + private flushSteerBuffer(): boolean { + const steers = this.steerBuffer; + if (steers.length === 0) return false; + for (const steer of steers) { + this.agent.context.appendUserMessage(steer.input, steer.origin); + } + steers.length = 0; + return true; + } + + finishResume(): void { + if (this.activeTurn === 'resuming') { + this.activeTurn = null; + } + this.steerBuffer.length = 0; + } + + private async turnWorker( + turnId: number, + input: readonly ContentPart[], + origin: PromptOrigin, + signal: AbortSignal, + ): Promise { + const startedAt = Date.now(); + let ended: TurnEndedEvent; + let completedStopReason: LoopTurnStopReason | undefined; + try { + const promptHookEnded = await this.applyUserPromptHook( + turnId, + input, + origin, + signal, + ); + if (promptHookEnded !== undefined) { + ended = promptHookEnded; + } else { + const stopReason = await this.runTurn(turnId, signal); + completedStopReason = stopReason; + ended = { + type: 'turn.ended', + turnId, + reason: stopReason === 'aborted' ? 'cancelled' : 'completed', + }; + this.agent.emitEvent(ended); + } + } catch (error) { + if (isAbortError(error)) { + ended = { + type: 'turn.ended', + turnId, + reason: 'cancelled', + }; + this.agent.emitEvent(ended); + } else { + const summary = summarizeTurnError(error, turnId); + void this.agent.hooks?.fireAndForgetTrigger('StopFailure', { + matcherValue: summary.name, + inputData: { + errorType: summary.name, + errorMessage: summary.message, + }, + }); + ended = { + type: 'turn.ended', + turnId, + reason: 'failed', + error: summary, + }; + this.agent.emitEvent(ended); + this.agent.emitEvent({ + type: 'error', + ...summary, + }); + if (this.shouldTrackApiError(turnId)) { + const classification = classifyApiError(error, summary); + const properties: Record = { + error_type: classification.errorType, + model: this.agent.config.model, + retryable: summary.retryable, + duration_ms: Date.now() - startedAt, + }; + if (classification.statusCode !== undefined) { + properties['status_code'] = classification.statusCode; + } + const inputTokens = currentTurnInputTokens(this.agent.usage.data().currentTurn); + if (inputTokens !== undefined) { + properties['input_tokens'] = inputTokens; + } + this.agent.telemetry.track('api_error', properties); + } + } + } finally { + // The turn may have been aborted and a new turn may have started + if (this.currentId === turnId) { + this.agent.usage.endTurn(); + this.activeTurn = null; + } + } + if (ended.reason !== 'completed') { + this.trackTurnInterrupted(turnId, this.currentStepByTurn.get(turnId) ?? this.currentStep); + } + this.telemetryModeByTurn.delete(turnId); + this.currentStepByTurn.delete(turnId); + this.interruptedTelemetryTurnIds.delete(turnId); + this.stepFailureByTurn.delete(turnId); + return { + event: ended, + stopReason: completedStopReason, + }; + } + + private async applyUserPromptHook( + turnId: number, + input: readonly ContentPart[], + origin: PromptOrigin, + signal: AbortSignal, + ): Promise { + if (origin.kind !== 'user') return undefined; + signal.throwIfAborted(); + const promptHookResults = await this.agent.hooks?.trigger('UserPromptSubmit', { + matcherValue: input, + signal, + inputData: { prompt: input }, + }); + signal.throwIfAborted(); + const blockResult = renderUserPromptHookBlockResult(promptHookResults); + if (blockResult !== undefined) { + this.agent.context.markLastUserPromptBlocked('UserPromptSubmit'); + this.agent.context.appendMessage({ + role: 'assistant', + content: [{ type: 'text', text: blockResult.text }], + toolCalls: [], + origin: { kind: 'hook_result', event: 'UserPromptSubmit', blocked: true }, + }); + this.agent.emitEvent({ + type: 'hook.result', + turnId, + hookEvent: blockResult.event, + content: blockResult.message, + blocked: true, + }); + const ended: TurnEndedEvent = { + type: 'turn.ended', + turnId, + reason: 'completed', + }; + this.agent.emitEvent(ended); + return ended; + } + + const hookResult = renderUserPromptHookResult(promptHookResults); + if (hookResult === undefined) return undefined; + + this.agent.context.appendUserMessage([{ type: 'text', text: hookResult.text }], { + kind: 'hook_result', + event: 'UserPromptSubmit', + }); + this.agent.emitEvent({ + type: 'hook.result', + turnId, + hookEvent: hookResult.event, + content: hookResult.message, + }); + return undefined; + } + + private async runTurn(turnId: number, signal: AbortSignal): Promise { + let stopHookContinuationUsed = false; + const deduper = new ToolCallDeduplicator(); + await this.agent.mcp?.waitForInitialLoad(signal); + while (true) { + signal.throwIfAborted(); + const model = this.agent.config.model; + const provider = this.agent.config.provider.withThinking(this.agent.config.thinkingLevel); + const loopControl = this.agent.providerManager?.config.loopControl; + const completionBudget = resolveCompletionBudget({ + reservedContextSize: loopControl?.reservedContextSize, + }); + + try { + const result = await runTurn({ + turnId: String(turnId), + signal, + llm: new KosongLLM({ + provider, + modelName: model, + systemPrompt: this.agent.config.systemPrompt, + capability: this.agent.config.modelCapabilities, + generate: this.agent.generate, + completionBudget, + }), + buildMessages: () => this.agent.context.messages, + dispatchEvent: this.buildDispatchEvent(turnId), + tools: this.agent.tools.loopTools, + log: this.agent.log, + maxSteps: loopControl?.maxStepsPerTurn, + maxRetryAttempts: loopControl?.maxRetriesPerStep, + hooks: { + beforeStep: async ({ signal: stepSignal }) => { + this.flushSteerBuffer(); + await this.agent.fullCompaction.beforeStep(stepSignal); + await this.agent.injection.inject(); + deduper.beginStep(); + return; + }, + afterStep: async ({ usage }) => { + this.agent.usage.record(model, usage, 'turn'); + await this.agent.fullCompaction.afterStep(); + deduper.endStep(); + }, + // oxlint-disable-next-line no-loop-func -- stop hook continuation state is scoped to this turn. + shouldContinueAfterStop: async ({ signal }) => { + if (this.flushSteerBuffer()) return { continue: true }; + signal.throwIfAborted(); + + // Stop hooks get one continuation; otherwise a hook that always blocks would loop forever. + if (stopHookContinuationUsed) return { continue: false }; + const stopBlock = await this.agent.hooks?.triggerBlock('Stop', { + signal, + inputData: { stopHookActive: stopHookContinuationUsed }, + }); + signal.throwIfAborted(); + if (stopBlock !== undefined) { + stopHookContinuationUsed = true; + this.agent.context.appendUserMessage( + [{ type: 'text', text: stopBlock.reason }], + { + kind: 'system_trigger', + name: 'stop_hook', + }, + ); + return { continue: true }; + } + return { continue: false }; + }, + prepareToolExecution: async (ctx) => { + const cached = deduper.checkSameStep( + ctx.toolCall.id, + ctx.toolCall.function.name, + ctx.args, + ); + if (cached !== null) return { syntheticResult: cached }; + const hookResult = await this.agent.hooks?.triggerBlock('PreToolUse', { + matcherValue: ctx.toolCall.function.name, + signal: ctx.signal, + inputData: { + toolName: ctx.toolCall.function.name, + toolInput: toolInputRecord(ctx.args), + toolCallId: ctx.toolCall.id, + }, + }); + ctx.signal.throwIfAborted(); + if (hookResult) { + this.agent.telemetry.track('hook_triggered', { + event_type: 'PreToolUse', + action: hookResult.block ? 'block' : 'allow', + }); + return hookResult; + } + const permissionResult = await this.agent.permission.beforeToolCall(ctx); + this.agent.telemetry.track('hook_triggered', { + event_type: 'PreToolUse', + action: permissionResult?.block === true ? 'block' : 'allow', + }); + return permissionResult; + }, + finalizeToolResult: async (ctx) => { + // Resolve dedup BEFORE firing the PostToolUse hook so same-step + // dups (whose ctx.result is the dedup placeholder) report the + // original's real outcome, not an empty success. + const finalResult = await deduper.finalizeResult( + ctx.toolCall.id, + ctx.toolCall.function.name, + ctx.args, + ctx.result, + ); + const { isError, output } = finalResult; + const event = isError === true ? 'PostToolUseFailure' : 'PostToolUse'; + void this.agent.hooks?.fireAndForgetTrigger(event, { + matcherValue: ctx.toolCall.function.name, + inputData: { + toolName: ctx.toolCall.function.name, + toolInput: toolInputRecord(ctx.args), + toolCallId: ctx.toolCall.id, + error: isError === true ? toKimiErrorPayload(toolOutputText(output)) : undefined, + toolOutput: isError === true ? undefined : toolOutputText(output).slice(0, 2000), + }, + }); + return finalResult; + }, + }, + }); + + return result.stopReason; + } catch (error) { + if ( + error instanceof APIContextOverflowError || + (isKimiError(error) && error.code === ErrorCodes.CONTEXT_OVERFLOW) + ) { + await this.agent.fullCompaction.handleOverflowError(signal, error); + continue; // Retry with compacted context + } + if (isMaxStepsExceededError(error)) { + this.agent.log.warn('turn hit max steps', { + turnId, + steps: this.currentStepByTurn.get(turnId) ?? this.currentStep, + limit: isKimiError(error) ? error.details?.['maxSteps'] : undefined, + }); + } else { + this.agent.log.error('turn failed', { turnId, error }); + } + throw error; + } + } + } + + private buildDispatchEvent(turnId: number) { + return createLoopEventDispatcher({ + appendTranscriptRecord: async (event: LoopRecordedEvent) => { + this.agent.context.appendLoopEvent(event); + }, + emitLiveEvent: (event: LoopEvent) => { + this.trackLoopTelemetry(event, turnId); + const mapped = mapLoopEvent(event, turnId); + if (mapped !== undefined) this.agent.emitEvent(mapped); + }, + }); + } + + private trackLoopTelemetry(event: LoopEvent, turnId: number): void { + if (event.type === 'step.begin') { + this.beginTrackedStep(turnId, event.step); + return; + } + if (event.type === 'turn.interrupted') { + if (event.reason === 'error' && event.activeStep !== undefined) { + this.stepFailureByTurn.set(turnId, event); + } + this.trackTurnInterrupted(turnId, interruptedStep(event)); + return; + } + this.trackToolLifecycle(event, turnId); + } + + private beginTrackedStep(turnId: number, step: number): void { + this.currentStepByTurn.set(turnId, step); + this.currentStep = step; + if (!this.stepToolCallKeys.has(step)) { + this.stepToolCallKeys.set(step, new Set()); + } + } + + private trackToolLifecycle(event: LoopEvent, turnId: number): void { + if (event.type === 'tool.call') { + const dupType = this.trackDuplicateToolCall(turnId, event.step, event.name, event.args); + this.toolCallDupType.set( + event.toolCallId, + dupType === 'cross_step' ? 'cross_step' : 'normal', + ); + this.toolCallStartedAt.set(event.toolCallId, { + name: event.name, + startedAt: Date.now(), + }); + return; + } + if (event.type === 'tool.result') { + const started = this.toolCallStartedAt.get(event.toolCallId); + if (started === undefined) return; + this.toolCallStartedAt.delete(event.toolCallId); + const dupType = this.toolCallDupType.get(event.toolCallId) ?? 'normal'; + this.toolCallDupType.delete(event.toolCallId); + const outcome = telemetryToolOutcome(event.result); + const properties: Record = { + tool_name: started.name, + outcome, + duration_ms: Date.now() - started.startedAt, + dup_type: dupType, + }; + const errorType = outcome === 'error' ? telemetryToolErrorType(event.result) : undefined; + if (errorType !== undefined) { + properties['error_type'] = errorType; + } + this.agent.telemetry.track('tool_call', properties); + } + } + + private trackDuplicateToolCall( + turnId: number, + step: number, + toolName: string, + args: unknown, + ): 'normal' | 'same_step' | 'cross_step' { + const argsText = canonicalTelemetryArgs(args); + const key = `${toolName}\u0000${argsText}`; + const stepKeys = this.stepToolCallKeys.get(step) ?? new Set(); + this.stepToolCallKeys.set(step, stepKeys); + + let dupType: 'same_step' | 'cross_step' | undefined; + if (stepKeys.has(key)) { + dupType = 'same_step'; + } else if (this.hasPriorStepToolCallKey(step, key)) { + dupType = 'cross_step'; + } + + stepKeys.add(key); + if (dupType === undefined) return 'normal'; + + this.agent.telemetry.track('tool_call_dedup_detected', { + turn_id: turnId, + step_no: step, + tool_name: toolName, + dup_type: dupType, + args_hash: createHash('sha256').update(argsText).digest('hex').slice(0, 8), + }); + return dupType; + } + + private hasPriorStepToolCallKey(step: number, key: string): boolean { + for (const [seenStep, keys] of this.stepToolCallKeys) { + if (seenStep !== step && keys.has(key)) return true; + } + return false; + } + + private trackTurnInterrupted(turnId: number, atStep: number): void { + if (this.interruptedTelemetryTurnIds.has(turnId)) return; + this.interruptedTelemetryTurnIds.add(turnId); + this.agent.telemetry.track('turn_interrupted', { + mode: this.telemetryModeByTurn.get(turnId) ?? this.telemetryMode(), + at_step: atStep, + }); + } + + private telemetryMode(): 'agent' | 'plan' { + return this.agent.planMode.isActive ? 'plan' : 'agent'; + } + + private shouldTrackApiError(turnId: number): boolean { + const failure = this.stepFailureByTurn.get(turnId); + return failure?.reason === 'error' && failure.activeStep !== undefined; + } +} + +function mapLoopEvent(event: LoopEvent, turnId: number): AgentEvent | undefined { + switch (event.type) { + case 'step.begin': + return { + type: 'turn.step.started', + turnId, + step: event.step, + stepId: event.uuid, + }; + case 'step.end': + return { + type: 'turn.step.completed', + turnId, + step: event.step, + stepId: event.uuid, + usage: event.usage, + finishReason: event.finishReason, + providerFinishReason: event.providerFinishReason, + rawFinishReason: event.rawFinishReason, + }; + case 'step.retrying': + return { + type: 'turn.step.retrying', + turnId, + step: event.step, + stepId: event.stepUuid, + failedAttempt: event.failedAttempt, + nextAttempt: event.nextAttempt, + maxAttempts: event.maxAttempts, + delayMs: event.delayMs, + errorName: event.errorName, + errorMessage: event.errorMessage, + statusCode: event.statusCode, + }; + case 'content.part': + return undefined; + case 'tool.call': + return { + type: 'tool.call.started', + turnId, + toolCallId: event.toolCallId, + name: event.name, + args: event.args, + description: event.description, + display: event.display, + }; + case 'tool.result': + return { + type: 'tool.result', + turnId, + toolCallId: event.toolCallId, + output: event.result.output, + isError: event.result.isError, + }; + case 'turn.interrupted': + if (event.activeStep === undefined) return undefined; + return { + type: 'turn.step.interrupted', + turnId, + step: event.activeStep, + reason: event.reason, + message: event.message, + }; + case 'text.delta': + return { + type: 'assistant.delta', + turnId, + delta: event.delta, + }; + case 'thinking.delta': + return { + type: 'thinking.delta', + turnId, + delta: event.delta, + }; + case 'tool.call.delta': + return { + type: 'tool.call.delta', + turnId, + toolCallId: event.toolCallId, + name: event.name, + argumentsPart: event.argumentsPart, + }; + case 'tool.progress': + return { + type: 'tool.progress', + turnId, + toolCallId: event.toolCallId, + update: event.update, + }; + } +} + +function summarizeTurnError(error: unknown, turnId: number): KimiErrorPayload { + const payload = toKimiErrorPayload(error); + const details = { ...payload.details, turnId }; + + // Substitute a friendlier TUI-aware message for model-not-configured. + // Raw kosong text ("Model not set" / "Provider not set") is not + // actionable; this string points the user at the login flow. + if (payload.code === 'model.not_configured') { + return { ...payload, message: LLM_NOT_SET_MESSAGE, details }; + } + + return { ...payload, details }; +} + +function toolInputRecord(args: unknown): Record { + return isPlainRecord(args) ? args : {}; +} + +function toolOutputText(output: ExecutableToolResult['output']): string { + if (typeof output === 'string') return output; + return output + .filter((part): part is Extract<(typeof output)[number], { type: 'text' }> => { + return typeof part === 'object' && part !== null && part.type === 'text'; + }) + .map((part) => part.text) + .join(''); +} + +function interruptedStep(event: LoopTurnInterruptedEvent): number { + return event.activeStep ?? event.attemptedSteps; +} + +interface ApiErrorClassification { + readonly errorType: string; + readonly statusCode?: number; +} + +function classifyApiError(error: unknown, summary: KimiErrorPayload): ApiErrorClassification { + const statusCode = apiStatusCode(error) ?? summaryStatusCode(summary); + if (statusCode !== undefined) { + if (statusCode === 429) return { errorType: 'rate_limit', statusCode }; + if (statusCode === 401 || statusCode === 403) return { errorType: 'auth', statusCode }; + if (statusCode >= 500) return { errorType: '5xx_server', statusCode }; + if (isContextOverflowMessage(summary.message)) { + return { errorType: 'context_overflow', statusCode }; + } + if (statusCode >= 400) return { errorType: '4xx_client', statusCode }; + return { errorType: 'api', statusCode }; + } + + if (summary.code === ErrorCodes.PROVIDER_RATE_LIMIT) return { errorType: 'rate_limit' }; + if (summary.code === ErrorCodes.PROVIDER_AUTH_ERROR) return { errorType: 'auth' }; + if (summary.code === ErrorCodes.CONTEXT_OVERFLOW) return { errorType: 'context_overflow' }; + if (isApiConnectionError(error, summary)) return { errorType: 'network' }; + if (isApiTimeoutError(error, summary)) return { errorType: 'timeout' }; + if (isApiEmptyResponseError(error, summary)) return { errorType: 'empty_response' }; + return { errorType: 'other' }; +} + +function apiStatusCode(error: unknown): number | undefined { + if (error instanceof APIStatusError) { + const statusCode = (error as { readonly statusCode?: unknown }).statusCode; + return typeof statusCode === 'number' ? statusCode : undefined; + } + if (typeof error !== 'object' || error === null) return undefined; + const statusCode = (error as { readonly statusCode?: unknown }).statusCode; + if (typeof statusCode === 'number') return statusCode; + const status = (error as { readonly status?: unknown }).status; + return typeof status === 'number' ? status : undefined; +} + +function summaryStatusCode(summary: KimiErrorPayload): number | undefined { + const statusCode = summary.details?.['statusCode']; + return typeof statusCode === 'number' ? statusCode : undefined; +} + +function isApiConnectionError(error: unknown, summary: KimiErrorPayload): boolean { + return error instanceof APIConnectionError || summary.name === 'APIConnectionError'; +} + +function isApiTimeoutError(error: unknown, summary: KimiErrorPayload): boolean { + return ( + error instanceof APITimeoutError || + summary.name === 'APITimeoutError' || + summary.name === 'TimeoutError' + ); +} + +function isApiEmptyResponseError(error: unknown, summary: KimiErrorPayload): boolean { + return error instanceof APIEmptyResponseError || summary.name === 'APIEmptyResponseError'; +} + +function isContextOverflowMessage(message: string): boolean { + const lower = message.toLowerCase(); + return ( + lower.includes('context length') || + lower.includes('context_length') || + lower.includes('max tokens') || + lower.includes('maximum context') || + lower.includes('too many tokens') + ); +} + +function currentTurnInputTokens(usage: TokenUsage | undefined): number | undefined { + if (usage === undefined) return undefined; + return inputTotal(usage); +} + +type ToolTelemetryResult = Extract['result']; + +function telemetryToolOutcome(result: ToolTelemetryResult): 'success' | 'error' | 'cancelled' { + if (result.isError !== true) return 'success'; + const text = toolResultText(result).toLowerCase(); + return text.includes('aborted') || text.includes('cancelled') ? 'cancelled' : 'error'; +} + +function telemetryToolErrorType(result: ToolTelemetryResult): string { + const text = toolResultText(result); + if (text.startsWith('Tool "') && text.includes('" not found')) return 'ToolNotFound'; + if (text.startsWith('Invalid args for tool "')) return 'ToolInputError'; + if (text.includes('prepareToolExecution hook failed')) return 'HookError'; + if (text.includes('finalizeToolResult hook failed')) return 'HookError'; + if (text.includes('blocked')) return 'ToolBlocked'; + return 'ToolError'; +} + +function toolResultText(result: ToolTelemetryResult): string { + return toolOutputText(result.output); +} diff --git a/packages/agent-core/src/agent/turn/kosong-llm.ts b/packages/agent-core/src/agent/turn/kosong-llm.ts new file mode 100644 index 000000000..834fa4712 --- /dev/null +++ b/packages/agent-core/src/agent/turn/kosong-llm.ts @@ -0,0 +1,254 @@ +/** + * Kosong-backed implementation of the loop `LLM` interface. + * + * Bridges the new `loop/llm.ts` contract onto + * the kosong `generate()` streaming API: + * + * - kosong's per-part `onMessagePart` is forwarded to loop per-delta + * callbacks (`onTextDelta`, `onThinkDelta`, `onToolCallDelta`). + * - loop per-block callbacks (`onTextPart`, `onThinkPart`) only fire + * after the kosong stream drains, iterating over the merged + * `result.message.content`. Completed + * blocks land on the WAL seam, raw deltas never do. + * - kosong's finish reasons are preserved as provider diagnostics. The loop + * derives loop control from the normalized response shape, not from the + * provider's finish-reason spelling. + */ + +import { + APIConnectionError, + APIEmptyResponseError, + APIStatusError, + APITimeoutError, + emptyUsage, + generate as kosongGenerate, + type ChatProvider, + type GenerateCallbacks, + type Message, + type ModelCapability, + type StreamedMessagePart, +} from '@moonshot-ai/kosong'; + +import type { LLM, LLMChatParams, LLMChatResponse, LLMRequestLogContext } from '../../loop'; +import { + applyCompletionBudget, + type CompletionBudget, +} from '../../utils/completion-budget'; + +export const GENERATE_REQUEST_LOG_CONTEXT = '__kimiRequestLogContext'; + +export type GenerateOptionsWithRequestLog = { + readonly signal?: AbortSignal; + readonly [GENERATE_REQUEST_LOG_CONTEXT]?: LLMRequestLogContext; +}; + +export type GenerateFn = typeof kosongGenerate; + +export interface KosongLLMConfig { + readonly provider: ChatProvider; + readonly modelName: string; + readonly systemPrompt: string; + readonly capability?: ModelCapability | undefined; + /** + * Optional override for the kosong `generate()` entry point. Lets the + * agent host (and its test harness) inject a scripted generator without + * having to substitute the entire LLM implementation. + */ + readonly generate?: GenerateFn | undefined; + /** + * Per-request completion-token budget. When set, each `chat()` call + * clones the configured provider with a clamped `max_completion_tokens` + * derived from the current input size and model context window. The + * clone is local to the call and never replaces `this.provider`. + */ + readonly completionBudget?: CompletionBudget | undefined; +} + +export class KosongLLM implements LLM { + readonly systemPrompt: string; + readonly modelName: string; + readonly capability?: ModelCapability | undefined; + + private readonly provider: ChatProvider; + private readonly generate: GenerateFn; + private readonly completionBudget: CompletionBudget | undefined; + + constructor(config: KosongLLMConfig) { + this.provider = config.provider; + this.modelName = config.modelName; + this.systemPrompt = config.systemPrompt; + this.capability = config.capability; + this.generate = config.generate ?? kosongGenerate; + this.completionBudget = config.completionBudget; + } + + async chat(params: LLMChatParams): Promise { + return this.chatOnce(params); + } + + private async chatOnce(params: LLMChatParams): Promise { + const callbacks = buildKosongCallbacks(params); + + // Compute and apply the per-request completion budget against a + // throwaway shallow clone. `effectiveProvider` is local to this call + // and never written back to `this.provider`, so retries (handled at + // a higher layer) keep using the same long-lived provider/client. + // The clamp must see every input the provider will serialize on the + // wire — system prompt and tool schemas included — or a near-full + // context can still slip past the limit. + const effectiveProvider = applyCompletionBudget({ + provider: this.provider, + budget: this.completionBudget, + capability: this.capability, + messages: params.messages, + systemPrompt: this.systemPrompt, + tools: params.tools, + }); + + const result = await this.generate( + effectiveProvider, + this.systemPrompt, + [...params.tools], + [...params.messages], + callbacks, + generateOptions(params), + ); + + // Replay merged content parts onto loop per-block callbacks after the + // stream drained. This preserves WAL append order and stops partial + // parts from landing if the upstream stream aborts mid-message. + if (params.onTextPart !== undefined || params.onThinkPart !== undefined) { + for (const part of result.message.content) { + if (part.type === 'text' && params.onTextPart !== undefined) { + await params.onTextPart(part); + } else if (part.type === 'think' && params.onThinkPart !== undefined) { + await params.onThinkPart(part); + } + } + } + + const response: LLMChatResponse = { + toolCalls: [...result.message.toolCalls], + ...(result.finishReason !== null ? { providerFinishReason: result.finishReason } : {}), + ...(result.rawFinishReason !== null ? { rawFinishReason: result.rawFinishReason } : {}), + usage: result.usage ?? emptyUsage(), + }; + + return response; + } + + isRetryableError(error: unknown): boolean { + if (error instanceof APIConnectionError || error instanceof APITimeoutError) { + return true; + } + if (error instanceof APIEmptyResponseError) { + return true; + } + return error instanceof APIStatusError && [429, 500, 502, 503, 504].includes(error.statusCode); + } +} + +function generateOptions(params: LLMChatParams): GenerateOptionsWithRequestLog { + const options: GenerateOptionsWithRequestLog = { + signal: params.signal, + }; + if (params.requestLogContext !== undefined) { + return { + ...options, + [GENERATE_REQUEST_LOG_CONTEXT]: params.requestLogContext, + }; + } + return options; +} + +function buildKosongCallbacks(params: LLMChatParams): GenerateCallbacks { + type ToolCallIdentity = { readonly toolCallId: string; readonly name: string }; + type BufferedToolCallDelta = { readonly argumentsPart?: string | undefined }; + + const toolCallIdentities = new Map(); + const pendingIndexedToolCallDeltas = new Map(); + let lastToolCallIdentity: ToolCallIdentity | undefined; + + const emitToolCallDelta = (delta: { + toolCallId: string; + name: string; + argumentsPart?: string; + }): void => { + if (params.onToolCallDelta === undefined) return; + params.onToolCallDelta(delta); + }; + + return { + onMessagePart: (part: StreamedMessagePart) => { + if (part.type === 'text') { + if (params.onTextDelta === undefined) return; + params.onTextDelta(part.text); + return; + } + if (part.type === 'think') { + if (params.onThinkDelta === undefined) return; + params.onThinkDelta(part.think); + return; + } + if (part.type === 'function') { + const identity = { toolCallId: part.id, name: part.function.name }; + lastToolCallIdentity = identity; + if (part._streamIndex !== undefined) { + toolCallIdentities.set(part._streamIndex, identity); + } + emitToolCallDelta({ + toolCallId: part.id, + name: part.function.name, + ...(part.function.arguments !== null ? { argumentsPart: part.function.arguments } : {}), + }); + if (part._streamIndex !== undefined) { + const pendingDeltas = pendingIndexedToolCallDeltas.get(part._streamIndex); + if (pendingDeltas !== undefined) { + pendingIndexedToolCallDeltas.delete(part._streamIndex); + for (const delta of pendingDeltas) { + emitToolCallDelta({ + toolCallId: identity.toolCallId, + name: identity.name, + ...delta, + }); + } + } + } + return; + } + if (part.type === 'tool_call_part') { + const argumentsPart = part.argumentsPart; + const delta = argumentsPart !== null ? { argumentsPart } : {}; + if (part.index !== undefined) { + const identity = toolCallIdentities.get(part.index); + if (identity === undefined) { + const pendingDeltas = pendingIndexedToolCallDeltas.get(part.index) ?? []; + pendingDeltas.push(delta); + pendingIndexedToolCallDeltas.set(part.index, pendingDeltas); + return; + } + emitToolCallDelta({ + toolCallId: identity.toolCallId, + name: identity.name, + ...delta, + }); + return; + } + const identity = lastToolCallIdentity; + if (identity === undefined) return; + emitToolCallDelta({ + toolCallId: identity.toolCallId, + name: identity.name, + ...delta, + }); + } + }, + }; +} + +export function buildMessagesWithSystem(systemPrompt: string, history: Message[]): Message[] { + return [ + { role: 'system', content: [{ type: 'text', text: systemPrompt }], toolCalls: [] }, + ...history, + ]; +} diff --git a/packages/agent-core/src/agent/turn/tool-dedup.ts b/packages/agent-core/src/agent/turn/tool-dedup.ts new file mode 100644 index 000000000..c360589aa --- /dev/null +++ b/packages/agent-core/src/agent/turn/tool-dedup.ts @@ -0,0 +1,195 @@ +import type { ContentPart } from '@moonshot-ai/kosong'; + +import type { ExecutableToolResult } from '../../loop/types'; + +import { canonicalTelemetryArgs } from './canonical-args'; + +const CROSS_STEP_DEDUP_TRIGGER_COUNT = 7; + +const REMINDER_TEXT = + '\n\n\n' + + 'You are repeating the exact same tool call with identical parameters.' + + ' Please carefully analyze the previous result. If the task is not yet complete,' + + ' try a different method or parameters instead of repeating the same call.' + + '\n'; + +interface Deferred { + readonly promise: Promise; + resolve(value: T): void; +} + +function makeDeferred(): Deferred { + let resolve!: (value: T) => void; + const promise = new Promise((r) => { + resolve = r; + }); + return { promise, resolve }; +} + +function makeKey(toolName: string, args: unknown): string { + return `${toolName} ${canonicalTelemetryArgs(args)}`; +} + +function appendReminder(result: ExecutableToolResult): ExecutableToolResult { + const output = result.output; + let newOutput: string | ContentPart[]; + if (typeof output === 'string') { + newOutput = output + REMINDER_TEXT; + } else { + const arr: ContentPart[] = [...output]; + const last = arr.at(-1); + if (last !== undefined && last.type === 'text') { + arr[arr.length - 1] = { type: 'text', text: last.text + REMINDER_TEXT }; + } else { + arr.push({ type: 'text', text: REMINDER_TEXT }); + } + newOutput = arr; + } + return result.isError === true + ? { ...result, output: newOutput, isError: true } + : { ...result, output: newOutput }; +} + +/** + * Placeholder result returned from `checkSameStep` for a duplicate call. Never + * reaches the model — it is replaced in `finalizeResult` by awaiting the + * original's deferred result. The loop dispatches `tool.result` events using + * the finalized value, so this content is purely internal bookkeeping. + * + * It must be a non-error result so `toolResultStopsTurn` in tool-call.ts does + * not short-circuit the batch on the dup's behalf. + */ +const DEDUP_PLACEHOLDER_RESULT: ExecutableToolResult = { output: '' }; + +/** + * Detects and suppresses repetitive tool calls within a single turn. + * + * Two behaviours are layered: + * - Same-step dedup: a duplicate `(toolName, args)` issued in the same LLM step + * reuses the original call's result instead of executing the tool twice. + * - Cross-step dedup: when the exact same call is repeated for + * `CROSS_STEP_DEDUP_TRIGGER_COUNT` consecutive occurrences (counting across + * steps), the result returned to the model is suffixed with a system reminder + * nudging it to try a different approach. + */ +export class ToolCallDeduplicator { + private stepDeferreds = new Map>(); + private stepCalls: string[] = []; + private originalCallIndex = new Map(); + private syntheticCallIds = new Set(); + /** + * Records the dedup key used at `checkSameStep` time, keyed by `toolCallId`. + * The loop is allowed to rewrite args between `prepareToolExecution` and + * `finalizeToolResult` via `PrepareToolExecutionResult.updatedArgs`, so the + * `(toolName, args)` pair available at finalize may differ from what was + * registered. We pin the key at registration time and look it up by call id + * during finalize. + */ + private callKeyByCallId = new Map(); + private consecutiveKey: string | null = null; + private consecutiveCount = 0; + + beginStep(): void { + for (const deferred of this.stepDeferreds.values()) { + deferred.resolve({ + output: 'Tool call deduplicated but original result was lost', + isError: true, + }); + } + this.stepDeferreds.clear(); + this.stepCalls = []; + this.originalCallIndex.clear(); + this.syntheticCallIds.clear(); + this.callKeyByCallId.clear(); + } + + endStep(): void { + for (const key of this.stepCalls) { + if (key === this.consecutiveKey) { + this.consecutiveCount += 1; + } else { + this.consecutiveKey = key; + this.consecutiveCount = 1; + } + } + } + + /** + * Called from `prepareToolExecution`. If this `(toolName, args)` was already + * seen in the current step, returns a placeholder result so the loop can + * skip executing the tool again; the real result is patched in during + * `finalizeResult`. Returns `null` for the first occurrence so the normal + * execution path proceeds. + * + * This method is intentionally synchronous to avoid deadlocking the prepare + * loop on a deferred that only resolves in the finalize phase. + */ + checkSameStep(toolCallId: string, toolName: string, args: unknown): ExecutableToolResult | null { + const key = makeKey(toolName, args); + const index = this.stepCalls.length; + this.stepCalls.push(key); + this.callKeyByCallId.set(toolCallId, key); + + const existing = this.stepDeferreds.get(key); + if (existing !== undefined) { + this.syntheticCallIds.add(toolCallId); + return DEDUP_PLACEHOLDER_RESULT; + } + this.stepDeferreds.set(key, makeDeferred()); + this.originalCallIndex.set(toolCallId, index); + return null; + } + + /** + * Called from `finalizeToolResult`, in provider order. For first-occurrence + * calls, projects the consecutive streak ending at this call and, if the + * threshold is reached, appends the system reminder, then resolves the + * deferred so subsequent same-step dups can fetch the real result. For + * synthetic duplicates, awaits the original's deferred and returns its + * value, discarding the placeholder. + */ + async finalizeResult( + toolCallId: string, + _toolName: string, + _args: unknown, + result: ExecutableToolResult, + ): Promise { + // Use the key recorded at registration time, NOT a fresh key from the args + // passed here — the loop may have rewritten args via updatedArgs. + const key = this.callKeyByCallId.get(toolCallId); + if (key === undefined) return result; + this.callKeyByCallId.delete(toolCallId); + + if (this.syntheticCallIds.delete(toolCallId)) { + const deferred = this.stepDeferreds.get(key); + if (deferred === undefined) return result; + return deferred.promise; + } + const index = this.originalCallIndex.get(toolCallId); + if (index === undefined) return result; + this.originalCallIndex.delete(toolCallId); + + let lastKey = this.consecutiveKey; + let streak = this.consecutiveCount; + for (let i = 0; i <= index; i += 1) { + const k = this.stepCalls[i]!; + if (k === lastKey) { + streak += 1; + } else { + lastKey = k; + streak = 1; + } + } + + const finalResult = + streak >= CROSS_STEP_DEDUP_TRIGGER_COUNT ? appendReminder(result) : result; + + this.stepDeferreds.get(key)?.resolve(finalResult); + return finalResult; + } +} + +export const __testing = { + CROSS_STEP_DEDUP_TRIGGER_COUNT, + REMINDER_TEXT, +}; diff --git a/packages/agent-core/src/agent/usage/index.ts b/packages/agent-core/src/agent/usage/index.ts new file mode 100644 index 000000000..e978e7d56 --- /dev/null +++ b/packages/agent-core/src/agent/usage/index.ts @@ -0,0 +1,79 @@ +import type { UsageStatus } from '#/rpc'; +import { addUsage, type TokenUsage } from '@moonshot-ai/kosong'; + +import type { Agent } from '..'; + +export type UsageRecordScope = 'session' | 'turn'; + +function copyUsage(usage: TokenUsage): TokenUsage { + return { ...usage }; +} + +export class UsageRecorder { + private readonly byModel: Record = {}; + private currentTurn: TokenUsage | undefined; + + constructor(protected readonly agent?: Agent) {} + + beginTurn(): void { + this.currentTurn = undefined; + } + + endTurn(): void { + this.currentTurn = undefined; + } + + record(model: string, usage: TokenUsage, scope: UsageRecordScope = 'session'): void { + this.agent?.records.logRecord({ + type: 'usage.record', + model, + usage, + usageScope: scope, + }); + const current = this.byModel[model]; + this.byModel[model] = current === undefined ? copyUsage(usage) : addUsage(current, usage); + + if (scope === 'turn') { + this.currentTurn = + this.currentTurn === undefined ? copyUsage(usage) : addUsage(this.currentTurn, usage); + } + this.agent?.emitStatusUpdated(); + } + + data(): UsageStatus { + const byModel = this.byModelSnapshot(); + const hasByModel = Object.keys(byModel).length > 0; + const currentTurn = this.currentTurn; + return { + byModel: hasByModel ? byModel : undefined, + total: hasByModel ? totalUsage(byModel) : undefined, + currentTurn: currentTurn === undefined ? undefined : copyUsage(currentTurn), + }; + } + + status(): UsageStatus | undefined { + const status = this.data(); + if ( + status.byModel === undefined && + status.total === undefined && + status.currentTurn === undefined + ) { + return undefined; + } + return status; + } + + private byModelSnapshot(): Record { + return Object.fromEntries( + Object.entries(this.byModel).map(([model, usage]) => [model, copyUsage(usage)]), + ); + } +} + +function totalUsage(byModel: Record): TokenUsage | undefined { + let total: TokenUsage | undefined; + for (const usage of Object.values(byModel)) { + total = total === undefined ? copyUsage(usage) : addUsage(total, usage); + } + return total; +} diff --git a/packages/agent-core/src/config/index.ts b/packages/agent-core/src/config/index.ts new file mode 100644 index 000000000..e9b03330f --- /dev/null +++ b/packages/agent-core/src/config/index.ts @@ -0,0 +1,5 @@ +export * from './merge'; +export * from './path'; +export * from './resolve'; +export * from './schema'; +export * from './toml'; diff --git a/packages/agent-core/src/config/merge.ts b/packages/agent-core/src/config/merge.ts new file mode 100644 index 000000000..ce4880b96 --- /dev/null +++ b/packages/agent-core/src/config/merge.ts @@ -0,0 +1,62 @@ +import { ErrorCodes, KimiError } from '#/errors'; +import { + KimiConfigPatchSchema, + formatConfigValidationError, + type KimiConfig, + type KimiConfigPatch, + validateConfig, +} from '#/config/schema'; + +export function mergeConfigPatch(config: KimiConfig, patch: KimiConfigPatch): KimiConfig { + const base = validateConfig(config); + const parsedPatch = parsePatch(patch); + const merged = deepMerge(base, parsedPatch); + return validateConfig(merged); +} + +function parsePatch(patch: KimiConfigPatch): KimiConfigPatch { + try { + return stripUndefinedDeep(KimiConfigPatchSchema.parse(patch)) as KimiConfigPatch; + } catch (error) { + throw new KimiError(ErrorCodes.CONFIG_INVALID, `Invalid configuration patch: ${formatConfigValidationError(error)}`, { + cause: error, + }); + } +} + +function deepMerge( + target: Record, + source: Record, +): Record { + const result = { ...target }; + for (const [key, sourceValue] of Object.entries(source)) { + if (sourceValue === undefined) continue; + const targetValue = result[key]; + if (isPlainObject(targetValue) && isPlainObject(sourceValue)) { + result[key] = deepMerge(targetValue, sourceValue); + } else { + result[key] = sourceValue; + } + } + return result; +} + +function stripUndefinedDeep(value: unknown): unknown { + if (Array.isArray(value)) { + return value.map(stripUndefinedDeep); + } + if (!isPlainObject(value)) { + return value; + } + const out: Record = {}; + for (const [key, entryValue] of Object.entries(value)) { + if (entryValue !== undefined) { + out[key] = stripUndefinedDeep(entryValue); + } + } + return out; +} + +function isPlainObject(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} diff --git a/packages/agent-core/src/config/path.ts b/packages/agent-core/src/config/path.ts new file mode 100644 index 000000000..d8d80f45b --- /dev/null +++ b/packages/agent-core/src/config/path.ts @@ -0,0 +1,18 @@ +import { mkdirSync } from 'node:fs'; +import { homedir } from 'node:os'; +import { join } from 'node:path'; + +export function resolveKimiHome(homeDir?: string | undefined): string { + return homeDir ?? process.env['KIMI_CODE_HOME'] ?? join(homedir(), '.kimi-code'); +} + +export function resolveConfigPath(input: { + readonly homeDir?: string | undefined; + readonly configPath?: string | undefined; +}): string { + return input.configPath ?? join(resolveKimiHome(input.homeDir), 'config.toml'); +} + +export function ensureKimiHome(homeDir: string): void { + mkdirSync(homeDir, { recursive: true, mode: 0o700 }); +} diff --git a/packages/agent-core/src/config/resolve.ts b/packages/agent-core/src/config/resolve.ts new file mode 100644 index 000000000..dbe1f4635 --- /dev/null +++ b/packages/agent-core/src/config/resolve.ts @@ -0,0 +1,26 @@ +const TRUE_BOOLEAN_ENV_VALUES = new Set(['1', 'true', 'yes', 'on']); +const FALSE_BOOLEAN_ENV_VALUES = new Set(['0', 'false', 'no', 'off']); + +export interface ResolveConfigValueInput { + readonly env?: Readonly>; + readonly envKey: string; + readonly configValue?: T; + readonly defaultValue: T; + readonly parseEnv: (value: string | undefined) => T | undefined; +} + +export function resolveConfigValue(input: ResolveConfigValueInput): T { + return ( + input.parseEnv(input.env?.[input.envKey]) ?? + input.configValue ?? + input.defaultValue + ); +} + +export function parseBooleanEnv(value: string | undefined): boolean | undefined { + const normalized = value?.trim().toLowerCase(); + if (normalized === undefined || normalized.length === 0) return undefined; + if (TRUE_BOOLEAN_ENV_VALUES.has(normalized)) return true; + if (FALSE_BOOLEAN_ENV_VALUES.has(normalized)) return false; + return undefined; +} diff --git a/packages/agent-core/src/config/schema.ts b/packages/agent-core/src/config/schema.ts new file mode 100644 index 000000000..5c334a564 --- /dev/null +++ b/packages/agent-core/src/config/schema.ts @@ -0,0 +1,279 @@ +import { HOOK_EVENT_TYPES } from '#/agent/hooks/types'; +import { parsePattern } from '#/agent/permission/parse-pattern'; +import { ErrorCodes, KimiError } from '#/errors'; +import { z } from 'zod'; + +export const ProviderTypeSchema = z.enum([ + 'anthropic', + 'openai', + 'kimi', + 'google-genai', + 'openai_responses', + 'vertexai', +]); + +export type ProviderType = z.infer; + +export const OAuthRefSchema = z.object({ + storage: z.enum(['file', 'keyring']), + key: z.string().min(1), +}); + +export type OAuthRef = z.infer; + +const StringRecordSchema = z.record(z.string(), z.string()); + +export const ProviderConfigSchema = z.object({ + type: ProviderTypeSchema, + apiKey: z.string().optional(), + baseUrl: z.string().optional(), + defaultModel: z.string().optional(), + oauth: OAuthRefSchema.optional(), + env: StringRecordSchema.optional(), + customHeaders: StringRecordSchema.optional(), +}); + +export type ProviderConfig = z.infer; + +export const ModelAliasSchema = z.object({ + provider: z.string(), + model: z.string(), + maxContextSize: z.number().int().min(1), + maxOutputSize: z.number().int().min(1).optional(), + capabilities: z.array(z.string()).optional(), + displayName: z.string().optional(), +}); + +export type ModelAlias = z.infer; + +export const ThinkingConfigSchema = z.object({ + mode: z.enum(['auto', 'on', 'off']).optional(), + effort: z.string().optional(), +}); + +export type ThinkingConfig = z.infer; + +export const PermissionModeSchema = z.enum(['yolo', 'manual', 'auto']); + +export const PermissionRuleDecisionSchema = z.enum(['allow', 'deny', 'ask']); +export const PermissionRuleScopeSchema = z.enum([ + 'turn-override', + 'session-runtime', + 'project', + 'user', +]); + +export const PermissionRuleSchema = z.object({ + decision: PermissionRuleDecisionSchema, + scope: PermissionRuleScopeSchema.default('user'), + pattern: z.string().min(1).refine(isValidPermissionPattern, { + message: 'Invalid permission rule pattern', + }), + reason: z.string().optional(), +}); + +export const PermissionConfigSchema = z.object({ + rules: z.array(PermissionRuleSchema).optional(), +}); + +export type PermissionConfig = z.infer; + +export const LoopControlSchema = z.object({ + maxStepsPerTurn: z.number().int().min(1).optional(), + maxRetriesPerStep: z.number().int().min(0).optional(), + maxRalphIterations: z.number().int().min(-1).optional(), + reservedContextSize: z.number().int().min(0).optional(), + compactionTriggerRatio: z.number().min(0.5).max(0.99).optional(), +}); + +export type LoopControl = z.infer; + +export const BackgroundConfigSchema = z.object({ + maxRunningTasks: z.number().int().min(1).optional(), + keepAliveOnExit: z.boolean().optional(), + killGracePeriodMs: z.number().int().min(0).optional(), + agentTaskTimeoutS: z.number().int().min(1).optional(), + printWaitCeilingS: z.number().int().min(1).optional(), +}); + +export type BackgroundConfig = z.infer; + +export const HookDefSchema = z + .object({ + event: z.enum(HOOK_EVENT_TYPES), + matcher: z.string().optional(), + command: z.string().min(1), + timeout: z.number().int().min(1).max(600).optional(), + }) + .strict(); + +export type HookDefConfig = z.infer; + +export const MoonshotServiceConfigSchema = z.object({ + baseUrl: z.string().optional(), + apiKey: z.string().optional(), + oauth: OAuthRefSchema.optional(), + customHeaders: StringRecordSchema.optional(), +}); + +export type MoonshotServiceConfig = z.infer; + +export const ServicesConfigSchema = z.object({ + moonshotSearch: MoonshotServiceConfigSchema.optional(), + moonshotFetch: MoonshotServiceConfigSchema.optional(), +}); + +export type ServicesConfig = z.infer; + +const McpServerCommonFields = { + enabled: z.boolean().optional(), + startupTimeoutMs: z.number().int().min(1).optional(), + toolTimeoutMs: z.number().int().min(1).optional(), + enabledTools: z.array(z.string()).optional(), + disabledTools: z.array(z.string()).optional(), +} as const; + +export const McpServerStdioConfigSchema = z.object({ + transport: z.literal('stdio'), + command: z.string().min(1), + args: z.array(z.string()).optional(), + env: StringRecordSchema.optional(), + cwd: z.string().optional(), + // Reserved for future kaos-backed stdio launchers. `undefined` and `'local'` + // both mean direct child_process spawn for now. + executor: z.enum(['local', 'kaos']).optional(), + ...McpServerCommonFields, +}); + +export type McpServerStdioConfig = z.infer; + +export const McpServerHttpConfigSchema = z.object({ + transport: z.literal('http'), + url: z.string().url(), + headers: StringRecordSchema.optional(), + // Indirect secret reference: the bearer token is looked up from + // `process.env[bearerTokenEnvVar]` at connection time, never committed. + bearerTokenEnvVar: z.string().min(1).optional(), + ...McpServerCommonFields, +}); + +export type McpServerHttpConfig = z.infer; + +const McpServerConfigDiscriminatedSchema = z.discriminatedUnion('transport', [ + McpServerStdioConfigSchema, + McpServerHttpConfigSchema, +]); + +export const McpServerConfigSchema = z.preprocess((raw) => { + if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) return raw; + const obj = raw as Record; + if ('transport' in obj) return obj; + if (typeof obj['command'] === 'string') return { ...obj, transport: 'stdio' }; + if (typeof obj['url'] === 'string') return { ...obj, transport: 'http' }; + return obj; +}, McpServerConfigDiscriminatedSchema); + +export type McpServerConfig = z.infer; + +export const KimiConfigSchema = z.object({ + providers: z.record(z.string(), ProviderConfigSchema).default({}), + defaultProvider: z.string().optional(), + defaultModel: z.string().optional(), + models: z.record(z.string(), ModelAliasSchema).optional(), + thinking: ThinkingConfigSchema.optional(), + planMode: z.boolean().optional(), + yolo: z.boolean().optional(), + defaultThinking: z.boolean().optional(), + defaultPermissionMode: PermissionModeSchema.optional(), + defaultPlanMode: z.boolean().optional(), + permission: PermissionConfigSchema.optional(), + hooks: z.array(HookDefSchema).optional(), + services: ServicesConfigSchema.optional(), + mergeAllAvailableSkills: z.boolean().optional(), + extraSkillDirs: z.array(z.string()).optional(), + loopControl: LoopControlSchema.optional(), + background: BackgroundConfigSchema.optional(), + telemetry: z.boolean().optional(), + raw: z.record(z.string(), z.unknown()).optional(), +}); + +export type KimiConfig = z.infer; + +const ProviderConfigPatchSchema = ProviderConfigSchema.partial(); +const ModelAliasPatchSchema = ModelAliasSchema.partial(); +const ThinkingConfigPatchSchema = ThinkingConfigSchema.partial(); +const PermissionConfigPatchSchema = PermissionConfigSchema.partial(); +const LoopControlPatchSchema = LoopControlSchema.partial(); +const BackgroundConfigPatchSchema = BackgroundConfigSchema.partial(); +const MoonshotServiceConfigPatchSchema = MoonshotServiceConfigSchema.partial(); +const ServicesConfigPatchSchema = z.object({ + moonshotSearch: MoonshotServiceConfigPatchSchema.optional(), + moonshotFetch: MoonshotServiceConfigPatchSchema.optional(), +}); + +export const KimiConfigPatchSchema = z + .object({ + providers: z.record(z.string(), ProviderConfigPatchSchema).optional(), + defaultProvider: z.string().optional(), + defaultModel: z.string().optional(), + models: z.record(z.string(), ModelAliasPatchSchema).optional(), + thinking: ThinkingConfigPatchSchema.optional(), + planMode: z.boolean().optional(), + yolo: z.boolean().optional(), + defaultThinking: z.boolean().optional(), + defaultPermissionMode: PermissionModeSchema.optional(), + defaultPlanMode: z.boolean().optional(), + permission: PermissionConfigPatchSchema.optional(), + hooks: z.array(HookDefSchema).optional(), + services: ServicesConfigPatchSchema.optional(), + mergeAllAvailableSkills: z.boolean().optional(), + extraSkillDirs: z.array(z.string()).optional(), + loopControl: LoopControlPatchSchema.optional(), + background: BackgroundConfigPatchSchema.optional(), + telemetry: z.boolean().optional(), + }) + .strict(); + +export type KimiConfigPatch = z.infer; + +export function getDefaultConfig(): KimiConfig { + return { + providers: {}, + }; +} + +export function validateConfig(config: unknown): KimiConfig { + try { + return KimiConfigSchema.parse(config); + } catch (error) { + throw new KimiError(ErrorCodes.CONFIG_INVALID, `Invalid configuration: ${formatConfigValidationError(error)}`, { + cause: error, + }); + } +} + +export function formatConfigValidationError(error: unknown): string { + const missingModelContextSize = missingModelContextSizeMessage(error); + if (missingModelContextSize !== undefined) return missingModelContextSize; + return error instanceof Error ? error.message : String(error); +} + +function missingModelContextSizeMessage(error: unknown): string | undefined { + if (!(error instanceof z.ZodError)) return undefined; + for (const issue of error.issues) { + const [section, modelName, field] = issue.path; + if (section === 'models' && typeof modelName === 'string' && field === 'maxContextSize') { + return `Model "${modelName}" must define a positive max_context_size in config.toml.`; + } + } + return undefined; +} + +function isValidPermissionPattern(pattern: string): boolean { + try { + parsePattern(pattern); + return true; + } catch { + return false; + } +} diff --git a/packages/agent-core/src/config/toml.ts b/packages/agent-core/src/config/toml.ts new file mode 100644 index 000000000..a049c1de1 --- /dev/null +++ b/packages/agent-core/src/config/toml.ts @@ -0,0 +1,508 @@ +import { existsSync, readFileSync } from 'node:fs'; +import { mkdir, open } from 'node:fs/promises'; +import { dirname } from 'node:path'; + +import { ErrorCodes, KimiError } from '#/errors'; +import { + KimiConfigSchema, + formatConfigValidationError, + getDefaultConfig, + type BackgroundConfig, + type HookDefConfig, + type KimiConfig, + type LoopControl, + type ModelAlias, + type MoonshotServiceConfig, + type OAuthRef, + type PermissionConfig, + type ProviderConfig, + type ServicesConfig, + type ThinkingConfig, + validateConfig, +} from '#/config/schema'; +import { atomicWrite } from '#/utils/fs'; +import { parse as parseToml, stringify as stringifyToml } from 'smol-toml'; + +/* ------------------------------------------------------------------ */ +/* Key helpers – reuse generic snake / camel conversion instead of */ +/* maintaining per-section *_KEY_MAP tables. */ +/* ------------------------------------------------------------------ */ + +function snakeToCamel(str: string): string { + return str.replaceAll(/_([a-z])/g, (_, ch: string) => ch.toUpperCase()); +} + +function camelToSnake(str: string): string { + return str.replaceAll(/[A-Z]/g, (ch: string) => `_${ch.toLowerCase()}`); +} + +/* ------------------------------------------------------------------ */ +/* Read / parse */ +/* ------------------------------------------------------------------ */ + +const DEFAULT_CONFIG_FILE_TEXT = `# ~/.kimi-code/config.toml +# Runtime settings for Kimi Code. +# This file starts empty so built-in defaults can apply. +# Login will populate managed Kimi provider and model entries. +`; + +export async function ensureConfigFile(filePath: string): Promise { + await mkdir(dirname(filePath), { recursive: true, mode: 0o700 }); + let handle: Awaited> | undefined; + try { + handle = await open(filePath, 'wx', 0o600); + await handle.writeFile(DEFAULT_CONFIG_FILE_TEXT, 'utf-8'); + } catch (error) { + if (isFileExistsError(error)) return; + throw error; + } finally { + await handle?.close(); + } +} + +export function readConfigFile(filePath: string): KimiConfig { + if (!existsSync(filePath)) { + return getDefaultConfig(); + } + const text = readFileSync(filePath, 'utf-8'); + return parseConfigString(text, filePath); +} + +export function parseConfigString(tomlText: string, filePath = 'config.toml'): KimiConfig { + if (tomlText.trim().length === 0) { + return getDefaultConfig(); + } + + let data: Record; + try { + data = parseToml(tomlText) as Record; + } catch (error) { + throw new KimiError(ErrorCodes.CONFIG_INVALID, `Invalid TOML in ${filePath}: ${error instanceof Error ? error.message : String(error)}`, { + cause: error, + }); + } + + return parseConfigData(data, filePath); +} + +function parseConfigData(data: Record, filePath: string): KimiConfig { + const raw = cloneRecord(data); + const transformed = transformTomlData(data); + transformed['raw'] = raw; + + try { + return KimiConfigSchema.parse(transformed); + } catch (error) { + throw new KimiError(ErrorCodes.CONFIG_INVALID, `Invalid configuration in ${filePath}: ${formatConfigValidationError(error)}`, { + cause: error, + }); + } +} + +export function transformTomlData(data: Record): Record { + const result: Record = {}; + for (const [key, value] of Object.entries(data)) { + const targetKey = snakeToCamel(key); + + if (targetKey === 'providers' && isPlainObject(value)) { + result[targetKey] = transformRecord(value, transformProviderData); + } else if (targetKey === 'models' && isPlainObject(value)) { + result[targetKey] = transformRecord(value, transformModelData); + } else if (targetKey === 'thinking' && isPlainObject(value)) { + result[targetKey] = transformPlainObject(value); + } else if (targetKey === 'permission' && isPlainObject(value)) { + result[targetKey] = transformPermissionData(value); + } else if (targetKey === 'services' && isPlainObject(value)) { + result[targetKey] = transformRecord(value, transformServiceData, snakeToCamel); + } else if (targetKey === 'loopControl' && isPlainObject(value)) { + result[targetKey] = transformLoopControlData(value); + } else if (targetKey === 'background' && isPlainObject(value)) { + result[targetKey] = transformPlainObject(value); + } else if (!isPlainObject(value)) { + result[targetKey] = value; + } + } + return result; +} + +function transformRecord( + value: Record, + transformEntry: (entry: Record) => Record, + transformName: (name: string) => string = (name) => name, +): Record { + const record: Record = {}; + for (const [entryName, entryConfig] of Object.entries(value)) { + record[transformName(entryName)] = isPlainObject(entryConfig) + ? transformEntry(entryConfig) + : entryConfig; + } + return record; +} + +function transformPlainObject(data: Record): Record { + const out: Record = {}; + for (const [key, value] of Object.entries(data)) { + out[snakeToCamel(key)] = value; + } + return out; +} + +function transformProviderData(data: Record): Record { + const out: Record = {}; + for (const [key, value] of Object.entries(data)) { + const targetKey = snakeToCamel(key); + if (targetKey === 'oauth') { + out[targetKey] = isPlainObject(value) ? transformPlainObject(value) : value; + } else if (targetKey === 'env' || targetKey === 'customHeaders') { + out[targetKey] = cloneObjectValue(value); + } else { + out[targetKey] = value; + } + } + return out; +} + +function transformModelData(data: Record): Record { + return transformPlainObject(data); +} + +function transformPermissionData(data: Record): Record { + const raw = transformPlainObject(data); + const out: Record = {}; + + const rules: unknown[] = []; + appendPermissionRules(rules, raw['rules']); + appendPermissionRules(rules, raw['deny'], 'deny'); + appendPermissionRules(rules, raw['allow'], 'allow'); + appendPermissionRules(rules, raw['ask'], 'ask'); + if (rules.length > 0) { + out['rules'] = rules; + } + return out; +} + +function appendPermissionRules( + target: unknown[], + value: unknown, + decision?: 'allow' | 'deny' | 'ask', +): void { + if (value === undefined) return; + const entries = Array.isArray(value) ? value : [value]; + for (const entry of entries) { + target.push(transformPermissionRule(entry, decision)); + } +} + +function transformPermissionRule(value: unknown, decision?: 'allow' | 'deny' | 'ask'): unknown { + if (!isPlainObject(value)) return value; + + const rule = transformPlainObject(value); + const tool = rule['tool']; + const match = rule['match']; + const pattern = rule['pattern']; + const out: Record = {}; + + if (decision !== undefined) { + out['decision'] = decision; + } else { + out['decision'] = rule['decision']; + } + out['scope'] = rule['scope']; + out['reason'] = rule['reason']; + + if (typeof tool === 'string') { + const argPattern = typeof match === 'string' ? match : pattern; + out['pattern'] = typeof argPattern === 'string' ? `${tool}(${argPattern})` : tool; + } else { + out['pattern'] = pattern; + } + + return out; +} + +function transformServiceData(data: Record): Record { + const out: Record = {}; + for (const [key, value] of Object.entries(data)) { + const targetKey = snakeToCamel(key); + if (targetKey === 'oauth') { + out[targetKey] = isPlainObject(value) ? transformPlainObject(value) : value; + } else if (targetKey === 'customHeaders') { + out[targetKey] = cloneObjectValue(value); + } else { + out[targetKey] = value; + } + } + return out; +} + +function transformLoopControlData(data: Record): Record { + const out = transformPlainObject(data); + if (out['maxStepsPerTurn'] === undefined && out['maxStepsPerRun'] !== undefined) { + out['maxStepsPerTurn'] = out['maxStepsPerRun']; + } + delete out['maxStepsPerRun']; + return out; +} + +/* ------------------------------------------------------------------ */ +/* Write / stringify */ +/* ------------------------------------------------------------------ */ + +export async function writeConfigFile(filePath: string, config: KimiConfig): Promise { + const validated = validateConfig(config); + await mkdir(dirname(filePath), { recursive: true, mode: 0o700 }); + await atomicWrite(filePath, `${stringifyToml(configToTomlData(validated))}\n`); +} + +export function configToTomlData(config: KimiConfig): Record { + const out = cloneRecord(config.raw); + + // Strip deprecated fields + delete out['default_yolo']; + delete out['defaultYolo']; + delete out['defaultPermissionMode']; + + // Top-level scalar fields + const scalarFields: (keyof KimiConfig)[] = [ + 'defaultProvider', + 'defaultModel', + 'planMode', + 'yolo', + 'defaultThinking', + 'defaultPermissionMode', + 'defaultPlanMode', + 'mergeAllAvailableSkills', + 'extraSkillDirs', + 'telemetry', + ]; + for (const key of scalarFields) { + setDefined(out, camelToSnake(key), config[key]); + } + + setRecordSection(out, 'providers', config.providers, providerToToml); + setRecordSection(out, 'models', config.models, modelToToml); + setSection(out, 'thinking', config.thinking, thinkingToToml); + setSection(out, 'services', config.services, servicesToToml); + setSection(out, 'loop_control', config.loopControl, loopControlToToml); + setSection(out, 'background', config.background, backgroundToToml); + setSection(out, 'permission', config.permission, permissionToToml); + setHooks(out, config.hooks); + + return out; +} + +function setRecordSection( + out: Record, + snakeKey: string, + value: Record | undefined, + toToml: (v: T, raw: unknown) => Record, +): void { + if (value === undefined) { + delete out[snakeKey]; + return; + } + + const rawSub = cloneRecord(out[snakeKey]); + const converted: Record = {}; + for (const [entryName, entryConfig] of Object.entries(value)) { + converted[entryName] = toToml(entryConfig, rawSub[entryName]); + } + + if (Object.keys(converted).length > 0) { + out[snakeKey] = converted; + } else { + delete out[snakeKey]; + } +} + +function setSection( + out: Record, + snakeKey: string, + value: T | undefined, + toToml: (v: T, raw: unknown) => Record, +): void { + if (value === undefined) { + delete out[snakeKey]; + return; + } + const rawSub = cloneRecord(out[snakeKey]); + const converted = toToml(value, rawSub); + if (Object.keys(converted).length > 0) { + out[snakeKey] = converted; + } else { + delete out[snakeKey]; + } +} + +function providerToToml(provider: ProviderConfig, rawProvider: unknown): Record { + const out = cloneRecord(rawProvider); + for (const [key, value] of Object.entries(provider)) { + if (key === 'oauth' && value !== undefined) { + out[camelToSnake(key)] = oauthToToml(value as OAuthRef); + } else if ((key === 'env' || key === 'customHeaders') && value !== undefined) { + out[camelToSnake(key)] = cloneUnknown(value); + } else { + setDefined(out, camelToSnake(key), value); + } + } + return out; +} + +function modelToToml(model: ModelAlias, rawModel: unknown): Record { + const out = cloneRecord(rawModel); + for (const [key, value] of Object.entries(model)) { + if (key === 'capabilities' && Array.isArray(value)) { + out[camelToSnake(key)] = [...value]; + } else { + setDefined(out, camelToSnake(key), value); + } + } + return out; +} + +function thinkingToToml(thinking: ThinkingConfig, rawThinking: unknown): Record { + const out = cloneRecord(rawThinking); + for (const [key, value] of Object.entries(thinking)) { + setDefined(out, camelToSnake(key), value); + } + return out; +} + +function permissionToToml( + permission: PermissionConfig, + rawPermission: unknown, +): Record { + const out = cloneRecord(rawPermission); + delete out['deny']; + delete out['allow']; + delete out['ask']; + + if (permission.rules !== undefined) { + out['rules'] = permission.rules.map(permissionRuleToToml); + } else { + delete out['rules']; + } + return out; +} + +function permissionRuleToToml( + rule: NonNullable[number], +): Record { + const out: Record = {}; + for (const [key, value] of Object.entries(rule)) { + setDefined(out, camelToSnake(key), value); + } + return out; +} + +function servicesToToml(services: ServicesConfig, rawServices: unknown): Record { + const out = cloneRecord(rawServices); + if (services.moonshotSearch !== undefined) { + out['moonshot_search'] = serviceToToml(services.moonshotSearch); + } else { + delete out['moonshot_search']; + } + if (services.moonshotFetch !== undefined) { + out['moonshot_fetch'] = serviceToToml(services.moonshotFetch); + } else { + delete out['moonshot_fetch']; + } + return out; +} + +function serviceToToml(service: MoonshotServiceConfig): Record { + const out: Record = {}; + for (const [key, value] of Object.entries(service)) { + if (key === 'oauth' && value !== undefined) { + out[camelToSnake(key)] = oauthToToml(value as OAuthRef); + } else if (key === 'customHeaders' && value !== undefined) { + out[camelToSnake(key)] = cloneUnknown(value); + } else { + setDefined(out, camelToSnake(key), value); + } + } + return out; +} + +function loopControlToToml( + loopControl: LoopControl, + rawLoopControl: unknown, +): Record { + const out = cloneRecord(rawLoopControl); + for (const [key, value] of Object.entries(loopControl)) { + setDefined(out, camelToSnake(key), value); + } + return out; +} + +function backgroundToToml( + background: BackgroundConfig, + rawBackground: unknown, +): Record { + const out = cloneRecord(rawBackground); + for (const [key, value] of Object.entries(background)) { + setDefined(out, camelToSnake(key), value); + } + return out; +} + +function setHooks(out: Record, hooks: readonly HookDefConfig[] | undefined): void { + if (hooks === undefined) { + delete out['hooks']; + return; + } + out['hooks'] = hooks.map(hookToToml); +} + +function hookToToml(hook: HookDefConfig): Record { + const out: Record = {}; + for (const [key, value] of Object.entries(hook)) { + setDefined(out, camelToSnake(key), value); + } + return out; +} + +function oauthToToml(oauth: OAuthRef): Record { + const out: Record = {}; + for (const [key, value] of Object.entries(oauth)) { + out[camelToSnake(key)] = value; + } + return out; +} + +/* ------------------------------------------------------------------ */ +/* Utilities */ +/* ------------------------------------------------------------------ */ + +function isPlainObject(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function cloneRecord(value: unknown): Record { + if (!isPlainObject(value)) return {}; + return cloneUnknown(value); +} + +function cloneUnknown(value: T): T { + return JSON.parse(JSON.stringify(value)) as T; +} + +function cloneObjectValue(value: unknown): unknown { + return isPlainObject(value) ? cloneUnknown(value) : value; +} + +function setDefined(target: Record, key: string, value: unknown): void { + if (value !== undefined) { + target[key] = value; + } else { + delete target[key]; + } +} + +function isFileExistsError(error: unknown): boolean { + return ( + typeof error === 'object' && + error !== null && + (error as { code?: unknown }).code === 'EEXIST' + ); +} diff --git a/packages/agent-core/src/errors.ts b/packages/agent-core/src/errors.ts new file mode 100644 index 000000000..5a9fb663a --- /dev/null +++ b/packages/agent-core/src/errors.ts @@ -0,0 +1,5 @@ +// Barrel re-export so #/errors resolves to a single .ts file (the first +// entry in the package imports map). vitest does not resolve cleanly through +// the directory fallback; this thin barrel keeps the alias working uniformly +// across node, tsc, and vitest. Real module lives under ./errors. +export * from './errors/index'; diff --git a/packages/agent-core/src/errors/classes.ts b/packages/agent-core/src/errors/classes.ts new file mode 100644 index 000000000..8a080ea08 --- /dev/null +++ b/packages/agent-core/src/errors/classes.ts @@ -0,0 +1,28 @@ +import type { KimiErrorCode } from './codes'; + +export interface KimiErrorOptions { + /** JSON-serializable structured details. */ + readonly details?: Record; + /** Original error or value. Local-only; never serialized to the wire. */ + readonly cause?: unknown; +} + +/** + * The single Kimi error class. + * + * Discrimination is always by `code`. Cross-process consumers receive + * `KimiErrorPayload` and must branch on `code` rather than class identity. + */ +export class KimiError extends Error { + readonly code: KimiErrorCode; + readonly details?: Record; + override readonly cause?: unknown; + + constructor(code: KimiErrorCode, message: string, options: KimiErrorOptions = {}) { + super(message); + this.name = 'KimiError'; + this.code = code; + this.details = options.details; + this.cause = options.cause; + } +} diff --git a/packages/agent-core/src/errors/codes.ts b/packages/agent-core/src/errors/codes.ts new file mode 100644 index 000000000..2583a8faa --- /dev/null +++ b/packages/agent-core/src/errors/codes.ts @@ -0,0 +1,377 @@ +/** + * Error codes for Kimi Core's public error protocol. + * + * `ErrorCodes` is the source of truth for every code Kimi Core may emit. + * Downstream consumers (SDK, RPC clients, telemetry, agent-facing docs) + * should depend on these string values rather than on class identity. + * + * Codes follow `domain.reason`. Adding a code is a minor change; renaming + * or removing one is a major change. + */ +export const ErrorCodes = { + CONFIG_INVALID: 'config.invalid', + + SESSION_NOT_FOUND: 'session.not_found', + SESSION_ALREADY_EXISTS: 'session.already_exists', + SESSION_ID_INVALID: 'session.id_invalid', + SESSION_ID_REQUIRED: 'session.id_required', + SESSION_ID_EMPTY: 'session.id_empty', + SESSION_TITLE_EMPTY: 'session.title_empty', + SESSION_STATE_NOT_FOUND: 'session.state_not_found', + SESSION_STATE_INVALID: 'session.state_invalid', + SESSION_FORK_ACTIVE_TURN: 'session.fork_active_turn', + SESSION_EXPORT_NOT_FOUND: 'session.export_not_found', + SESSION_EXPORT_MISSING_VERSION: 'session.export_missing_version', + SESSION_CLOSED: 'session.closed', + SESSION_PERMISSION_MODE_INVALID: 'session.permission_mode_invalid', + SESSION_THINKING_EMPTY: 'session.thinking_empty', + SESSION_MODEL_EMPTY: 'session.model_empty', + SESSION_PLAN_MODE_INVALID: 'session.plan_mode_invalid', + SESSION_APPROVAL_HANDLER_ERROR: 'session.approval_handler_error', + SESSION_QUESTION_HANDLER_ERROR: 'session.question_handler_error', + SESSION_INIT_FAILED: 'session.init_failed', + + AGENT_NOT_FOUND: 'agent.not_found', + TURN_AGENT_BUSY: 'turn.agent_busy', + + MODEL_NOT_CONFIGURED: 'model.not_configured', + MODEL_CONFIG_INVALID: 'model.config_invalid', + AUTH_LOGIN_REQUIRED: 'auth.login_required', + + CONTEXT_OVERFLOW: 'context.overflow', + LOOP_MAX_STEPS_EXCEEDED: 'loop.max_steps_exceeded', + PROVIDER_API_ERROR: 'provider.api_error', + PROVIDER_RATE_LIMIT: 'provider.rate_limit', + PROVIDER_AUTH_ERROR: 'provider.auth_error', + PROVIDER_CONNECTION_ERROR: 'provider.connection_error', + + SKILL_NOT_FOUND: 'skill.not_found', + SKILL_TYPE_UNSUPPORTED: 'skill.type_unsupported', + SKILL_NAME_EMPTY: 'skill.name_empty', + + RECORDS_WRITE_FAILED: 'records.write_failed', + COMPACTION_FAILED: 'compaction.failed', + + BACKGROUND_TASK_ID_EMPTY: 'background.task_id_empty', + MCP_SERVER_NOT_FOUND: 'mcp.server_not_found', + MCP_SERVER_DISABLED: 'mcp.server_disabled', + MCP_STARTUP_FAILED: 'mcp.startup_failed', + MCP_TOOL_NAME_COLLISION: 'mcp.tool_name_collision', + + REQUEST_INVALID: 'request.invalid', + REQUEST_WORK_DIR_REQUIRED: 'request.work_dir_required', + REQUEST_PROMPT_INPUT_EMPTY: 'request.prompt_input_empty', + + SHELL_GIT_BASH_NOT_FOUND: 'shell.git_bash_not_found', + + NOT_IMPLEMENTED: 'not_implemented', + INTERNAL: 'internal', +} as const; + +export type KimiErrorCode = (typeof ErrorCodes)[keyof typeof ErrorCodes]; + +export interface KimiErrorInfo { + readonly title: string; + readonly retryable: boolean; + /** + * Whether the code is a stable public contract. `false` reserves the + * right to rename or remove without a major version bump. + */ + readonly public: boolean; + readonly action?: string; +} + +export const KIMI_ERROR_INFO = { + 'config.invalid': { + title: 'Invalid configuration', + retryable: false, + public: true, + action: 'Check config.toml and provider/model settings.', + }, + + 'session.not_found': { + title: 'Session not found', + retryable: false, + public: true, + action: 'Check the session id or list available sessions.', + }, + 'session.already_exists': { + title: 'Session already exists', + retryable: false, + public: true, + action: 'Use a different session id or remove the existing session first.', + }, + 'session.id_invalid': { + title: 'Invalid session id', + retryable: false, + public: true, + action: 'Use a session id without path-traversal characters.', + }, + 'session.id_required': { + title: 'Session id required', + retryable: false, + public: true, + action: 'Provide a session id when calling this method.', + }, + 'session.id_empty': { + title: 'Session id is empty', + retryable: false, + public: true, + action: 'Provide a non-empty session id.', + }, + 'session.title_empty': { + title: 'Session title is empty', + retryable: false, + public: true, + action: 'Provide a non-empty session title.', + }, + 'session.state_not_found': { + title: 'Session state missing', + retryable: false, + public: true, + action: 'The session directory is corrupted or missing state.json.', + }, + 'session.state_invalid': { + title: 'Session state invalid', + retryable: false, + public: true, + action: 'The session state.json is corrupted; remove the session or repair the file.', + }, + 'session.fork_active_turn': { + title: 'Cannot fork session during active turn', + retryable: true, + public: true, + action: 'Wait for the active turn to complete before forking.', + }, + 'session.export_not_found': { + title: 'Session export directory missing', + retryable: false, + public: true, + action: 'The session has not been persisted to disk yet.', + }, + 'session.export_missing_version': { + title: 'Export version is missing', + retryable: false, + public: true, + action: 'Provide a version when exporting the session.', + }, + 'session.closed': { + title: 'Session is closed', + retryable: false, + public: true, + action: 'Create a new session.', + }, + 'session.permission_mode_invalid': { + title: 'Invalid permission mode', + retryable: false, + public: true, + action: 'Use one of: yolo / manual / auto.', + }, + 'session.thinking_empty': { + title: 'Thinking value is empty', + retryable: false, + public: true, + action: 'Provide a non-empty thinking option.', + }, + 'session.model_empty': { + title: 'Model is empty', + retryable: false, + public: true, + action: 'Provide a non-empty model identifier.', + }, + 'session.plan_mode_invalid': { + title: 'Invalid plan mode', + retryable: false, + public: true, + action: 'Provide a boolean plan mode.', + }, + 'session.approval_handler_error': { + title: 'Approval handler threw', + retryable: false, + public: true, + action: 'Inspect the SDK approval handler for an unhandled exception.', + }, + 'session.question_handler_error': { + title: 'Question handler threw', + retryable: false, + public: true, + action: 'Inspect the SDK question handler for an unhandled exception.', + }, + 'session.init_failed': { + title: 'Session init failed', + retryable: false, + public: false, + action: 'Review the init failure details and try again.', + }, + + 'agent.not_found': { + title: 'Agent not found', + retryable: false, + public: true, + action: 'Check the agent id or list available agents.', + }, + 'turn.agent_busy': { + title: 'Agent is busy', + retryable: true, + public: true, + action: 'Wait for the current turn to finish or steer it.', + }, + + 'model.not_configured': { + title: 'No model configured', + retryable: false, + public: true, + action: 'Set a default model in config.toml or via setModel.', + }, + 'model.config_invalid': { + title: 'Invalid model configuration', + retryable: false, + public: true, + action: 'Check the model and provider entries in config.toml.', + }, + 'auth.login_required': { + title: 'Login required', + retryable: false, + public: true, + action: 'Run the login flow for the provider before retrying.', + }, + + 'context.overflow': { + title: 'Context window overflow', + retryable: true, + public: true, + action: 'Compact the conversation or start a new session.', + }, + 'loop.max_steps_exceeded': { + title: 'Turn exceeded max steps', + retryable: false, + public: true, + action: 'Increase loop_control.max_steps_per_turn or split the task.', + }, + 'provider.api_error': { + title: 'Provider API error', + retryable: false, + public: true, + action: 'Inspect details.statusCode / details.requestId; check provider status.', + }, + 'provider.rate_limit': { + title: 'Provider rate limit', + retryable: true, + public: true, + action: 'Retry after a delay or reduce request frequency.', + }, + 'provider.auth_error': { + title: 'Provider authentication error', + retryable: false, + public: true, + action: 'Re-authenticate with the provider.', + }, + 'provider.connection_error': { + title: 'Provider connection error', + retryable: true, + public: true, + action: 'Check network connectivity and retry.', + }, + + 'skill.not_found': { + title: 'Skill not found', + retryable: false, + public: true, + action: 'List available skills via the skill registry.', + }, + 'skill.type_unsupported': { + title: 'Skill type not supported', + retryable: false, + public: true, + action: 'Only inline skills can be activated by the user.', + }, + 'skill.name_empty': { + title: 'Skill name is empty', + retryable: false, + public: true, + action: 'Provide a non-empty skill name.', + }, + + 'records.write_failed': { + title: 'Failed to write records', + retryable: true, + public: true, + action: 'Check disk space and permissions on the session directory.', + }, + 'compaction.failed': { + title: 'Compaction failed', + retryable: false, + public: true, + action: 'Inspect logs and consider increasing compaction limits.', + }, + + 'background.task_id_empty': { + title: 'Background task id is empty', + retryable: false, + public: true, + action: 'Provide a non-empty task id.', + }, + 'mcp.server_not_found': { + title: 'MCP server not found', + retryable: false, + public: true, + action: 'List configured MCP servers and check the requested name.', + }, + 'mcp.server_disabled': { + title: 'MCP server is disabled', + retryable: false, + public: true, + action: 'Enable the MCP server entry in config before reconnecting.', + }, + 'mcp.startup_failed': { + title: 'MCP server startup failed', + retryable: true, + public: true, + action: 'Inspect the MCP server log or call reconnect once the server is healthy.', + }, + 'mcp.tool_name_collision': { + title: 'MCP tool name collision', + retryable: false, + public: true, + action: 'Rename one of the colliding MCP tools or servers so their qualified names are unique.', + }, + + 'request.invalid': { + title: 'Invalid request', + retryable: false, + public: true, + action: 'Check the input shape matches the API contract.', + }, + 'request.work_dir_required': { + title: 'workDir is required', + retryable: false, + public: true, + action: 'Provide workDir in the request payload.', + }, + 'request.prompt_input_empty': { + title: 'Prompt input is empty', + retryable: false, + public: true, + action: 'Provide non-empty prompt input.', + }, + + 'shell.git_bash_not_found': { + title: 'Git Bash not found', + retryable: false, + public: true, + action: 'Install Git for Windows from https://gitforwindows.org/ or set KIMI_SHELL_PATH to a bash.exe.', + }, + + not_implemented: { + title: 'Not implemented', + retryable: false, + public: true, + action: 'This feature is not implemented yet.', + }, + internal: { + title: 'Internal error', + retryable: false, + public: true, + action: 'Inspect logs or report the issue with diagnostics.', + }, +} as const satisfies Record; diff --git a/packages/agent-core/src/errors/index.ts b/packages/agent-core/src/errors/index.ts new file mode 100644 index 000000000..317c7f3c5 --- /dev/null +++ b/packages/agent-core/src/errors/index.ts @@ -0,0 +1,17 @@ +export { + ErrorCodes, + KIMI_ERROR_INFO, + type KimiErrorCode, + type KimiErrorInfo, +} from './codes'; +export { + KimiError, + type KimiErrorOptions, +} from './classes'; +export { + fromKimiErrorPayload, + isKimiError, + makeErrorPayload, + toKimiErrorPayload, + type KimiErrorPayload, +} from './serialize'; diff --git a/packages/agent-core/src/errors/serialize.ts b/packages/agent-core/src/errors/serialize.ts new file mode 100644 index 000000000..efbbbb453 --- /dev/null +++ b/packages/agent-core/src/errors/serialize.ts @@ -0,0 +1,149 @@ +import { + APIConnectionError, + APIStatusError, + APITimeoutError, + ChatProviderError, +} from '@moonshot-ai/kosong'; + +import { KimiError } from './classes'; +import { ErrorCodes, KIMI_ERROR_INFO, type KimiErrorCode } from './codes'; + +/** + * Wire-safe payload of a Kimi error. + * + * The structure passed across process / language boundaries (RPC, events, + * telemetry, SDK wrappers). Class identity does not survive the boundary; + * downstream code must branch on `code` rather than `instanceof`. + * + * `details` is JSON-serialized. `cause` is intentionally absent -- it is + * local-only diagnostic state and must not cross the boundary. + */ +export interface KimiErrorPayload { + readonly code: KimiErrorCode; + readonly message: string; + readonly name?: string; + readonly details?: Record; + readonly retryable: boolean; +} + +/** Type guard for KimiError. */ +export function isKimiError(error: unknown): error is KimiError { + return error instanceof KimiError; +} + +/** + * Build a KimiErrorPayload directly from a code + message (no Error instance + * needed). Use this for synthetic error events that are signaled, not thrown + * -- e.g. "turn busy" or "compaction failed". `retryable` is filled from + * KIMI_ERROR_INFO so callers cannot drift out of sync with the registry. + */ +export function makeErrorPayload( + code: KimiErrorCode, + message: string, + options?: { readonly details?: Record; readonly name?: string }, +): KimiErrorPayload { + return { + code, + message, + name: options?.name, + details: options?.details, + retryable: KIMI_ERROR_INFO[code].retryable, + }; +} + +/** + * Normalize any value into a KimiErrorPayload. + * + * Recognized errors: + * - `KimiError`: passthrough. + * - `APIStatusError`: 429 -> rate_limit, 401 -> auth_error, otherwise -> api_error. + * - `APIConnectionError` / `APITimeoutError`: connection_error. + * - `ChatProviderError`: api_error. + * - Heuristic "Model not set" / "Provider not set" messages: model.not_configured. + * + * Anything else collapses to `internal`. We never echo `cause` or stack on + * the wire. + */ +export function toKimiErrorPayload(error: unknown): KimiErrorPayload { + if (isKimiError(error)) { + return { + code: error.code, + message: error.message, + name: error.name, + details: error.details, + retryable: KIMI_ERROR_INFO[error.code].retryable, + }; + } + + if (error instanceof APIStatusError) { + const code: KimiErrorCode = + error.statusCode === 429 + ? ErrorCodes.PROVIDER_RATE_LIMIT + : error.statusCode === 401 + ? ErrorCodes.PROVIDER_AUTH_ERROR + : ErrorCodes.PROVIDER_API_ERROR; + return { + code, + message: error.message, + name: error.name, + details: { + statusCode: error.statusCode, + requestId: error.requestId, + }, + retryable: KIMI_ERROR_INFO[code].retryable, + }; + } + + if (error instanceof APIConnectionError || error instanceof APITimeoutError) { + return { + code: ErrorCodes.PROVIDER_CONNECTION_ERROR, + message: error.message, + name: error.name, + retryable: KIMI_ERROR_INFO[ErrorCodes.PROVIDER_CONNECTION_ERROR].retryable, + }; + } + + if (error instanceof ChatProviderError) { + return { + code: ErrorCodes.PROVIDER_API_ERROR, + message: error.message, + name: error.name, + retryable: KIMI_ERROR_INFO[ErrorCodes.PROVIDER_API_ERROR].retryable, + }; + } + + if (error instanceof Error) { + if (error.message === 'Model not set' || error.message === 'Provider not set') { + return { + code: ErrorCodes.MODEL_NOT_CONFIGURED, + message: error.message, + name: error.name, + retryable: KIMI_ERROR_INFO[ErrorCodes.MODEL_NOT_CONFIGURED].retryable, + }; + } + + return { + code: ErrorCodes.INTERNAL, + message: error.message, + name: error.name, + retryable: KIMI_ERROR_INFO[ErrorCodes.INTERNAL].retryable, + }; + } + + return { + code: ErrorCodes.INTERNAL, + message: String(error), + retryable: KIMI_ERROR_INFO[ErrorCodes.INTERNAL].retryable, + }; +} + +/** + * Rehydrate a KimiErrorPayload into a KimiError. Used by SDK boundary code + * receiving errors over RPC to re-surface them with a real class so + * in-process consumers can still use `instanceof`. + */ +export function fromKimiErrorPayload(payload: KimiErrorPayload): KimiError { + return new KimiError(payload.code, payload.message, { + details: payload.details, + }); +} diff --git a/packages/agent-core/src/index.ts b/packages/agent-core/src/index.ts new file mode 100644 index 000000000..5e972d975 --- /dev/null +++ b/packages/agent-core/src/index.ts @@ -0,0 +1,45 @@ +export * from './agent'; +export * from './session'; +export * from './rpc'; +export * from './config'; +export * from './session/export'; +export * from './telemetry'; +export * from './errors'; +export { + flushDiagnosticLogs, + getRootLogger, + log, + redact, + resolveGlobalLogPath, +} from './logging/logger'; +export { resolveLoggingConfig } from './logging/resolve-config'; +export type { ResolveLoggingInput } from './logging/resolve-config'; +export type { + LogContext, + LogEntry, + LogLevel, + LogPayload, + Logger, + LoggingConfig, + RootLogger, + SessionAttachInput, + SessionLogHandle, +} from './logging/types'; +export { USER_PROMPT_ORIGIN } from './agent/context'; +export type { + AgentContextData, + ContextMessage, + PromptOrigin, + UserPromptOrigin, +} from './agent/context'; +export type { + BackgroundLifecycleEvent, + BackgroundTaskInfo, + BackgroundTaskKind, + BackgroundTaskStatus, +} from './tools/background/manager'; +export type { RuntimeConfig } from './runtime-types'; +export type { + BearerTokenProvider, + OAuthTokenProviderResolver, +} from './providers/runtime-provider'; diff --git a/packages/agent-core/src/logging/formatter.ts b/packages/agent-core/src/logging/formatter.ts new file mode 100644 index 000000000..df37591a5 --- /dev/null +++ b/packages/agent-core/src/logging/formatter.ts @@ -0,0 +1,200 @@ +import type { LogContext, LogEntry } from './types'; + +export const MSG_MAX_CHARS = 200; +export const CTX_VALUE_MAX_CHARS = 2048; +export const STACK_MAX_BYTES = 2048; +export const ENTRY_MAX_BYTES = 4096; +export const REDACT_MAX_DEPTH = 10; + +const REDACTED_KEYS: ReadonlySet = new Set([ + 'authorization', + 'apikey', + 'token', + 'refreshtoken', + 'accesstoken', + 'idtoken', + 'password', + 'secret', + 'clientsecret', + 'apisecret', + 'cookie', + 'setcookie', + 'bearer', +]); + +const SAFE_KEY_RE = /^[\w.-]+$/; +const ELLIPSIS = '…'; +const TRUNCATED_TAIL = ` …truncated`; +const REDACTED = '[REDACTED]'; +const RAW_SECRET_PATTERNS: readonly RegExp[] = [ + /\b(authorization\s*[:=]\s*bearer\s+)[^\s"'`]+/gi, + /\b((?:api[_-]?key|access[_-]?token|refresh[_-]?token|id[_-]?token|token|password|secret)\s*[:=]\s*)[^\s"'`]+/gi, + /\b(cookie\s*[:=]\s*)[^\r\n]+/gi, +]; + +const LEVEL_LABEL: Record, string> = { + error: 'ERROR', + warn: 'WARN ', + info: 'INFO ', + debug: 'DEBUG', +}; + +const ANSI_LEVEL: Record, string> = { + error: '\u001B[31m', + warn: '\u001B[33m', + info: '\u001B[36m', + debug: '\u001B[90m', +}; +const ANSI_RESET = '\u001B[0m'; + +function normalizeKey(key: string): string { + return key.toLowerCase().replaceAll(/[_\-.]/g, ''); +} + +export function redactCtx(ctx: LogContext): LogContext { + const seen = new WeakSet(); + const walk = (value: unknown, depth: number): unknown => { + if (depth > REDACT_MAX_DEPTH) return '[REDACTED:depth]'; + if (value === null || typeof value !== 'object') return value; + if (seen.has(value)) return '[REDACTED:cycle]'; + seen.add(value); + if (Array.isArray(value)) { + return value.map((item) => walk(item, depth + 1)); + } + const out: Record = {}; + for (const [key, raw] of Object.entries(value as Record)) { + out[key] = REDACTED_KEYS.has(normalizeKey(key)) + ? REDACTED + : walk(raw, depth + 1); + } + return out; + }; + return walk(ctx, 0) as LogContext; +} + +export interface FormatOptions { + readonly ansi?: boolean | undefined; + readonly omitContextKeys?: readonly string[]; +} + +export interface FormattedEntry { + readonly text: string; + readonly dropped: boolean; +} + +function truncate(value: string, max: number): string { + return value.length <= max ? value : value.slice(0, max - 1) + ELLIPSIS; +} + +function serializeValue(raw: unknown): string { + if (typeof raw === 'string') return redactString(raw); + if (raw === undefined) return 'undefined'; + if (raw === null) return 'null'; + if ( + typeof raw === 'number' || + typeof raw === 'boolean' || + typeof raw === 'bigint' || + typeof raw === 'symbol' + ) { + return String(raw); + } + try { + const json = JSON.stringify(raw); + if (json !== undefined) return json; + } catch { + // fall through to a stable non-contentful fallback + } + if (typeof raw === 'function') return raw.name === '' ? '[Function]' : `[Function: ${raw.name}]`; + return Object.prototype.toString.call(raw); +} + +function redactString(value: string): string { + let out = value; + for (const pattern of RAW_SECRET_PATTERNS) { + out = out.replace(pattern, `$1${REDACTED}`); + } + return out; +} + +function quote(value: string): string { + return `"${value.replaceAll('\\', '\\\\').replaceAll('"', '\\"').replaceAll('\n', '\\n')}"`; +} + +function formatPair(key: string, raw: unknown): string { + const limited = truncate(serializeValue(raw), CTX_VALUE_MAX_CHARS); + const renderedKey = SAFE_KEY_RE.test(key) ? key : quote(key); + const renderedVal = /[\s="\\]/.test(limited) || limited.length === 0 ? quote(limited) : limited; + return `${renderedKey}=${renderedVal}`; +} + +function clipBytes(text: string, maxBytes: number): string { + if (Buffer.byteLength(text, 'utf-8') <= maxBytes) return text; + // Binary-search the longest prefix that fits. + let lo = 0; + let hi = text.length; + while (lo < hi) { + const mid = (lo + hi + 1) >> 1; + if ( + Buffer.byteLength(text.slice(0, mid), 'utf-8') <= + maxBytes - Buffer.byteLength(TRUNCATED_TAIL, 'utf-8') + ) { + lo = mid; + } else { + hi = mid - 1; + } + } + return text.slice(0, lo) + TRUNCATED_TAIL; +} + +function clipStack(stack: string): string { + if (Buffer.byteLength(stack, 'utf-8') <= STACK_MAX_BYTES) return stack; + return clipBytes(stack, STACK_MAX_BYTES); +} + +function indentStack(stack: string): string { + return stack + .split('\n') + .map((line, i) => (i === 0 ? ` ${line}` : ` ${line.trimStart()}`)) + .join('\n'); +} + +export function formatEntry(entry: LogEntry, options: FormatOptions = {}): FormattedEntry { + const ctx = entry.ctx ? redactCtx(entry.ctx) : undefined; + const omitContextKeys = new Set(options.omitContextKeys ?? []); + const msg = truncate(entry.msg, MSG_MAX_CHARS); + const pairs: string[] = []; + if (ctx) { + for (const [k, v] of Object.entries(ctx)) { + if (omitContextKeys.has(k)) continue; + if (v !== undefined) pairs.push(formatPair(k, v)); + } + } + + const time = new Date(entry.t).toISOString(); + const label = LEVEL_LABEL[entry.level]; + const rendered = pairs.length === 0 + ? `${time} ${label} ${msg}` + : `${time} ${label} ${msg} ${pairs.join(' ')}`; + + let head = Buffer.byteLength(rendered, 'utf-8') > ENTRY_MAX_BYTES + ? clipBytes(rendered, ENTRY_MAX_BYTES) + : rendered; + + if (options.ansi === true) { + head = `${ANSI_LEVEL[entry.level]}${head}${ANSI_RESET}`; + } + + if (entry.error?.stack) { + head = `${head}\n${indentStack(clipStack(redactString(entry.error.stack)))}`; + } else if (entry.error?.message) { + head = `${head}\n Error: ${redactString(entry.error.message)}`; + } + + return { text: head, dropped: false }; +} + +export function extractError(value: Error): { message: string; stack?: string } { + return typeof value.stack === 'string' + ? { message: value.message, stack: value.stack } + : { message: value.message }; +} diff --git a/packages/agent-core/src/logging/index.ts b/packages/agent-core/src/logging/index.ts new file mode 100644 index 000000000..51be3b8a6 --- /dev/null +++ b/packages/agent-core/src/logging/index.ts @@ -0,0 +1,32 @@ +export type { + LogContext, + LogEntry, + LogLevel, + LogPayload, + Logger, + LoggingConfig, + RootLogger, + SessionAttachInput, + SessionLogHandle, +} from './types'; +export { LOG_LEVEL_RANK, levelEnabled } from './types'; + +export { + __resetRootLoggerForTest, + flushDiagnosticLogs, + getRootLogger, + log, + redact, + resolveGlobalLogPath, +} from './logger'; + +export { + CTX_VALUE_MAX_CHARS, + ENTRY_MAX_BYTES, + MSG_MAX_CHARS, + REDACT_MAX_DEPTH, + STACK_MAX_BYTES, + extractError, + formatEntry, + redactCtx, +} from './formatter'; diff --git a/packages/agent-core/src/logging/logger.ts b/packages/agent-core/src/logging/logger.ts new file mode 100644 index 000000000..7112de2ca --- /dev/null +++ b/packages/agent-core/src/logging/logger.ts @@ -0,0 +1,437 @@ +import { join } from 'node:path'; + +import { extractError, formatEntry, redactCtx } from './formatter'; +import { RotatingFileSink } from './sinks'; +import { + type LogContext, + type LogEntry, + type LogLevel, + type LogPayload, + type Logger, + type LoggingConfig, + type RootLogger, + type SessionAttachInput, + type SessionLogHandle, + levelEnabled, +} from './types'; + +const ROOT_SYMBOL = Symbol.for('kimi.logger.root'); +const SESSION_LOG_ID = Symbol('kimi.logger.sessionLogId'); +const LLM_REQUEST_SESSION_LOG_OMITTED_CONTEXT_KEYS = ['sessionId']; +const MAIN_LLM_REQUEST_SESSION_LOG_OMITTED_CONTEXT_KEYS = ['sessionId', 'agentId']; +let nextSessionLogId = 0; + +interface SessionEntry { + readonly logId: string; + readonly sessionId: string; + readonly sessionDir: string; + readonly sink: RotatingFileSink; + state: 'open' | 'closing'; + closePromise: Promise | undefined; + refCount: number; +} + +class RootLoggerImpl implements RootLogger { + private config: LoggingConfig | undefined; + private globalSink: RotatingFileSink | undefined; + private readonly sessions = new Map(); + private readonly sessionsById = new Map>(); + + isConfigured(): boolean { + return this.config !== undefined; + } + + getConfig(): LoggingConfig | undefined { + return this.config; + } + + configure(config: LoggingConfig): Promise { + if (this.config !== undefined && sameLoggingConfig(this.config, config)) { + return Promise.resolve(); + } + const oldGlobalSink = this.globalSink; + this.config = config; + this.globalSink = makeGlobalSink(config); + return oldGlobalSink?.close() ?? Promise.resolve(); + } + + attachSession(input: SessionAttachInput): SessionLogHandle { + const existing = this.findOpenSession(input.sessionId, input.sessionDir); + if (existing !== undefined) { + existing.refCount += 1; + return makeHandle(existing); + } + const config = this.config; + if (config === undefined || config.level === 'off') { + return makeNoopHandle(input.sessionId); + } + const sink = new RotatingFileSink({ + path: join(input.sessionDir, 'logs', 'kimi-code.log'), + maxBytes: config.sessionMaxBytes, + files: config.sessionFiles, + }); + const entry: SessionEntry = { + logId: `session-log-${String(++nextSessionLogId)}`, + sessionId: input.sessionId, + sessionDir: input.sessionDir, + sink, + state: 'open', + closePromise: undefined, + refCount: 1, + }; + this.sessions.set(entry.logId, entry); + this.trackSessionId(entry); + return makeHandle(entry); + } + + async flush(): Promise { + const tasks: Promise[] = []; + if (this.globalSink !== undefined) tasks.push(this.globalSink.flush()); + for (const entry of this.sessions.values()) { + tasks.push(this.flushEntry(entry)); + } + if (tasks.length === 0) return true; + const results = await Promise.all(tasks); + return results.every(Boolean); + } + + async flushGlobal(): Promise { + if (this.globalSink === undefined) return true; + return this.globalSink.flush(); + } + + async flushSession(sessionId: string): Promise { + const entries = this.getEntriesForSessionId(sessionId); + if (entries.length === 0) return true; + const results = await Promise.all(entries.map((entry) => this.flushEntry(entry))); + return results.every(Boolean); + } + + flushSync(): void { + const deadline = Date.now() + 200; + this.globalSink?.flushSync(); + for (const entry of this.sessions.values()) { + if (entry.state !== 'open') continue; + if (Date.now() > deadline) break; + entry.sink.flushSync(); + } + } + + emit(entry: LogEntry): void { + const config = this.config; + if (config === undefined || config.level === 'off') return; + if (!levelEnabled(config.level, entry.level)) return; + + const formatted = formatEntry(entry); + if (formatted.dropped) return; + + this.globalSink?.enqueue(formatted.text + '\n'); + + const session = this.resolveSessionEntry(entry); + if (session !== undefined) { + const omitContextKeys = + entry.msg === 'llm request' ? llmRequestSessionLogOmittedKeys(entry) : undefined; + const sessionFormatted = formatEntry(entry, { + omitContextKeys, + }); + if (!sessionFormatted.dropped) { + session.sink.enqueue(sessionFormatted.text + '\n'); + } + } + } + + detachSession(logId: string): Promise { + const entry = this.sessions.get(logId); + if (entry === undefined) return Promise.resolve(); + if (entry.state === 'closing') return entry.closePromise ?? Promise.resolve(); + entry.refCount -= 1; + if (entry.refCount > 0) return Promise.resolve(); + entry.state = 'closing'; + entry.closePromise = entry.sink.close().finally(() => { + if (this.sessions.get(logId) === entry) { + this.sessions.delete(logId); + this.untrackSessionId(entry); + } + }); + return entry.closePromise; + } + + /** @internal — vitest only. */ + async __shutdownForTest(): Promise { + const closes: Promise[] = []; + if (this.globalSink !== undefined) closes.push(this.globalSink.close()); + for (const entry of this.sessions.values()) { + if (entry.state === 'closing') { + if (entry.closePromise !== undefined) closes.push(entry.closePromise); + } else { + entry.state = 'closing'; + entry.closePromise = entry.sink.close(); + closes.push(entry.closePromise); + } + } + this.sessions.clear(); + this.sessionsById.clear(); + this.globalSink = undefined; + this.config = undefined; + await Promise.allSettled(closes); + } + + private findOpenSession(sessionId: string, sessionDir: string): SessionEntry | undefined { + for (const entry of this.getEntriesForSessionId(sessionId)) { + if (entry.sessionDir === sessionDir && entry.state === 'open') return entry; + } + return undefined; + } + + private trackSessionId(entry: SessionEntry): void { + const ids = this.sessionsById.get(entry.sessionId) ?? new Set(); + ids.add(entry.logId); + this.sessionsById.set(entry.sessionId, ids); + } + + private untrackSessionId(entry: SessionEntry): void { + const ids = this.sessionsById.get(entry.sessionId); + if (ids === undefined) return; + ids.delete(entry.logId); + if (ids.size === 0) this.sessionsById.delete(entry.sessionId); + } + + private getEntriesForSessionId(sessionId: string): SessionEntry[] { + const ids = this.sessionsById.get(sessionId); + if (ids === undefined) return []; + return [...ids] + .map((id) => this.sessions.get(id)) + .filter((entry): entry is SessionEntry => entry !== undefined); + } + + private resolveSessionEntry(entry: LogEntry): SessionEntry | undefined { + if (entry.sessionLogId !== undefined) { + const session = this.sessions.get(entry.sessionLogId); + return session?.state === 'open' ? session : undefined; + } + if (entry.sessionId === undefined) return undefined; + const openEntries = this.getEntriesForSessionId(entry.sessionId).filter( + (item) => item.state === 'open', + ); + return openEntries.length === 1 ? openEntries[0] : undefined; + } + + private async flushEntry(entry: SessionEntry): Promise { + if (entry.state === 'closing') { + await entry.closePromise; + return true; + } + return entry.sink.flush(); + } +} + +function llmRequestSessionLogOmittedKeys(entry: LogEntry): readonly string[] { + return entry.ctx?.['agentId'] === 'main' + ? MAIN_LLM_REQUEST_SESSION_LOG_OMITTED_CONTEXT_KEYS + : LLM_REQUEST_SESSION_LOG_OMITTED_CONTEXT_KEYS; +} + +function makeHandle(entry: SessionEntry): SessionLogHandle { + let closed = false; + return { + logger: log.createChild({ + sessionId: entry.sessionId, + [SESSION_LOG_ID]: entry.logId, + } as LogContext), + async flush() { + if (entry.state === 'closing') { + await entry.closePromise; + } else { + await entry.sink.flush(); + } + }, + async close() { + if (closed) return; + closed = true; + await getRootInternal().detachSession(entry.logId); + }, + }; +} + +function makeNoopHandle(sessionId: string): SessionLogHandle { + return { + logger: log.createChild({ sessionId }), + async flush() {}, + async close() {}, + }; +} + +function getRootInternal(): RootLoggerImpl { + const globalAny = globalThis as Record; + const existing = globalAny[ROOT_SYMBOL]; + if (existing instanceof RootLoggerImpl) return existing; + const fresh = new RootLoggerImpl(); + globalAny[ROOT_SYMBOL] = fresh; + return fresh; +} + +export function getRootLogger(): RootLogger { + return getRootInternal(); +} + +export function flushDiagnosticLogs(): Promise { + return getRootInternal().flush(); +} + +class LoggerImpl implements Logger { + constructor(private readonly boundCtx: LogContext) {} + + error(message: string, payload?: LogPayload): void { + this.emitAt('error', message, payload); + } + warn(message: string, payload?: LogPayload): void { + this.emitAt('warn', message, payload); + } + info(message: string, payload?: LogPayload): void { + this.emitAt('info', message, payload); + } + debug(message: string, payload?: LogPayload): void { + this.emitAt('debug', message, payload); + } + + createChild(ctx: LogContext): Logger { + return new LoggerImpl({ ...this.boundCtx, ...ctx }); + } + + private emitAt( + level: Exclude, + message: string, + payload: LogPayload, + ): void { + const root = getRootInternal(); + if (!root.isConfigured()) return; + try { + const { ctx: payloadCtx, error } = resolvePayload(payload); + // Bound ctx wins so call-site can't overwrite ownership fields. + const ctx = mergeCtx(payloadCtx, this.boundCtx); + const sessionId = ctx?.['sessionId']; + const sessionLogId = (ctx as InternalLogContext | undefined)?.[SESSION_LOG_ID]; + root.emit({ + t: Date.now(), + level, + msg: message, + ctx: stripInternalCtx(ctx), + error, + sessionId: typeof sessionId === 'string' ? sessionId : undefined, + sessionLogId: typeof sessionLogId === 'string' ? sessionLogId : undefined, + }); + } catch { + // Diagnostic logging is best-effort and must never affect main control flow. + } + } +} + +type InternalLogContext = LogContext & { [SESSION_LOG_ID]?: unknown }; + +function stripInternalCtx(ctx: LogContext | undefined): LogContext | undefined { + if (ctx === undefined) return undefined; + if (!(SESSION_LOG_ID in ctx)) return ctx; + const { [SESSION_LOG_ID]: _internal, ...visible } = ctx as InternalLogContext; + return visible; +} + +function makeGlobalSink(config: LoggingConfig): RotatingFileSink | undefined { + if (config.level === 'off') return undefined; + return new RotatingFileSink({ + path: config.globalLogPath, + maxBytes: config.globalMaxBytes, + files: config.globalFiles, + }); +} + +function sameLoggingConfig(a: LoggingConfig, b: LoggingConfig): boolean { + return ( + a.level === b.level && + a.globalLogPath === b.globalLogPath && + a.globalMaxBytes === b.globalMaxBytes && + a.globalFiles === b.globalFiles && + a.sessionMaxBytes === b.sessionMaxBytes && + a.sessionFiles === b.sessionFiles + ); +} + +function resolvePayload( + payload: LogPayload, +): { ctx: LogContext | undefined; error: LogEntry['error'] } { + if (payload === undefined || payload === null) { + return { ctx: undefined, error: undefined }; + } + if (payload instanceof Error) { + return { ctx: undefined, error: extractError(payload) }; + } + if (typeof payload === 'object') { + // bunyan-style: a `{ error: Error }` field is hoisted out, stack extracted. + const obj = payload as Record; + if (obj['error'] instanceof Error) { + const { error: errValue, ...rest } = obj; + return { ctx: rest as LogContext, error: extractError(errValue) }; + } + return { ctx: obj as LogContext, error: undefined }; + } + if ( + typeof payload === 'string' || + typeof payload === 'number' || + typeof payload === 'boolean' || + typeof payload === 'bigint' || + typeof payload === 'symbol' + ) { + return { ctx: { reason: String(payload) }, error: undefined }; + } + if (typeof payload === 'function') { + const reason = payload.name === '' ? '[Function]' : `[Function: ${payload.name}]`; + return { ctx: { reason }, error: undefined }; + } + return { ctx: { reason: Object.prototype.toString.call(payload) }, error: undefined }; +} + +function mergeCtx( + payloadCtx: LogContext | undefined, + boundCtx: LogContext, +): LogContext | undefined { + const boundHasKeys = Object.keys(boundCtx).length > 0; + if (!boundHasKeys) return payloadCtx; + if (payloadCtx === undefined) return { ...boundCtx }; + return { ...payloadCtx, ...boundCtx }; +} + +/** + * Root logger. Import and use directly for events that don't belong to any + * session (CLI startup, harness construction, etc.): + * + * import { log } from 'kimi-code-sdk'; + * log.info('kimi-code starting', { version }); + * + * For events scoped to a session or agent, use the parent's `log` field: + * + * session.log.error('mcp initial load failed', error); + * agent.log.error('turn failed', { turnId, error }); + * + * Late-binding: methods look up the current `RootLogger` on every call, so + * importing `log` at module load (before `KimiHarness` configures the root) + * is safe — calls during the pre-configure window are silent noops. + */ +export const log: Logger = new LoggerImpl({}); + +export function redact(value: T): T { + if (value === null || typeof value !== 'object') return value; + return redactCtx({ value: value as unknown })['value'] as T; +} + +/** @internal — vitest only. */ +export async function __resetRootLoggerForTest(): Promise { + const globalAny = globalThis as Record; + const existing = globalAny[ROOT_SYMBOL]; + if (existing instanceof RootLoggerImpl) { + await existing.__shutdownForTest(); + } + globalAny[ROOT_SYMBOL] = undefined; +} + +export function resolveGlobalLogPath(homeDir: string): string { + return join(homeDir, 'logs', 'kimi-code.log'); +} diff --git a/packages/agent-core/src/logging/resolve-config.ts b/packages/agent-core/src/logging/resolve-config.ts new file mode 100644 index 000000000..6bf66cde0 --- /dev/null +++ b/packages/agent-core/src/logging/resolve-config.ts @@ -0,0 +1,51 @@ +import { resolveGlobalLogPath } from './logger'; +import type { LogLevel, LoggingConfig } from './types'; + +export const DEFAULT_LOG_LEVEL: LogLevel = 'info'; +export const DEFAULT_GLOBAL_MAX_BYTES = 6 * 1024 * 1024; // 6 MB +export const DEFAULT_GLOBAL_FILES = 5; // 6 MB x 5 = 30 MB +export const DEFAULT_SESSION_MAX_BYTES = 5 * 1024 * 1024; // 5 MB +export const DEFAULT_SESSION_FILES = 3; // 5 MB x 3 = 15 MB + +export interface ResolveLoggingInput { + readonly homeDir: string; + readonly env?: NodeJS.ProcessEnv | undefined; +} + +/** + * Build the runtime `LoggingConfig` from env vars + defaults. + * + * v1 deliberately does not read `config.toml [logging]` — the schema is in + * flux and reading it adds a startup-time failure surface. Users who need to + * override the defaults set env vars: + * + * KIMI_LOG_LEVEL=debug + * KIMI_LOG_GLOBAL_MAX_BYTES=... KIMI_LOG_GLOBAL_FILES=... + * KIMI_LOG_SESSION_MAX_BYTES=... KIMI_LOG_SESSION_FILES=... + */ +export function resolveLoggingConfig(input: ResolveLoggingInput): LoggingConfig { + const env = input.env ?? process.env; + return { + level: parseLevel(env['KIMI_LOG_LEVEL']) ?? DEFAULT_LOG_LEVEL, + globalLogPath: resolveGlobalLogPath(input.homeDir), + globalMaxBytes: parsePositiveInt(env['KIMI_LOG_GLOBAL_MAX_BYTES']) ?? DEFAULT_GLOBAL_MAX_BYTES, + globalFiles: parsePositiveInt(env['KIMI_LOG_GLOBAL_FILES']) ?? DEFAULT_GLOBAL_FILES, + sessionMaxBytes: + parsePositiveInt(env['KIMI_LOG_SESSION_MAX_BYTES']) ?? DEFAULT_SESSION_MAX_BYTES, + sessionFiles: parsePositiveInt(env['KIMI_LOG_SESSION_FILES']) ?? DEFAULT_SESSION_FILES, + }; +} + +function parseLevel(value: string | undefined): LogLevel | undefined { + if (value === undefined) return undefined; + const v = value.toLowerCase().trim(); + if (v === 'off' || v === 'error' || v === 'warn' || v === 'info' || v === 'debug') return v; + return undefined; +} + +function parsePositiveInt(value: string | undefined): number | undefined { + if (value === undefined) return undefined; + const n = Number.parseInt(value, 10); + if (!Number.isFinite(n) || n <= 0) return undefined; + return n; +} diff --git a/packages/agent-core/src/logging/sinks.ts b/packages/agent-core/src/logging/sinks.ts new file mode 100644 index 000000000..ed7c07016 --- /dev/null +++ b/packages/agent-core/src/logging/sinks.ts @@ -0,0 +1,224 @@ +import { mkdir, open, rename, stat, unlink } from 'node:fs/promises'; +import { appendFileSync, mkdirSync } from 'node:fs'; +import { dirname } from 'node:path'; + +import { syncDir } from '#/utils/fs'; + +export const PENDING_MAX = 1000; +const STDERR_NOTICE_INTERVAL_MS = 30_000; + +class AsyncSerialQueue { + private tail: Promise = Promise.resolve(); + + run(task: () => Promise): Promise { + const next = this.tail.then(task, task); + this.tail = next.catch(() => {}); + return next; + } +} +export interface Sink { + enqueue(line: string): void; + /** Resolves to false when the pending batch could not be written. */ + flush(): Promise; + close(): Promise; + flushSync(): void; +} + +interface RotatingFileSinkOptions { + readonly path: string; + readonly maxBytes: number; + readonly files: number; +} + +export class RotatingFileSink implements Sink { + private readonly queue = new AsyncSerialQueue(); + private pending: string[] = []; + private dropped = 0; + private closed = false; + private failed = false; + private lastStderrNotice = 0; + private currentBytes = -1; + private directorySynced = false; + + constructor(private readonly options: RotatingFileSinkOptions) {} + + enqueue(line: string): void { + if (this.closed) return; + if (this.pending.length >= PENDING_MAX) { + this.pending.shift(); + this.dropped++; + } + this.pending.push(line); + this.scheduleDrain(); + } + + async flush(): Promise { + return this.queue.run(() => this.drain()); + } + + async close(): Promise { + if (this.closed) return; + this.closed = true; + try { + await this.flush(); + } catch { + // swallow — close must not throw + } + } + + flushSync(): void { + if (this.closed || this.pending.length === 0) return; + try { + mkdirSync(dirname(this.options.path), { recursive: true }); + const body = this.pending.join('') + this.takeDroppedNotice(); + this.pending = []; + appendFileSync(this.options.path, body); + } catch (error) { + this.noteFailure(error); + } + } + + private scheduleDrain(): void { + if (this.closed) return; + queueMicrotask(() => { + if (this.closed || this.pending.length === 0) return; + this.queue.run(() => this.drain()).catch(() => {}); + }); + } + + private async drain(): Promise { + if (this.pending.length === 0) return true; + const droppedLine = this.takeDroppedNotice(); + const lines = droppedLine === '' ? [...this.pending] : [...this.pending, droppedLine]; + this.pending = []; + try { + await mkdir(dirname(this.options.path), { recursive: true }); + if (this.currentBytes < 0) { + this.currentBytes = await this.statSize(this.options.path); + } + await this.appendLines(lines); + + if (!this.directorySynced) { + await syncDir(dirname(this.options.path)); + this.directorySynced = true; + } + + this.failed = false; + return true; + } catch (error) { + this.noteFailure(error); + this.restorePending(lines); + return false; + } + } + + private restorePending(lines: readonly string[]): void { + const restored = [...lines, ...this.pending]; + const overflow = restored.length - PENDING_MAX; + if (overflow <= 0) { + this.pending = restored; + return; + } + this.dropped += overflow; + this.pending = restored.slice(overflow); + } + + private async appendLines(lines: readonly string[]): Promise { + let chunk = ''; + let chunkBytes = 0; + for (const line of lines) { + const lineBytes = Buffer.byteLength(line, 'utf-8'); + if ( + chunkBytes > 0 && + (chunkBytes + lineBytes > this.options.maxBytes || + this.currentBytes + chunkBytes + lineBytes > this.options.maxBytes) + ) { + await this.appendChunk(chunk); + chunk = ''; + chunkBytes = 0; + } + + if ( + chunkBytes === 0 && + this.currentBytes > 0 && + this.currentBytes + lineBytes > this.options.maxBytes + ) { + await this.rotate(); + } + + chunk += line; + chunkBytes += lineBytes; + } + if (chunkBytes > 0) { + await this.appendChunk(chunk); + } + } + + private async appendChunk(chunk: string): Promise { + const fh = await open(this.options.path, 'a'); + try { + await fh.appendFile(chunk, 'utf-8'); + await fh.sync(); + } finally { + await fh.close(); + } + this.currentBytes += Buffer.byteLength(chunk, 'utf-8'); + if (this.currentBytes >= this.options.maxBytes) { + await this.rotate(); + } + } + + private async rotate(): Promise { + const { path, files } = this.options; + for (let i = files - 2; i >= 1; i--) { + const from = `${path}.${i}`; + const to = `${path}.${i + 1}`; + try { + await rename(from, to); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error; + } + } + try { + await rename(path, `${path}.1`); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error; + } + // last archive may be evicted; ensure we don't keep > files + try { + await unlink(`${path}.${files}`); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error; + } + this.currentBytes = 0; + this.directorySynced = false; + } + + private async statSize(p: string): Promise { + try { + const s = await stat(p); + return s.size; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return 0; + throw error; + } + } + + private takeDroppedNotice(): string { + if (this.dropped === 0) return ''; + const line = `... dropped ${this.dropped} entries ...\n`; + this.dropped = 0; + return line; + } + + private noteFailure(error: unknown): void { + this.failed = true; + const now = Date.now(); + if (now - this.lastStderrNotice < STDERR_NOTICE_INTERVAL_MS) return; + this.lastStderrNotice = now; + const code = (error as NodeJS.ErrnoException)?.code ?? 'UNKNOWN'; + try { + process.stderr.write(`[logger] write failed: ${code}\n`); + } catch {} + } +} diff --git a/packages/agent-core/src/logging/types.ts b/packages/agent-core/src/logging/types.ts new file mode 100644 index 000000000..0a97b3959 --- /dev/null +++ b/packages/agent-core/src/logging/types.ts @@ -0,0 +1,91 @@ +export type LogLevel = 'off' | 'error' | 'warn' | 'info' | 'debug'; + +export type LogContext = Record; + +/** + * Second argument to `log.error / warn / info / debug`. + * + * Three usage shapes, detected at runtime: + * - `Error` → stack is extracted onto the entry + * - `LogContext` (object) → merged into entry context; if it contains + * `{ error: Error }`, that field is pulled out + * and its stack extracted (bunyan-style) + * - `unknown` → typically a `catch` binding; treated as an Error if + * it's an Error instance, otherwise stringified into a + * `reason` field + */ +export type LogPayload = unknown; + +export interface Logger { + error(message: string, payload?: LogPayload): void; + warn(message: string, payload?: LogPayload): void; + info(message: string, payload?: LogPayload): void; + debug(message: string, payload?: LogPayload): void; + /** + * Returns a new logger that adds `ctx` to every entry it emits. The bound + * context wins over per-call payload context, so callers can't accidentally + * overwrite ownership fields like `sessionId` / `agentId`: + * + * finalCtx = { ...payloadCtx, ...boundCtx } + * + * Children chain — `parent.createChild({a: 1}).createChild({b: 2})` binds + * both. + */ + createChild(ctx: LogContext): Logger; +} + +export interface LogEntry { + readonly t: number; + readonly level: Exclude; + readonly msg: string; + readonly ctx?: LogContext | undefined; + readonly error?: { readonly message: string; readonly stack?: string } | undefined; + readonly sessionId?: string | undefined; + readonly sessionLogId?: string | undefined; +} + +export interface LoggingConfig { + readonly level: LogLevel; + readonly globalLogPath: string; + readonly globalMaxBytes: number; + readonly globalFiles: number; + readonly sessionMaxBytes: number; + readonly sessionFiles: number; +} + +export interface SessionLogHandle { + readonly logger: Logger; + flush(): Promise; + close(): Promise; +} + +export interface SessionAttachInput { + readonly sessionId: string; + readonly sessionDir: string; +} + +export interface RootLogger { + configure(config: LoggingConfig): Promise; + attachSession(input: SessionAttachInput): SessionLogHandle; + /** False if any sink could not flush its pending batch. */ + flush(): Promise; + /** False if the global sink could not flush; true when there is no global sink. */ + flushGlobal(): Promise; + /** False if the session sink could not flush; true when there is no active sink. */ + flushSession(sessionId: string): Promise; + flushSync(): void; + isConfigured(): boolean; + getConfig(): LoggingConfig | undefined; +} + +export const LOG_LEVEL_RANK: Record = { + off: 0, + error: 1, + warn: 2, + info: 3, + debug: 4, +}; + +export function levelEnabled(threshold: LogLevel, level: Exclude): boolean { + return LOG_LEVEL_RANK[threshold] >= LOG_LEVEL_RANK[level]; +} diff --git a/packages/agent-core/src/loop/README.md b/packages/agent-core/src/loop/README.md new file mode 100644 index 000000000..0b2daf030 --- /dev/null +++ b/packages/agent-core/src/loop/README.md @@ -0,0 +1,57 @@ +# Loop + +`loop` is the stateless agent loop. It does not own sessions, wire +transport, compaction execution, permissions UI, or durable protocol +bridging. Those are host-layer responsibilities. + +## Internal Owners + +- `run-turn.ts` owns turn-level convergence: abort and compaction safe + points, max-step enforcement, usage aggregation, optional continuation after + non-tool stops, and final `TurnResult` mapping. +- `turn-step.ts` owns one provider step: pre/post step hooks, message + construction, the atomic step envelope, LLM call, streaming callback + wiring, and the handoff to the tool-call lifecycle. +- `tool-call.ts` owns the tool-call batch lifecycle. Classification is + pure; preparation dispatches recorded `tool.call` events in provider + order; terminal `tool.result` events are recorded in provider order before + `step.end` seals the step. +- `tool-scheduler.ts` owns stateful tool execution scheduling: + tasks with non-conflicting resource accesses may overlap, while conflicting + tasks are serialized at provider-order boundaries. +- `llm.ts`, `events.ts`, and `types.ts` define the narrow model, + event/transcript, message, and tool surfaces that hosts provide to the loop. + +## Contracts + +- The core loop must not import from host-layer implementations. +- `LLM` is the only source of model metadata, optional capability metadata, + and system prompt. +- `buildMessages` builds the latest model-visible messages per model step. +- `dispatchEvent` is the only event path the loop writes to. The dispatcher + records `LoopRecordedEvent`s, publishes `LoopLiveOnlyEvent`s, and routes + shared events such as `step.begin`, `step.end`, `tool.call`, and + `tool.result` to both transcript and live listeners. +- Live event listener failures are contained by `LoopEventDispatcher` and + must not affect the agent loop. +- Provider usage is recorded immediately after `LLM.chat` returns, not + after tool execution completes. Aborted tool execution must still report + spent LLM usage. +- The transcript step envelope is intentionally partial on provider abort: + `step.begin` may exist without `step.end`. +- Every dispatched `tool.call` must be followed by a matching `tool.result` + unless the step is interrupted before the result dispatch point. + +## Test Boundaries + +The main regression guards live in `test/loop`: + +- `turn-lifecycle.e2e.test.ts` covers turn convergence and usage + aggregation. +- `transcript.e2e.test.ts` covers step ordering and durable transcript + linkage. +- `tool-call.e2e.test.ts` and `hooks.e2e.test.ts` cover tool preparation, + description, result finalization, and hook safety points. +- `abort.e2e.test.ts`, `error-paths.e2e.test.ts`, and `events.e2e.test.ts` + cover abort convergence, error propagation, and live event containment. +- `streaming.e2e.test.ts` covers provider streaming callback wiring. diff --git a/packages/agent-core/src/loop/errors.ts b/packages/agent-core/src/loop/errors.ts new file mode 100644 index 000000000..43f7199d7 --- /dev/null +++ b/packages/agent-core/src/loop/errors.ts @@ -0,0 +1,27 @@ +/** + * Loop-local error helpers. + */ + +import { ErrorCodes, KimiError, isKimiError } from '#/errors'; + +export function createMaxStepsExceededError(maxSteps: number, message?: string): KimiError { + return new KimiError(ErrorCodes.LOOP_MAX_STEPS_EXCEEDED, message ?? `Turn exceeded maxSteps=${maxSteps}`, { + details: { maxSteps }, + }); +} + +export function isMaxStepsExceededError(error: unknown): boolean { + return isKimiError(error) && error.code === ErrorCodes.LOOP_MAX_STEPS_EXCEEDED; +} + +export function isAbortError(err: unknown): boolean { + if (err instanceof Error) { + return err.name === 'AbortError'; + } + return false; +} + +export function errorMessage(err: unknown): string { + if (err instanceof Error) return err.message; + return String(err); +} diff --git a/packages/agent-core/src/loop/events.ts b/packages/agent-core/src/loop/events.ts new file mode 100644 index 000000000..640ee7af4 --- /dev/null +++ b/packages/agent-core/src/loop/events.ts @@ -0,0 +1,182 @@ +import type { FinishReason, TextPart, ThinkPart, TokenUsage } from '@moonshot-ai/kosong'; + +import type { ToolInputDisplay } from '../tools/display'; +import type { ExecutableToolResult, LoopStepStopReason, ToolUpdate } from './types'; + +export type LoopInterruptReason = 'aborted' | 'max_steps' | 'error'; + +export interface LoopStepBeginEvent { + readonly type: 'step.begin'; + readonly uuid: string; + readonly turnId: string; + readonly step: number; +} + +export interface LoopStepEndEvent { + readonly type: 'step.end'; + readonly uuid: string; + readonly turnId: string; + readonly step: number; + readonly usage?: TokenUsage | undefined; + readonly finishReason?: LoopStepStopReason | undefined; + /** + * Provider diagnostics are optional and must not drive loop control. + * Use `finishReason` for normalized behavior. + */ + readonly providerFinishReason?: FinishReason | undefined; + readonly rawFinishReason?: string | undefined; +} + +export interface LoopStepRetryingEvent { + readonly type: 'step.retrying'; + readonly turnId: string; + readonly step: number; + readonly stepUuid: string; + readonly failedAttempt: number; + readonly nextAttempt: number; + readonly maxAttempts: number; + readonly delayMs: number; + readonly errorName: string; + readonly errorMessage: string; + readonly statusCode?: number; +} + +export interface LoopContentPartEvent { + readonly type: 'content.part'; + readonly uuid: string; + readonly turnId: string; + readonly step: number; + readonly stepUuid: string; + readonly part: TextPart | ThinkPart; +} + +export interface LoopToolCallEvent { + readonly type: 'tool.call'; + readonly uuid: string; + readonly turnId: string; + readonly step: number; + readonly stepUuid: string; + readonly toolCallId: string; + readonly name: string; + readonly args: unknown; + readonly description?: string | undefined; + readonly display?: ToolInputDisplay | undefined; +} + +export interface LoopToolResultEvent { + readonly type: 'tool.result'; + readonly parentUuid: string; + readonly toolCallId: string; + readonly result: ExecutableToolResult; +} + +export interface LoopTurnInterruptedEvent { + readonly type: 'turn.interrupted'; + readonly reason: LoopInterruptReason; + readonly attemptedSteps: number; + readonly activeStep?: number | undefined; + readonly message?: string | undefined; +} + +export interface LoopTextDeltaEvent { + readonly type: 'text.delta'; + readonly delta: string; +} + +export interface LoopThinkingDeltaEvent { + readonly type: 'thinking.delta'; + readonly delta: string; +} + +export interface LoopToolCallDeltaEvent { + readonly type: 'tool.call.delta'; + readonly toolCallId: string; + readonly name?: string | undefined; + readonly argumentsPart?: string | undefined; +} + +export interface LoopToolProgressEvent { + readonly type: 'tool.progress'; + readonly toolCallId: string; + readonly update: ToolUpdate; +} + +export type LoopRecordedEvent = + | LoopStepBeginEvent + | LoopStepEndEvent + | LoopContentPartEvent + | LoopToolCallEvent + | LoopToolResultEvent; + +export type LoopLiveOnlyEvent = + | LoopTurnInterruptedEvent + | LoopStepRetryingEvent + | LoopTextDeltaEvent + | LoopThinkingDeltaEvent + | LoopToolCallDeltaEvent + | LoopToolProgressEvent; + +export type LoopEvent = LoopRecordedEvent | LoopLiveOnlyEvent; +export type LoopLiveEventEmitter = (event: LoopEvent) => void; + +export type LoopEventDispatcher = { + (event: LoopRecordedEvent): Promise; + (event: LoopLiveOnlyEvent): void; +}; + +export interface CreateLoopEventDispatcherInput { + readonly appendTranscriptRecord: (record: LoopRecordedEvent) => Promise; + readonly emitLiveEvent?: LoopLiveEventEmitter | undefined; +} + +export function createLoopEventDispatcher( + input: CreateLoopEventDispatcherInput, +): LoopEventDispatcher { + function dispatchEvent(event: LoopRecordedEvent): Promise; + function dispatchEvent(event: LoopLiveOnlyEvent): void; + function dispatchEvent(event: LoopEvent): Promise | void { + if (isRecordedEvent(event)) { + return recordEvent(input, event); + } + safeEmitLive(input.emitLiveEvent, event); + } + return dispatchEvent; +} + +function isRecordedEvent(event: LoopEvent): event is LoopRecordedEvent { + return ( + event.type === 'step.begin' || + event.type === 'step.end' || + event.type === 'content.part' || + event.type === 'tool.call' || + event.type === 'tool.result' + ); +} + +async function recordEvent( + input: CreateLoopEventDispatcherInput, + event: LoopRecordedEvent, +): Promise { + await input.appendTranscriptRecord(event); + safeEmitLive(input.emitLiveEvent, event); +} + +function safeEmitLive(emit: LoopLiveEventEmitter | undefined, event: LoopEvent): void { + if (emit === undefined) return; + let maybePromise: unknown; + try { + maybePromise = (emit as (event: LoopEvent) => unknown)(event); + } catch { + return; + } + if ( + maybePromise !== undefined && + maybePromise !== null && + typeof (maybePromise as { then?: unknown }).then === 'function' && + typeof (maybePromise as { catch?: unknown }).catch === 'function' + ) { + (maybePromise as Promise).catch(() => { + // Live listeners are best-effort; their failures must not affect the turn. + }); + } +} diff --git a/packages/agent-core/src/loop/index.ts b/packages/agent-core/src/loop/index.ts new file mode 100644 index 000000000..c0071df54 --- /dev/null +++ b/packages/agent-core/src/loop/index.ts @@ -0,0 +1,70 @@ +/** + * Public entry point for the stateless agent loop. + * + * Higher-level orchestration may import from this module; this module must not + * import from host-layer implementations. + */ + +export type { + AfterStepHook, + BeforeStepResult, + BeforeStepHook, + LoopHooks, + LoopAfterStepContext, + LoopStepHookContext, + LoopStepStopReason, + LoopStoppedStepContext, + LoopTerminalStepStopReason, + LoopTurnStopReason, + StopReason, + ShouldContinueAfterStopHook, + ShouldContinueAfterStopResult, + LoopMessageBuilder, + ExecutableTool, + ToolExecution, + ToolCall, + ExecutableToolContext, + ToolExecutionHookContext, + PrepareToolExecutionHook, + PrepareToolExecutionResult, + ExecutableToolResult, + FinalizeToolResultContext, + FinalizeToolResultHook, + ToolUpdate, + TurnResult, +} from './types'; + +export { ToolAccesses } from './tool-access'; + +export type { + CreateLoopEventDispatcherInput, + LoopContentPartEvent, + LoopRecordedEvent, + LoopStepBeginEvent, + LoopStepEndEvent, + LoopStepRetryingEvent, + LoopLiveOnlyEvent, + LoopEvent, + LoopInterruptReason, + LoopLiveEventEmitter, + LoopEventDispatcher, + LoopTextDeltaEvent, + LoopThinkingDeltaEvent, + LoopToolCallDeltaEvent, + LoopToolCallEvent, + LoopToolProgressEvent, + LoopToolResultEvent, + LoopTurnInterruptedEvent, +} from './events'; +export { createLoopEventDispatcher } from './events'; + +export type { + LLM, + LLMChatParams, + LLMChatResponse, + LLMRequestLogContext, + ToolCallDelta, +} from './llm'; + +export { runTurn } from './run-turn'; +export type { RunTurnInput } from './run-turn'; diff --git a/packages/agent-core/src/loop/llm.ts b/packages/agent-core/src/loop/llm.ts new file mode 100644 index 000000000..df8314593 --- /dev/null +++ b/packages/agent-core/src/loop/llm.ts @@ -0,0 +1,71 @@ +/** + * LLM contract for the model capability used by the stateless loop. + * + * The immutable `LLM` object owns provider/model metadata, capability metadata, + * and the system prompt. Other host concerns are injected through separate + * surfaces. + */ + +import type { + FinishReason, + Message, + ModelCapability, + TextPart, + ThinkPart, + TokenUsage, + Tool, + ToolCall, +} from '@moonshot-ai/kosong'; + +export interface ToolCallDelta { + readonly toolCallId: string; + readonly name?: string | undefined; + readonly argumentsPart?: string | undefined; +} + +export interface LLMRequestLogContext { + readonly turnId?: string; + readonly step?: number; + readonly stepUuid?: string; + readonly attempt?: number; + readonly maxAttempts?: number; +} + +export interface LLMChatParams { + messages: Message[]; + tools: readonly Tool[]; + signal: AbortSignal; + requestLogContext?: LLMRequestLogContext; + onTextDelta?: ((delta: string) => void) | undefined; + onThinkDelta?: ((delta: string) => void) | undefined; + onToolCallDelta?: ((delta: ToolCallDelta) => void) | undefined; + /** + * Fires once per completed text block. Additive relative to + * `onTextDelta` — deltas still fire chunk-by-chunk for UI streaming. + * Returned promises are awaited by the adapter to preserve transcript append + * order. Durable transcript writes receive completed blocks only. + */ + onTextPart?: ((part: TextPart) => Promise | void) | undefined; + /** + * Fires once per completed thinking block. Additive relative to + * `onThinkDelta` — deltas still fire chunk-by-chunk for UI streaming. + * Returned promises are awaited by the adapter to preserve transcript append + * order. Durable transcript writes receive completed blocks only. + */ + onThinkPart?: ((part: ThinkPart) => Promise | void) | undefined; +} + +export interface LLMChatResponse { + toolCalls: ToolCall[]; + providerFinishReason?: FinishReason | undefined; + rawFinishReason?: string | undefined; + usage: TokenUsage; +} + +export interface LLM { + readonly systemPrompt: string; + readonly modelName: string; + readonly capability?: ModelCapability | undefined; + isRetryableError?(error: unknown): boolean; + chat(params: LLMChatParams): Promise; +} diff --git a/packages/agent-core/src/loop/retry.ts b/packages/agent-core/src/loop/retry.ts new file mode 100644 index 000000000..eba9247bd --- /dev/null +++ b/packages/agent-core/src/loop/retry.ts @@ -0,0 +1,137 @@ +import { sleep } from '@antfu/utils'; +import * as retry from 'retry'; + +import type { Logger } from '#/logging/types'; + +import { abortable } from '../utils/abort'; +import type { LoopEventDispatcher } from './events'; +import { isAbortError } from './errors'; +import type { LLM, LLMChatParams, LLMChatResponse } from './llm'; + +export const DEFAULT_MAX_RETRY_ATTEMPTS = 3; + +const RETRY_MIN_TIMEOUT_MS = 300; +const RETRY_MAX_TIMEOUT_MS = 5000; +const RETRY_FACTOR = 2; + +export interface ChatWithRetryInput { + readonly llm: LLM; + readonly params: LLMChatParams; + readonly dispatchEvent: LoopEventDispatcher; + readonly turnId: string; + readonly currentStep: number; + readonly stepUuid: string; + readonly maxAttempts?: number; + readonly log?: Logger | undefined; +} + +export async function chatWithRetry(input: ChatWithRetryInput): Promise { + const maxAttempts = input.maxAttempts ?? DEFAULT_MAX_RETRY_ATTEMPTS; + + if (input.llm.isRetryableError === undefined || maxAttempts <= 1) { + const effectiveMaxAttempts = Math.max(maxAttempts, 1); + try { + return await input.llm.chat(paramsForAttempt(input, 1, effectiveMaxAttempts)); + } catch (error) { + logRequestFailure(input, error, 1, effectiveMaxAttempts); + throw error; + } + } + + const delays = retryBackoffDelays(maxAttempts); + + for (let attempt = 1; ; attempt += 1) { + try { + return await input.llm.chat(paramsForAttempt(input, attempt, maxAttempts)); + } catch (error) { + if (attempt >= maxAttempts || !input.llm.isRetryableError(error)) { + logRequestFailure(input, error, attempt, maxAttempts); + throw error; + } + + const delayMs = delays[attempt - 1] ?? 0; + input.params.signal.throwIfAborted(); + input.dispatchEvent({ + type: 'step.retrying', + turnId: input.turnId, + step: input.currentStep, + stepUuid: input.stepUuid, + failedAttempt: attempt, + nextAttempt: attempt + 1, + maxAttempts, + delayMs, + ...retryErrorFields(error), + }); + await sleepForRetry(delayMs, input.params.signal); + } + } +} + +function logRequestFailure( + input: ChatWithRetryInput, + error: unknown, + attempt: number, + maxAttempts: number, +): void { + if (isAbortError(error) || input.params.signal.aborted) return; + input.log?.warn('llm request failed', { + turnId: input.turnId, + step: input.currentStep, + attempt, + maxAttempts, + model: input.llm.modelName, + ...retryErrorFields(error), + }); +} + +function paramsForAttempt( + input: ChatWithRetryInput, + attempt: number, + maxAttempts: number, +): LLMChatParams { + return { + ...input.params, + requestLogContext: { + turnId: input.turnId, + step: input.currentStep, + stepUuid: input.stepUuid, + attempt, + maxAttempts, + }, + }; +} + +export function retryBackoffDelays(maxAttempts: number): number[] { + return retry.timeouts({ + retries: Math.max(maxAttempts - 1, 0), + minTimeout: RETRY_MIN_TIMEOUT_MS, + maxTimeout: RETRY_MAX_TIMEOUT_MS, + factor: RETRY_FACTOR, + randomize: true, + }); +} + +export async function sleepForRetry(delayMs: number, signal: AbortSignal): Promise { + signal.throwIfAborted(); + await abortable(sleep(delayMs), signal); +} + +interface RetryErrorFields { + readonly errorName: string; + readonly errorMessage: string; + readonly statusCode?: number; +} + +function retryErrorFields(error: unknown): RetryErrorFields { + return { + errorName: error instanceof Error ? error.name : typeof error, + errorMessage: error instanceof Error ? error.message : String(error), + statusCode: maybeStatusCode(error), + }; +} + +function maybeStatusCode(error: unknown): number | undefined { + if (typeof error !== 'object' || error === null) return undefined; + const statusCode = (error as { statusCode?: unknown }).statusCode; + return typeof statusCode === 'number' ? statusCode : undefined; +} diff --git a/packages/agent-core/src/loop/run-turn.ts b/packages/agent-core/src/loop/run-turn.ts new file mode 100644 index 000000000..92e9e6f32 --- /dev/null +++ b/packages/agent-core/src/loop/run-turn.ts @@ -0,0 +1,141 @@ +/** + * Turn-level loop for a stateless agent run. + * + * Owns convergence across steps: abort checks at loop boundaries, max-step + * enforcement, usage aggregation, optional continuation after non-tool stops, + * and final `TurnResult` mapping. One-step execution lives in `turn-step.ts`. + */ + +import { addUsage, emptyUsage, type TokenUsage } from '@moonshot-ai/kosong'; + +import type { Logger } from '#/logging/types'; + +import { + createMaxStepsExceededError, + errorMessage, + isAbortError, + isMaxStepsExceededError, +} from './errors'; +import type { LoopInterruptReason, LoopEventDispatcher, LoopTurnInterruptedEvent } from './events'; +import type { LLM } from './llm'; +import { executeLoopStep } from './turn-step'; +import type { + ExecutableTool, + LoopHooks, + LoopMessageBuilder, + LoopTerminalStepStopReason, + LoopTurnStopReason, + TurnResult, +} from './types'; + +const DEFAULT_MAX_STEPS = 1000; + +export interface RunTurnInput { + readonly turnId: string; + readonly signal: AbortSignal; + readonly llm: LLM; + readonly buildMessages: LoopMessageBuilder; + readonly dispatchEvent: LoopEventDispatcher; + readonly tools?: readonly ExecutableTool[] | undefined; + readonly hooks?: LoopHooks | undefined; + readonly log?: Logger | undefined; + readonly maxSteps?: number | undefined; + readonly maxRetryAttempts?: number; +} + +export async function runTurn(input: RunTurnInput): Promise { + const { + turnId, + signal, + llm, + buildMessages, + dispatchEvent, + tools, + hooks, + log, + maxSteps = DEFAULT_MAX_STEPS, + maxRetryAttempts, + } = input; + let usage: TokenUsage = emptyUsage(); + let steps = 0; + // Normal exits overwrite this with the completed step's stop reason. + let stopReason: LoopTurnStopReason = 'end_turn'; + let activeStep: number | undefined; + const recordStepUsage = (stepUsage: TokenUsage): void => { + usage = addUsage(usage, stepUsage); + }; + + try { + while (true) { + signal.throwIfAborted(); + + if (steps >= maxSteps) { + throw createMaxStepsExceededError(maxSteps); + } + + steps += 1; + activeStep = steps; + const stepResult = await executeLoopStep({ + turnId, + signal, + buildMessages, + dispatchEvent, + llm, + tools, + hooks, + log, + currentStep: steps, + maxRetryAttempts, + recordUsage: recordStepUsage, + }); + activeStep = undefined; + + if (stepResult.stopReason === 'tool_use') { + continue; + } + + const terminalStopReason: LoopTerminalStepStopReason = stepResult.stopReason; + stopReason = terminalStopReason; + + if ( + !( + await hooks?.shouldContinueAfterStop?.({ + turnId, + stepNumber: steps, + usage: stepResult.usage, + stopReason: terminalStopReason, + signal, + llm, + }) + )?.continue + ) { + break; + } + } + } catch (error) { + if (isAbortError(error) || signal.aborted) { + dispatchEvent(makeInterruptedEvent('aborted', steps, activeStep)); + return { stopReason: 'aborted', steps, usage }; + } + const reason: LoopInterruptReason = isMaxStepsExceededError(error) ? 'max_steps' : 'error'; + dispatchEvent(makeInterruptedEvent(reason, steps, activeStep, errorMessage(error))); + throw error; + } + + return { stopReason, steps, usage }; +} + +function makeInterruptedEvent( + reason: LoopInterruptReason, + attemptedSteps: number, + activeStep: number | undefined, + message?: string | undefined, +): LoopTurnInterruptedEvent { + return { + type: 'turn.interrupted', + reason, + attemptedSteps, + ...(activeStep !== undefined ? { activeStep } : {}), + ...(message !== undefined ? { message } : {}), + }; +} diff --git a/packages/agent-core/src/loop/tool-access.ts b/packages/agent-core/src/loop/tool-access.ts new file mode 100644 index 000000000..f14eecb3e --- /dev/null +++ b/packages/agent-core/src/loop/tool-access.ts @@ -0,0 +1,150 @@ +export type ToolFileAccessOperation = 'read' | 'write' | 'readwrite' | 'search'; + +export type ToolResourceAccess = + | { + readonly kind: 'file'; + readonly operation: ToolFileAccessOperation; + /** + * Canonical file path when the access can be narrowed. Omitted means the + * operation may touch any file in the backend file namespace. + */ + readonly path?: string; + readonly recursive?: boolean; + } + | { + /** + * Arbitrary side effects or resources that cannot be represented as a + * file access. This is intentionally operation-less and globally + * exclusive for concurrency. + */ + readonly kind: 'all'; + }; + +export type ToolAccesses = readonly ToolResourceAccess[]; + +export const ToolAccesses = { + none(): ToolAccesses { + return []; + }, + + all(): ToolAccesses { + return [{ kind: 'all' }]; + }, + + file( + operation: ToolFileAccessOperation, + path?: string, + options: { readonly recursive?: boolean } = {}, + ): ToolAccesses { + const access: { + kind: 'file'; + operation: ToolFileAccessOperation; + path?: string; + recursive?: boolean; + } = { kind: 'file', operation }; + if (path !== undefined) access.path = path; + if (options.recursive !== undefined) access.recursive = options.recursive; + return [access]; + }, + + readFile(path: string): ToolAccesses { + return ToolAccesses.file('read', path); + }, + + readTree(path: string): ToolAccesses { + return ToolAccesses.file('read', path, { recursive: true }); + }, + + readAnyFile(): ToolAccesses { + return ToolAccesses.file('read'); + }, + + writeFile(path: string): ToolAccesses { + return ToolAccesses.file('write', path); + }, + + writeTree(path: string): ToolAccesses { + return ToolAccesses.file('write', path, { recursive: true }); + }, + + writeAnyFile(): ToolAccesses { + return ToolAccesses.file('write'); + }, + + readWriteFile(path: string): ToolAccesses { + return ToolAccesses.file('readwrite', path); + }, + + readWriteTree(path: string): ToolAccesses { + return ToolAccesses.file('readwrite', path, { recursive: true }); + }, + + readWriteAnyFile(): ToolAccesses { + return ToolAccesses.file('readwrite'); + }, + + searchTree(path: string): ToolAccesses { + return ToolAccesses.file('search', path, { recursive: true }); + }, + + searchAnyFile(): ToolAccesses { + return ToolAccesses.file('search'); + }, + + conflict(left: ToolAccesses, right: ToolAccesses): boolean { + return left.some((leftAccess) => + right.some((rightAccess) => resourceAccessesConflict(leftAccess, rightAccess)), + ); + }, +}; + +function resourceAccessesConflict(left: ToolResourceAccess, right: ToolResourceAccess): boolean { + if (left.kind === 'all' || right.kind === 'all') return true; + if (!fileOperationsConflict(left.operation, right.operation)) return false; + return fileAccessesOverlap(left, right); +} + +function fileOperationsConflict( + left: ToolFileAccessOperation, + right: ToolFileAccessOperation, +): boolean { + return fileOperationWrites(left) || fileOperationWrites(right); +} + +function fileOperationWrites(operation: ToolFileAccessOperation): boolean { + switch (operation) { + case 'read': + case 'search': + return false; + case 'write': + case 'readwrite': + return true; + } +} + +function fileAccessesOverlap( + left: Extract, + right: Extract, +): boolean { + if (left.path === undefined || right.path === undefined) return true; + + const leftPath = normalizePath(left.path); + const rightPath = normalizePath(right.path); + if (leftPath === rightPath) return true; + + const leftPrefix = leftPath.endsWith('/') ? leftPath : `${leftPath}/`; + const rightPrefix = rightPath.endsWith('/') ? rightPath : `${rightPath}/`; + return ( + (left.recursive === true && rightPath.startsWith(leftPrefix)) || + (right.recursive === true && leftPath.startsWith(rightPrefix)) + ); +} + +function normalizePath(path: string): string { + const normalized = path.replaceAll('\\', '/').replaceAll(/\/+/g, '/'); + const folded = normalized.toLowerCase(); + if (folded.length > 1 && folded.endsWith('/')) { + return folded.slice(0, -1); + } + return folded; +} diff --git a/packages/agent-core/src/loop/tool-call.ts b/packages/agent-core/src/loop/tool-call.ts new file mode 100644 index 000000000..6981ebb51 --- /dev/null +++ b/packages/agent-core/src/loop/tool-call.ts @@ -0,0 +1,626 @@ +/** + * Tool-call lifecycle for one completed provider response. + * + * This module keeps the provider-order invariant in one place: + * - validate every provider tool call before hooks or events + * - run preparation hooks and compute tool-call display fields in provider order + * - dispatch `tool.call` before execution starts + * - execute tools with non-conflicting resource accesses concurrently + * - serialize tools whose resource accesses conflict + * - dispatch terminal `tool.result` events in provider order + * + * These phases are coupled by transcript ordering and abort handling, so they + * should be reviewed together. + */ + +import type { ContentPart } from '@moonshot-ai/kosong'; + +import type { Logger } from '#/logging/types'; +import { + compileToolArgsValidator, + validateToolArgs, + type JsonType, + type ToolArgsValidator, +} from '../tools/args-validator'; +import { PathSecurityError } from '../tools/policies/path-access'; + +import { errorMessage, isAbortError } from './errors'; +import type { LoopEventDispatcher, LoopToolCallEvent } from './events'; +import type { LLM, LLMChatResponse } from './llm'; +import { ToolAccesses } from './tool-access'; +import { ToolScheduler, type ToolCallTask } from './tool-scheduler'; +import type { + ExecutableTool, + LoopHooks, + ToolCall, + PrepareToolExecutionResult, + ExecutableToolResult, + RunnableToolExecution, + ToolExecution, +} from './types'; + +const GRACE_TIMEOUT_MS = 2_000; +const TOOL_OUTPUT_EMPTY = 'Tool output is empty.'; +const TOOL_OUTPUT_NON_TEXT = 'Tool returned non-text content.'; + +const validators = new WeakMap(); + +export interface ToolCallStepContext { + readonly tools?: readonly ExecutableTool[] | undefined; + readonly hooks?: LoopHooks | undefined; + readonly log?: Logger | undefined; + readonly dispatchEvent: LoopEventDispatcher; + readonly llm: LLM; + readonly signal: AbortSignal; + readonly turnId: string; + readonly currentStep: number; + readonly stepUuid: string; +} + +type PreflightedToolCall = RunnableToolCall | RejectedToolCall; + +interface RunnableToolCall { + readonly kind: 'runnable'; + readonly toolCall: ToolCall; + readonly toolName: string; + readonly tool: ExecutableTool; + readonly args: unknown; +} + +interface RejectedToolCall { + readonly kind: 'rejected'; + readonly toolCall: ToolCall; + readonly toolName: string; + readonly args: unknown; + readonly output: string; +} + +type PrepareToolExecutionDecision = + | { readonly kind: 'allowed'; readonly args: unknown; readonly metadata?: unknown } + | { readonly kind: 'synthetic'; readonly args: unknown; readonly result: ExecutableToolResult } + | { readonly kind: 'blocked'; readonly args: unknown; readonly output: string } + | { readonly kind: 'hookFailed'; readonly args: unknown; readonly output: string }; + +interface PendingToolResult { + readonly toolCall: ToolCall; + readonly toolName: string; + readonly args: unknown; + readonly result: ExecutableToolResult; + readonly stopTurn?: boolean | undefined; +} + +interface PreparedToolCallTask { + readonly task: ToolCallTask; + readonly stopBatchAfterThis?: boolean | undefined; +} + +type ToolCallDisplayFields = Pick; + +export interface ToolCallBatchResult { + readonly stopTurn: boolean; +} + +export async function runToolCallBatch( + step: ToolCallStepContext, + response: LLMChatResponse, +): Promise { + if (response.toolCalls.length === 0) return { stopTurn: false }; + const calls = response.toolCalls.map((toolCall) => preflightToolCall(step.tools, toolCall)); + const scheduler = new ToolScheduler(); + const pendingResults: Array> = []; + let stopTurn = false; + + try { + for (let index = 0; index < calls.length; index += 1) { + const call = calls[index]!; + const prepared = await prepareToolCall(step, call); + pendingResults.push(scheduler.add(prepared.task)); + + if (prepared.stopBatchAfterThis === true) { + stopTurn = true; + for (const skippedCall of calls.slice(index + 1)) { + const skippedTask = await prepareSkippedToolCall(step, skippedCall); + pendingResults.push(scheduler.add(skippedTask)); + } + break; + } + } + + // Tool tasks may finish out of order; terminal results are still emitted in + // provider order. Await all tasks so each recorded `tool.call` gets a + // paired `tool.result`; the caller checks abort before writing `step.end`. + for (const pendingResult of pendingResults) { + const result = await finalizePendingToolResult(step, await pendingResult); + if (result.stopTurn === true) stopTurn = true; + await step.dispatchEvent({ + type: 'tool.result', + parentUuid: result.toolCall.id, + toolCallId: result.toolCall.id, + result: result.result, + }); + } + } finally { + // Preparation or result dispatch can throw after execution has started. + // Always settle spawned tasks before the caller continues so rejected + // execute promises cannot surface as detached unhandled rejections. + await Promise.allSettled(pendingResults); + } + return { stopTurn }; +} + +/** + * Provider-order validation pass. It does not run hooks, spawn tools, or write + * events. Validator compilation may populate the local cache. + */ +function preflightToolCall( + tools: readonly ExecutableTool[] | undefined, + toolCall: ToolCall, +): PreflightedToolCall { + const toolName = toolCall.function.name; + const parsedArgs = parseToolCallArguments(toolCall.function.arguments); + const args = parsedArgs.success ? parsedArgs.data : {}; + const tool = tools?.find((candidate) => candidate.name === toolName); + if (tool === undefined) { + return { + kind: 'rejected', + toolCall, + toolName, + args, + output: `Tool "${toolName}" not found`, + }; + } + if (!parsedArgs.success) { + return { + kind: 'rejected', + toolCall, + toolName, + args, + output: `Invalid args for tool "${toolName}": malformed JSON in arguments: ${parsedArgs.error}`, + }; + } + const validationError = validateExecutableToolArgs(tool, parsedArgs.data); + if (validationError !== null) { + return { + kind: 'rejected', + toolCall, + toolName, + args: parsedArgs.data, + output: `Invalid args for tool "${toolName}": ${validationError}`, + }; + } + return { kind: 'runnable', toolCall, toolName, tool, args: parsedArgs.data }; +} + +function parseToolCallArguments( + raw: string | null, +): + | { readonly success: true; readonly data: unknown } + | { readonly success: false; readonly error: string } { + if (raw === null || raw.length === 0) { + return { success: true, data: {} }; + } + try { + return { success: true, data: JSON.parse(raw) as unknown }; + } catch (error) { + return { success: false, error: errorMessage(error) }; + } +} + +function validateExecutableToolArgs(tool: ExecutableTool, args: unknown): string | null { + let validator = validators.get(tool); + if (validator === undefined) { + try { + validator = compileToolArgsValidator(tool.parameters); + validators.set(tool, validator); + } catch (error) { + return error instanceof Error ? error.message : String(error); + } + } + return validateToolArgs(validator, args as JsonType); +} + +async function prepareToolCall( + step: ToolCallStepContext, + call: PreflightedToolCall, +): Promise { + if (call.kind === 'rejected') { + await dispatchToolCall(step, call, call.args); + return { task: makeResolvedToolCallTask(makeErrorToolResult(call, call.args, call.output)) }; + } + + const decision = await runPrepareToolExecutionHook(step, call); + if (decision.kind === 'blocked') { + await dispatchToolCall(step, call, decision.args); + return { + task: makeResolvedToolCallTask(makeErrorToolResult(call, decision.args, decision.output)), + }; + } + + if (decision.kind === 'hookFailed') { + await dispatchToolCall(step, call, decision.args); + return { + task: makeResolvedToolCallTask(makeErrorToolResult(call, decision.args, decision.output)), + }; + } + + if (decision.kind === 'synthetic') { + await dispatchToolCall(step, call, decision.args); + return { + task: makeResolvedToolCallTask(makeToolResult(call, decision.args, decision.result)), + stopBatchAfterThis: toolResultStopsTurn(decision.result), + }; + } + + const validationError = validateExecutableToolArgs(call.tool, decision.args); + if (validationError !== null) { + await dispatchToolCall(step, call, decision.args); + const output = `Invalid args for tool "${call.toolName}" after prepareToolExecution hook: ${validationError}`; + return { task: makeResolvedToolCallTask(makeErrorToolResult(call, decision.args, output)) }; + } + + const effectiveArgs = decision.args; + let execution: ToolExecution; + try { + execution = call.tool.resolveExecution(effectiveArgs); + } catch (error) { + if (!(error instanceof PathSecurityError)) { + step.log?.warn('tool execution setup failed', { + toolName: call.toolName, + toolCallId: call.toolCall.id, + error, + }); + } + const output = + error instanceof PathSecurityError + ? error.message + : `Tool "${call.toolName}" failed to resolve execution: ${errorMessage(error)}`; + await dispatchToolCall(step, call, effectiveArgs); + return { + task: makeResolvedToolCallTask(makeErrorToolResult(call, effectiveArgs, output)), + }; + } + + const displayFields = toolCallDisplayFieldsFromExecution(execution); + await dispatchToolCall(step, call, effectiveArgs, displayFields); + + if (step.signal.aborted) { + return { + task: makeResolvedToolCallTask( + makeErrorToolResult(call, effectiveArgs, `Tool "${call.toolName}" was aborted`), + ), + }; + } + + if (execution.isError === true) { + return { + task: makeResolvedToolCallTask(makeToolResult(call, effectiveArgs, execution)), + stopBatchAfterThis: execution.stopTurn, + }; + } + + return { + task: { + accesses: execution.accesses ?? ToolAccesses.all(), + start: async () => ({ + result: runRunnableToolCall(step, call, effectiveArgs, decision.metadata, execution), + }), + }, + }; +} + +async function prepareSkippedToolCall( + step: ToolCallStepContext, + call: PreflightedToolCall, +): Promise> { + const output = 'Tool skipped because a previous tool call stopped the turn.'; + await dispatchToolCall(step, call, call.args); + return makeResolvedToolCallTask(makeErrorToolResult(call, call.args, output)); +} + +function makeResolvedToolCallTask(result: PendingToolResult): ToolCallTask { + return { + accesses: ToolAccesses.none(), + start: async () => ({ result: Promise.resolve(result) }), + }; +} + +/** + * Run `prepareToolExecution` in provider order before recording `tool.call`. + * Hook decisions can block a call or replace args before execution starts. + */ +async function runPrepareToolExecutionHook( + step: ToolCallStepContext, + call: RunnableToolCall, +): Promise { + const { hooks, signal, turnId, currentStep, llm } = step; + const { toolCall, args } = call; + + if (hooks?.prepareToolExecution === undefined) { + return { kind: 'allowed', args }; + } + + let hookResult: PrepareToolExecutionResult | undefined; + try { + hookResult = await hooks.prepareToolExecution({ + toolCall, + tool: call.tool, + args, + turnId, + stepNumber: currentStep, + signal, + llm, + }); + } catch (error) { + // If the turn is cancelled while an abort-aware hook is awaited, report the + // call as aborted instead of treating it as a hook failure. + if (isAbortError(error) || signal.aborted) { + return { + kind: 'hookFailed', + args, + output: `Tool "${call.toolName}" was aborted during prepareToolExecution hook`, + }; + } + return { + kind: 'hookFailed', + args, + output: `prepareToolExecution hook failed for "${call.toolName}": ${errorMessage(error)}`, + }; + } + + const effectiveArgs = hookResult?.updatedArgs ?? args; + if (hookResult?.block === true) { + return { + kind: 'blocked', + args: effectiveArgs, + output: hookResult.reason ?? `Tool call "${call.toolName}" was blocked`, + }; + } + + if (hookResult?.syntheticResult !== undefined) { + return { kind: 'synthetic', args: effectiveArgs, result: hookResult.syntheticResult }; + } + + return { kind: 'allowed', args: effectiveArgs, metadata: hookResult?.executionMetadata }; +} + +function toolCallDisplayFieldsFromExecution( + execution: ToolExecution, +): ToolCallDisplayFields | undefined { + if (execution.isError === true) return undefined; + const description = execution.description; + const display = execution.display; + return { + description: description !== undefined && description.length > 0 ? description : undefined, + display, + }; +} + +async function runRunnableToolCall( + step: ToolCallStepContext, + call: RunnableToolCall, + effectiveArgs: unknown, + metadata: unknown, + execution: RunnableToolExecution, +): Promise { + const { signal } = step; + const { toolCall, toolName } = call; + + if (signal.aborted) { + return makeErrorToolResult(call, effectiveArgs, `Tool "${toolName}" was aborted`); + } + + let toolResult: ExecutableToolResult; + try { + toolResult = await executeTool(step, execution, toolCall, toolName, metadata); + } catch (error) { + const aborted = isAbortError(error) || signal.aborted; + if (!aborted) { + step.log?.warn('tool execution failed', { + toolName, + toolCallId: toolCall.id, + error, + }); + } + const output = aborted + ? `Tool "${toolName}" was aborted` + : `Tool "${toolName}" failed: ${errorMessage(error)}`; + return makeErrorToolResult(call, effectiveArgs, output); + } + + return makeToolResult(call, effectiveArgs, toolResult); +} + +async function finalizePendingToolResult( + step: ToolCallStepContext, + pendingResult: PendingToolResult, +): Promise { + const { hooks, signal, turnId, currentStep, llm } = step; + if (hooks?.finalizeToolResult === undefined) { + return { ...pendingResult, result: normalizeToolResult(pendingResult.result) }; + } + + try { + const finalizedResult = await hooks.finalizeToolResult({ + toolCall: pendingResult.toolCall, + args: pendingResult.args, + result: pendingResult.result, + turnId, + stepNumber: currentStep, + signal, + llm, + }); + const effectiveResult = finalizedResult ?? pendingResult.result; + return { + ...pendingResult, + stopTurn: pendingResult.stopTurn === true || toolResultStopsTurn(effectiveResult), + result: normalizeToolResult(effectiveResult), + }; + } catch (error) { + // This is the redaction/truncation boundary. If it fails, do not persist + // the raw tool output; write an error result instead. + const aborted = isAbortError(error) || signal.aborted; + if (!aborted) { + step.log?.warn('finalizeToolResult hook failed', { + toolName: pendingResult.toolName, + toolCallId: pendingResult.toolCall.id, + error, + }); + } + const output = aborted + ? `Tool "${pendingResult.toolName}" aborted during finalizeToolResult hook.` + : `finalizeToolResult hook failed for "${pendingResult.toolName}": ${errorMessage(error)}`; + return { + ...pendingResult, + stopTurn: pendingResult.stopTurn, + result: { output, isError: true }, + }; + } +} + +async function executeTool( + step: ToolCallStepContext, + execution: RunnableToolExecution, + toolCall: ToolCall, + toolName: string, + metadata: unknown, +): Promise { + const { dispatchEvent, signal, turnId } = step; + + signal.throwIfAborted(); + + const executePromise = execution.execute({ + turnId, + toolCallId: toolCall.id, + metadata, + signal, + onUpdate: (update) => { + if (signal.aborted) return; + dispatchEvent({ + type: 'tool.progress', + toolCallId: toolCall.id, + update, + }); + }, + }); + return raceExecuteWithGraceTimeout(executePromise, signal, toolName); +} + +async function raceExecuteWithGraceTimeout( + executePromise: Promise, + signal: AbortSignal, + toolName: string, +): Promise { + let graceTimer: ReturnType | undefined; + let onAbort: (() => void) | undefined; + + const graceSentinel: Promise = new Promise((resolve) => { + const armTimer = (): void => { + graceTimer = setTimeout(() => { + resolve({ + output: `Tool "${toolName}" aborted by grace timeout (${String(GRACE_TIMEOUT_MS)}ms)`, + isError: true, + }); + }, GRACE_TIMEOUT_MS); + }; + if (signal.aborted) { + armTimer(); + } else { + onAbort = armTimer; + signal.addEventListener('abort', onAbort, { once: true }); + } + }); + + try { + // Tools that ignore AbortSignal may never settle. After abort, the grace + // branch lets the turn finish with a synthetic error result. + return await Promise.race([executePromise, graceSentinel]); + } finally { + if (graceTimer !== undefined) clearTimeout(graceTimer); + if (onAbort !== undefined) { + try { + signal.removeEventListener('abort', onAbort); + } catch { + // Some AbortSignal polyfills do not implement removeEventListener. + } + } + } +} + +function isMediaContentPart(part: ContentPart): boolean { + return part.type === 'image_url' || part.type === 'audio_url' || part.type === 'video_url'; +} + +function normalizeToolResult(r: ExecutableToolResult): ExecutableToolResult { + let output: ExecutableToolResult['output']; + if (typeof r.output === 'string') { + output = r.output.length > 0 ? r.output : TOOL_OUTPUT_EMPTY; + } else if (r.output.length === 0) { + output = TOOL_OUTPUT_EMPTY; + } else { + const hasMediaBlock = r.output.some(isMediaContentPart); + if (hasMediaBlock) { + const hasNonEmptyText = r.output.some((c) => c.type === 'text' && c.text.length > 0); + output = hasNonEmptyText + ? r.output + : [{ type: 'text', text: TOOL_OUTPUT_NON_TEXT }, ...r.output]; + } else { + const textJoined = r.output + .filter((c): c is Extract => c.type === 'text') + .map((c) => c.text) + .join(''); + output = textJoined.length > 0 ? textJoined : TOOL_OUTPUT_EMPTY; + } + } + return r.isError === true ? { output, isError: true } : { output }; +} + +function makeToolResult( + call: PreflightedToolCall, + args: unknown, + result: ExecutableToolResult, +): PendingToolResult { + return { + toolCall: call.toolCall, + toolName: call.toolName, + args, + result, + stopTurn: toolResultStopsTurn(result), + }; +} + +function toolResultStopsTurn(result: ExecutableToolResult): boolean { + return result.isError === true && result.stopTurn === true; +} + +function makeErrorToolResult( + call: PreflightedToolCall, + args: unknown, + output: string, +): PendingToolResult { + return makeToolResult(call, args, { output, isError: true }); +} + +/** + * Record `tool.call` in provider order. Reusing the provider/API tool-call id + * keeps transcript linkage on one canonical identity. + */ +async function dispatchToolCall( + step: ToolCallStepContext, + call: PreflightedToolCall, + args: unknown, + displayFields?: ToolCallDisplayFields | undefined, +): Promise { + const { toolCall, toolName } = call; + await step.dispatchEvent({ + type: 'tool.call', + uuid: toolCall.id, + turnId: step.turnId, + step: step.currentStep, + stepUuid: step.stepUuid, + toolCallId: toolCall.id, + name: toolName, + args, + description: displayFields?.description, + display: displayFields?.display, + }); +} diff --git a/packages/agent-core/src/loop/tool-scheduler.ts b/packages/agent-core/src/loop/tool-scheduler.ts new file mode 100644 index 000000000..0976d3fed --- /dev/null +++ b/packages/agent-core/src/loop/tool-scheduler.ts @@ -0,0 +1,100 @@ +/** + * Stateful execution scheduler for tool calls in one model step. + * + * The scheduler owns only execution ordering: + * - tasks with non-conflicting resource accesses may overlap + * - tasks with conflicting resource accesses wait for the conflicting active tasks + * - drained results are handed back in provider order + * + * Validation, hooks, event construction, and result finalization stay in + * `tool-call.ts`. + */ + +import { createControlledPromise, type ControlledPromise } from '@antfu/utils'; + +import { ToolAccesses } from './tool-access'; + +// Scheduler + +export interface ToolCallTask { + readonly accesses: ToolAccesses; + readonly start: () => Promise<{ readonly result: Promise }>; +} + +interface ScheduledToolCallTask extends ToolCallTask { + readonly result: ControlledPromise; +} + +export class ToolScheduler { + private readonly activeTasks: Array> = []; + private queuedTasks: Array> = []; + + add(task: ToolCallTask): Promise { + const result = createControlledPromise(); + void result.catch(() => undefined); + + const scheduledTask: ScheduledToolCallTask = { ...task, result }; + if (this.isBlocked(task, this.queuedTasks)) { + this.queuedTasks.push(scheduledTask); + } else { + this.start(scheduledTask); + } + + return result; + } + + private isBlocked( + task: ToolCallTask, + queuedBefore: readonly ToolCallTask[], + ): boolean { + return ( + this.conflictsWithAny(task, this.activeTasks) || this.conflictsWithAny(task, queuedBefore) + ); + } + + private conflictsWithAny( + task: ToolCallTask, + candidates: readonly ToolCallTask[], + ): boolean { + return candidates.some((candidate) => + ToolAccesses.conflict(task.accesses, candidate.accesses), + ); + } + + private start(task: ScheduledToolCallTask): void { + this.activeTasks.push(task); + let started: Promise<{ readonly result: Promise }>; + try { + started = task.start(); + } catch (error) { + task.result.reject(error); + this.finish(task); + return; + } + + void started + .then(({ result }) => result) + .then(task.result.resolve, task.result.reject) + .finally(() => { + this.finish(task); + }); + } + + private finish(task: ScheduledToolCallTask): void { + const index = this.activeTasks.indexOf(task); + if (index >= 0) this.activeTasks.splice(index, 1); + this.startQueuedTasks(); + } + + private startQueuedTasks(): void { + const stillQueued: Array> = []; + for (const task of this.queuedTasks) { + if (this.isBlocked(task, stillQueued)) { + stillQueued.push(task); + } else { + this.start(task); + } + } + this.queuedTasks = stillQueued; + } +} diff --git a/packages/agent-core/src/loop/turn-step.ts b/packages/agent-core/src/loop/turn-step.ts new file mode 100644 index 000000000..ceceb346a --- /dev/null +++ b/packages/agent-core/src/loop/turn-step.ts @@ -0,0 +1,249 @@ +/** + * Executes one provider step. + * + * A step owns the provider call, atomic transcript envelope, streaming callback + * wiring, tool-call lifecycle, and post-step hooks. Provider usage is recorded + * immediately after `llm.chat` returns so a later abort during tool execution + * does not lose model usage that was already spent. + */ + +import { randomUUID } from 'node:crypto'; + +import type { TokenUsage } from '@moonshot-ai/kosong'; +import type { Logger } from '#/logging/types'; + +import type { LoopEventDispatcher } from './events'; +import type { LLM, LLMChatParams, LLMChatResponse } from './llm'; +import { chatWithRetry } from './retry'; +import { runToolCallBatch, type ToolCallStepContext } from './tool-call'; +import type { ExecutableTool, LoopHooks, LoopMessageBuilder, LoopStepStopReason } from './types'; + +type ChatStreamingCallbacks = Pick< + LLMChatParams, + 'onTextDelta' | 'onThinkDelta' | 'onToolCallDelta' | 'onTextPart' | 'onThinkPart' +>; + +export interface ExecuteLoopStepDeps { + readonly turnId: string; + readonly signal: AbortSignal; + readonly buildMessages: LoopMessageBuilder; + readonly dispatchEvent: LoopEventDispatcher; + readonly llm: LLM; + readonly tools?: readonly ExecutableTool[] | undefined; + readonly hooks?: LoopHooks | undefined; + readonly log?: Logger | undefined; + readonly currentStep: number; + readonly maxRetryAttempts?: number; + readonly recordUsage: (usage: TokenUsage) => void; +} + +export async function executeLoopStep(deps: ExecuteLoopStepDeps): Promise<{ + readonly usage: TokenUsage; + readonly stopReason: LoopStepStopReason; +}> { + const { + turnId, + signal, + buildMessages, + dispatchEvent, + llm, + tools, + hooks, + log, + currentStep, + maxRetryAttempts, + recordUsage, + } = deps; + + if (hooks?.beforeStep !== undefined) { + const beforeStep = await hooks.beforeStep({ + turnId, + stepNumber: currentStep, + signal, + llm, + }); + if (beforeStep?.block === true) { + throw new Error(beforeStep.reason ?? `Step ${String(currentStep)} was blocked`); + } + } + + signal.throwIfAborted(); + + const messages = await buildMessages(); + signal.throwIfAborted(); + + const stepUuid = randomUUID(); + + const step: ToolCallStepContext = { + tools, + hooks, + log, + dispatchEvent, + llm, + signal, + turnId, + currentStep, + stepUuid, + }; + + await dispatchEvent({ + type: 'step.begin', + uuid: stepUuid, + turnId, + step: currentStep, + }); + + const chatParams: LLMChatParams = { + messages, + tools: tools ?? [], + signal, + ...createChatStreamingCallbacks({ + dispatchEvent, + turnId, + currentStep, + stepUuid, + }), + }; + const response: LLMChatResponse = await chatWithRetry({ + llm, + params: chatParams, + dispatchEvent, + turnId, + currentStep, + stepUuid, + maxAttempts: maxRetryAttempts, + log, + }); + const usage = response.usage; + recordUsage(usage); + const stopReason = deriveStepStopReason(response); + + // Execute tools only when the normalized response shape represents a tool + // step. Provider terminal diagnostics such as filtering or truncation must + // not trigger side-effecting tool execution even if a malformed response also + // contains tool calls. + let effectiveStopReason = stopReason; + if (stopReason === 'tool_use') { + const toolBatch = await runToolCallBatch(step, response); + if (toolBatch.stopTurn) effectiveStopReason = 'end_turn'; + } + + // When a tool batch runs, it drains paired `tool.result` events even when + // cancellation is requested. Check the signal here before sealing the step. + signal.throwIfAborted(); + + await dispatchEvent({ + type: 'step.end', + uuid: stepUuid, + turnId, + step: currentStep, + usage, + finishReason: effectiveStopReason, + ...stepEndProviderDiagnostics(response, effectiveStopReason), + }); + + if (hooks?.afterStep !== undefined) { + try { + await hooks.afterStep({ + turnId, + stepNumber: currentStep, + usage, + stopReason: effectiveStopReason, + signal, + llm, + }); + } catch { + // The step is already sealed; observer hooks cannot change the result. + } + } + + return { usage, stopReason: effectiveStopReason }; +} + +function deriveStepStopReason(response: LLMChatResponse): LoopStepStopReason { + switch (response.providerFinishReason) { + case 'truncated': + return 'max_tokens'; + case 'filtered': + return 'filtered'; + case 'paused': + return 'paused'; + case 'other': + return 'unknown'; + case 'completed': + case undefined: + return response.toolCalls.length > 0 ? 'tool_use' : 'end_turn'; + case 'tool_calls': + return response.toolCalls.length > 0 ? 'tool_use' : 'unknown'; + default: { + const _exhaustive: never = response.providerFinishReason; + return _exhaustive; + } + } +} + +function stepEndProviderDiagnostics( + response: LLMChatResponse, + stopReason: LoopStepStopReason, +): Pick { + const providerFinishReason = response.providerFinishReason; + if ( + (providerFinishReason === 'completed' && stopReason === 'end_turn') || + (providerFinishReason === 'tool_calls' && stopReason === 'tool_use') + ) { + return {}; + } + + return { + ...(providerFinishReason !== undefined ? { providerFinishReason } : {}), + ...(response.rawFinishReason !== undefined + ? { rawFinishReason: response.rawFinishReason } + : {}), + }; +} + +function createChatStreamingCallbacks(deps: { + readonly dispatchEvent: LoopEventDispatcher; + readonly turnId: string; + readonly currentStep: number; + readonly stepUuid: string; +}): ChatStreamingCallbacks { + const { dispatchEvent, turnId, currentStep, stepUuid } = deps; + + return { + onTextDelta: (delta) => { + dispatchEvent({ type: 'text.delta', delta }); + }, + onThinkDelta: (delta) => { + dispatchEvent({ type: 'thinking.delta', delta }); + }, + onToolCallDelta: (delta) => { + dispatchEvent({ + type: 'tool.call.delta', + toolCallId: delta.toolCallId, + name: delta.name, + argumentsPart: delta.argumentsPart, + }); + }, + onTextPart: async (part) => { + await dispatchEvent({ + type: 'content.part', + uuid: randomUUID(), + turnId, + step: currentStep, + stepUuid, + part, + }); + }, + onThinkPart: async (part) => { + await dispatchEvent({ + type: 'content.part', + uuid: randomUUID(), + turnId, + step: currentStep, + stepUuid, + part, + }); + }, + }; +} diff --git a/packages/agent-core/src/loop/types.ts b/packages/agent-core/src/loop/types.ts new file mode 100644 index 000000000..433435c52 --- /dev/null +++ b/packages/agent-core/src/loop/types.ts @@ -0,0 +1,208 @@ +/** + * Public contracts for the stateless agent loop. + * + * This file defines the narrow surfaces that connect a Kosong conversation to + * tool execution, phase hooks, and turn results. Host-layer metadata, policy, + * archival limits, and UI concerns stay outside these contracts. + * + * Field naming is camelCase unless a reused Kosong type says otherwise. + * Optional fields use `?: T | undefined` intentionally under + * `exactOptionalPropertyTypes: true`. + */ + +import type { ContentPart, Message, TokenUsage, Tool, ToolCall } from '@moonshot-ai/kosong'; + +import type { ToolInputDisplay } from '../tools/display'; +import type { ToolAccesses } from './tool-access'; +import type { LLM } from './llm'; + +export type { ToolCall }; + +export type LoopMessageBuilder = () => Message[] | Promise; + +/** + * Stop reason for one completed model step. + * + * `tool_use` is a loop-control signal: the loop executes the requested tools and + * continues with another step. The other values are terminal for the current + * turn unless a host hook explicitly asks the loop to continue. + */ +export type LoopStepStopReason = + | 'end_turn' + | 'max_tokens' + | 'tool_use' + | 'filtered' + | 'paused' + | 'unknown'; + +export type LoopTerminalStepStopReason = Exclude; + +/** + * Stop reasons that can be returned in a normal `TurnResult`. + * + * `tool_use` is intentionally absent because it cannot be the final result of a + * completed turn. Errors and max-step exhaustion are represented by thrown + * errors, not by this union. Compaction is a host-level retry concern rather + * than a stop reason. + */ +export type LoopTurnStopReason = LoopTerminalStepStopReason | 'aborted'; + +/** + * @deprecated Legacy umbrella union. Use `LoopStepStopReason` for per-step + * model responses and `LoopTurnStopReason` for `TurnResult`. + */ +export type StopReason = LoopStepStopReason | 'aborted'; + +export interface TurnResult { + stopReason: LoopTurnStopReason; + steps: number; + usage: TokenUsage; +} + +export type ExecutableToolOutput = string | ContentPart[]; + +export interface ExecutableToolSuccessResult { + readonly output: ExecutableToolOutput; + readonly isError?: false | undefined; + /** + * Optional human-readable side channel for tool-result metadata that + * should not contaminate the data stream the model sees (e.g. a + * "Task snapshot retrieved." brief for TaskOutput). Distinct from + * `output`: callers rendering tool results decide whether to surface + * this to the user. + */ + readonly message?: string | undefined; +} + +export interface ExecutableToolErrorResult { + readonly output: ExecutableToolOutput; + readonly isError: true; + /** See {@link ExecutableToolSuccessResult.message}. */ + readonly message?: string | undefined; + /** + * Internal loop-control hint. Tool result events strip this field before + * persistence; it only tells the current turn whether another model step is + * allowed after this tool batch. + */ + readonly stopTurn?: boolean | undefined; +} + +export type ExecutableToolResult = ExecutableToolSuccessResult | ExecutableToolErrorResult; + +export interface ToolUpdate { + kind: 'stdout' | 'stderr' | 'progress' | 'status' | 'custom'; + text?: string | undefined; + percent?: number | undefined; + /** Vendor-defined event identifier when `kind === 'custom'`. */ + customKind?: string | undefined; + /** Opaque payload paired with `customKind`. */ + customData?: unknown; +} + +/** + * Per-call context passed to tool implementations. + */ +export interface ExecutableToolContext { + readonly turnId: string; + readonly toolCallId: string; + readonly metadata?: unknown; + readonly signal: AbortSignal; + readonly onUpdate?: ((update: ToolUpdate) => void) | undefined; +} + +export interface RunnableToolExecution { + readonly isError?: false | undefined; + readonly accesses?: ToolAccesses | undefined; + readonly display?: ToolInputDisplay | undefined; + readonly description?: string; + readonly execute: (ctx: ExecutableToolContext) => Promise; +} + +export type ToolExecution = RunnableToolExecution | ExecutableToolErrorResult; + +export interface ExecutableTool extends Tool { + resolveExecution(input: Input): ToolExecution; +} + +/** + * Step hooks are aligned to recorded phase boundaries: `beforeStep` runs before + * `step.begin`, while `afterStep` runs after `step.end`. + */ + +export interface LoopStepHookContext { + readonly turnId: string; + readonly stepNumber: number; + readonly signal: AbortSignal; + readonly llm: LLM; +} + +export interface ToolExecutionHookContext extends LoopStepHookContext { + readonly toolCall: ToolCall; + readonly tool?: ExecutableTool | undefined; + readonly args: unknown; +} + +export interface PrepareToolExecutionResult { + readonly block?: boolean | undefined; + readonly reason?: string | undefined; + readonly updatedArgs?: unknown; + readonly syntheticResult?: ExecutableToolResult | undefined; + readonly executionMetadata?: unknown; +} + +export interface FinalizeToolResultContext extends ToolExecutionHookContext { + readonly result: ExecutableToolResult; +} + +export interface LoopAfterStepContext extends LoopStepHookContext { + readonly usage: TokenUsage; + readonly stopReason: LoopStepStopReason; +} + +export interface LoopStoppedStepContext extends LoopStepHookContext { + readonly usage: TokenUsage; + readonly stopReason: LoopTerminalStepStopReason; +} + +export interface BeforeStepResult { + readonly block?: boolean | undefined; + readonly reason?: string | undefined; +} + +export interface ShouldContinueAfterStopResult { + readonly continue: boolean; +} + +export type BeforeStepHook = (ctx: LoopStepHookContext) => Promise; + +export type AfterStepHook = (ctx: LoopAfterStepContext) => Promise; + +export type PrepareToolExecutionHook = ( + ctx: ToolExecutionHookContext, +) => Promise; + +export type FinalizeToolResultHook = ( + ctx: FinalizeToolResultContext, +) => Promise; + +export type ShouldContinueAfterStopHook = ( + ctx: LoopStoppedStepContext, +) => Promise; + +/** + * Groups every awaited phase hook. + * + * Hooks can affect control flow at deterministic transcript points. Event + * listeners observe output and cannot change turn behavior. + * + * Tool hooks run serially in provider tool-call order before the matching + * durable event is recorded, so preparation and finalization decisions are + * resolved at stable transcript points. + */ +export interface LoopHooks { + beforeStep?: BeforeStepHook | undefined; + afterStep?: AfterStepHook | undefined; + prepareToolExecution?: PrepareToolExecutionHook | undefined; + finalizeToolResult?: FinalizeToolResultHook | undefined; + shouldContinueAfterStop?: ShouldContinueAfterStopHook | undefined; +} diff --git a/packages/agent-core/src/mcp/auth-tool.ts b/packages/agent-core/src/mcp/auth-tool.ts new file mode 100644 index 000000000..37bd73652 --- /dev/null +++ b/packages/agent-core/src/mcp/auth-tool.ts @@ -0,0 +1,181 @@ +/** + * Synthetic `mcp____authenticate` tool. + * + * When an MCP HTTP server lands in the `needs-auth` state — i.e. its + * initial connection failed with a 401 / `UnauthorizedError` and no static + * bearer token is configured — the {@link ToolManager} swaps the real MCP + * tool list for this single tool. Calling it: + * + * 1. Asks {@link McpOAuthService} to perform RFC 9728 / RFC 8414 / RFC 7591 + * discovery and produce an authorization URL. + * 2. Streams that URL back to the model via `onUpdate({kind:'status'})` + * and returns it in the tool output so the model can hand it to the + * human user. + * 3. Blocks (up to {@link DEFAULT_AUTH_TIMEOUT_MS}) on the one-shot + * localhost callback listener owned by the OAuth service. + * 4. Drives a manager-level `reconnect(name)` once tokens have been + * persisted, which flips the entry to `connected` and lets + * `ToolManager` swap the synthetic tool out for the real MCP tools. + * + * The blocking shape (option 1 in the plan) keeps the implementation + * simple at the cost of holding one tool call open for the duration of + * the human's browser flow. If the model ends up re-invoking the tool + * mid-flow we just start a fresh flow; the new callback server supersedes + * the old one. + */ + +import { z } from 'zod'; + +import { + type ExecutableTool, + type ExecutableToolContext, + type ExecutableToolResult, +} from '../loop'; +import { toInputJsonSchema } from '../tools/support/input-schema'; +import { + MCP_OAUTH_AUTHORIZATION_URL_TOOL_UPDATE, + type McpOAuthAuthorizationUrlUpdateData, +} from '../rpc/events'; +import { AlreadyAuthorizedError, type McpOAuthService } from './oauth'; +import { qualifyMcpToolName } from './tool-naming'; + +const DEFAULT_AUTH_TIMEOUT_MS = 15 * 60 * 1000; // 15 minutes + +const AUTH_TOOL_TOOL_NAME = 'authenticate'; + +const DESCRIPTION_TEMPLATE = (serverName: string): string => + `Authenticate with MCP server "${serverName}" via OAuth. + +This server requires an OAuth login that has not yet been completed. ` + + `Calling this tool starts the authorization flow: + + 1. The tool prints an authorization URL. + 2. **You must show that URL to the user verbatim** and ask them to open it + in a browser, sign in, and approve the kimi-code client. + 3. The tool blocks (up to 15 minutes) until the browser redirects back to + the local callback listener. + 4. On success, kimi-code reconnects the MCP server and the real tools + replace this synthetic tool. + +Take no arguments. Treat the URL as sensitive — do not modify it or strip +query parameters.`; + +export interface CreateMcpAuthToolOptions { + /** Friendly MCP server name as configured in `mcp.json`. */ + readonly serverName: string; + /** Base URL of the MCP server (used for OAuth resource metadata discovery). */ + readonly serverUrl: string; + /** OAuth orchestrator, typically `Session`-scoped. */ + readonly oauthService: McpOAuthService; + /** + * Triggers a manager-level reconnect once tokens land on disk. Implemented + * by the {@link McpConnectionManager} and bound in the {@link ToolManager} + * `needs-auth` branch. + */ + readonly reconnect: (signal?: AbortSignal) => Promise; + /** + * Overrides the per-call OAuth wait timeout. Tests set this to a small + * number; production callers should accept the default. + */ + readonly timeoutMs?: number; +} + +export function createMcpAuthTool(options: CreateMcpAuthToolOptions): ExecutableTool { + const { serverName, serverUrl, oauthService, reconnect, timeoutMs } = options; + const name = qualifyMcpToolName(serverName, AUTH_TOOL_TOOL_NAME); + const description = DESCRIPTION_TEMPLATE(serverName); + // No arguments; an empty object schema keeps providers happy across SDKs. + const parameters = toInputJsonSchema(z.object({})); + const execute = async (ctx: ExecutableToolContext): Promise => { + const { signal, onUpdate } = ctx; + signal.throwIfAborted(); + + onUpdate?.({ kind: 'status', text: `Discovering OAuth metadata for ${serverName}…` }); + + let flow: Awaited>; + try { + flow = await oauthService.beginAuthorization(serverName, serverUrl); + } catch (error) { + if (error instanceof AlreadyAuthorizedError) { + onUpdate?.({ kind: 'status', text: `Already authorized; reconnecting ${serverName}…` }); + try { + await reconnect(signal); + } catch (reconnectError) { + return errorResult(serverName, reconnectError); + } + return { + output: + `MCP server "${serverName}" already had valid OAuth credentials. ` + + `Reconnected; real tools are available now.`, + }; + } + return errorResult(serverName, error); + } + + const urlText = flow.authorizationUrl.toString(); + const customData: McpOAuthAuthorizationUrlUpdateData = { + serverName, + authorizationUrl: urlText, + }; + onUpdate?.({ + kind: 'custom', + customKind: MCP_OAUTH_AUTHORIZATION_URL_TOOL_UPDATE, + customData, + }); + onUpdate?.({ + kind: 'status', + text: + `Open this URL in your browser to authorize "${serverName}":\n` + + `\n${urlText}\n\n` + + `Waiting for the OAuth callback (timeout 15 min). ` + + `If you cancel, call this tool again to restart the flow.`, + }); + + try { + await flow.complete({ signal, timeoutMs: timeoutMs ?? DEFAULT_AUTH_TIMEOUT_MS }); + } catch (error) { + return errorResult(serverName, error, urlText); + } + + onUpdate?.({ kind: 'status', text: `Authorized — reconnecting ${serverName}…` }); + try { + await reconnect(signal); + } catch (error) { + return errorResult(serverName, error); + } + + return { + output: + `MCP server "${serverName}" authenticated successfully. ` + + `The real MCP tools have replaced this synthetic authenticate tool.`, + }; + }; + + return { + name, + description, + parameters, + resolveExecution: () => { + return { + description: `Authenticating ${serverName}`, + execute, + }; + }, + }; +} + +function errorResult( + serverName: string, + error: unknown, + authorizationUrl?: string, +): ExecutableToolResult { + const message = error instanceof Error ? error.message : String(error); + const suffix = + authorizationUrl !== undefined + ? `\n\nAuthorization URL (still valid if the listener has not timed out): ${authorizationUrl}` + : ''; + return { + isError: true, + output: `OAuth flow for MCP server "${serverName}" did not complete: ${message}${suffix}`, + }; +} diff --git a/packages/agent-core/src/mcp/client-http.ts b/packages/agent-core/src/mcp/client-http.ts new file mode 100644 index 000000000..626c5d2bd --- /dev/null +++ b/packages/agent-core/src/mcp/client-http.ts @@ -0,0 +1,231 @@ +import { ErrorCodes, KimiError } from '#/errors'; +import type { McpServerHttpConfig } from '#/config/schema'; +import { Client } from '@modelcontextprotocol/sdk/client/index.js'; +import type { OAuthClientProvider } from '@modelcontextprotocol/sdk/client/auth.js'; +import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'; + +import { + buildRequestOptions, + KIMI_MCP_CLIENT_NAME, + KIMI_MCP_CLIENT_VERSION, + toMcpToolDefinition, + toMcpToolResult, + type UnexpectedCloseListener, + type UnexpectedCloseReason, +} from './client-shared'; +import type { MCPClient, MCPToolDefinition, MCPToolResult } from './types'; + +export interface HttpMcpClientOptions { + readonly clientName?: string; + readonly clientVersion?: string; + readonly toolCallTimeoutMs?: number; + /** + * Reads `process.env[name]` by default. Tests can inject a deterministic + * lookup function so they do not have to mutate global env. + */ + readonly envLookup?: (name: string) => string | undefined; + /** + * Lets tests inject a fake `fetch` for the underlying transport. + */ + readonly fetch?: typeof fetch; + /** + * OAuth client provider attached to the transport. Set only when the server + * has no static token configuration; the SDK uses this to handle 401s with + * RFC 9728 / RFC 8414 / DCR discovery and PKCE. The connection manager wires + * this in and surfaces `UnauthorizedError` as a `needs-auth` status. + */ + readonly oauthProvider?: OAuthClientProvider; +} + +/** + * Wraps the SDK streamable-HTTP transport as a kosong {@link MCPClient}. + * Static bearer tokens are looked up from `process.env[bearerTokenEnvVar]`. + * OAuth providers are attached separately by the connection manager. + */ +export class HttpMcpClient implements MCPClient { + private readonly client: Client; + private readonly transport: StreamableHTTPClientTransport; + private readonly toolCallTimeoutMs?: number; + private started = false; + private closed = false; + // See StdioMcpClient.ready — distinguishes handshake-phase failures (caller + // sees them via `connect()` throwing, no unexpectedClose) from post-ready + // disconnects (the case `onUnexpectedClose` is designed to surface). + private ready = false; + private hooksInstalled = false; + private unexpectedCloseListener: UnexpectedCloseListener | undefined; + private lastTransportError: Error | undefined; + // See StdioMcpClient — buffered when the listener has not been installed + // yet so an early close is replayed instead of dropped. + private pendingUnexpectedClose: UnexpectedCloseReason | undefined; + // Latch so `onerror` and a (theoretical) `onclose` for the same transport + // failure do not double-fire. Once we have decided the connection is dead, + // additional SDK notifications are noise. + private unexpectedCloseFired = false; + + constructor(config: McpServerHttpConfig, options: HttpMcpClientOptions = {}) { + const envLookup = options.envLookup ?? ((name) => process.env[name]); + const headers = buildMcpHttpHeaders(config, envLookup); + + this.transport = new StreamableHTTPClientTransport(new URL(config.url), { + requestInit: headers !== undefined ? { headers } : undefined, + fetch: options.fetch, + authProvider: options.oauthProvider, + }); + this.client = new Client({ + name: options.clientName ?? KIMI_MCP_CLIENT_NAME, + version: options.clientVersion ?? KIMI_MCP_CLIENT_VERSION, + }); + this.toolCallTimeoutMs = options.toolCallTimeoutMs; + } + + async connect(): Promise { + if (this.closed) { + throw new Error('MCP HTTP client is closed'); + } + if (this.started) return; + this.started = true; + // Install hooks BEFORE the SDK handshake; see StdioMcpClient.connect. + this.installTransportHooks(); + try { + await this.client.connect(this.transport); + } catch (error) { + await this.closeStartedClient(); + throw error; + } + if (this.closed) { + await this.closeStartedClient(); + throw new Error('MCP HTTP client was closed during startup'); + } + this.ready = true; + } + + async close(): Promise { + if (this.closed) return; + this.closed = true; + await this.closeStartedClient(); + } + + /** + * Register a listener for unsolicited transport drops. See + * `StdioMcpClient.onUnexpectedClose` for semantics. If the transport + * already signalled a terminal failure, the buffered reason is replayed + * synchronously. + */ + onUnexpectedClose(listener: UnexpectedCloseListener): void { + this.unexpectedCloseListener = listener; + const pending = this.pendingUnexpectedClose; + if (pending !== undefined) { + this.pendingUnexpectedClose = undefined; + listener(pending); + } + } + + async listTools(): Promise { + const result = await this.client.listTools(); + return result.tools.map(toMcpToolDefinition); + } + + async callTool( + name: string, + args: Record, + signal?: AbortSignal, + ): Promise { + const requestOptions = buildRequestOptions(this.toolCallTimeoutMs, signal); + const result = await this.client.callTool({ name, arguments: args }, undefined, requestOptions); + return toMcpToolResult(result); + } + + private async closeStartedClient(): Promise { + if (!this.started) return; + this.started = false; + await this.client.close(); + } + + private installTransportHooks(): void { + // Idempotent — see StdioMcpClient.installTransportHooks. + if (this.hooksInstalled) return; + this.hooksInstalled = true; + this.client.onclose = () => { + if (this.closed) return; + // Handshake-phase close surfaces via `client.connect()` throwing. + if (!this.ready) return; + this.fireUnexpectedClose({ error: this.lastTransportError }); + }; + // streamable-http's transport only calls `onclose` on its own `close()` + // path, so 99% of remote disconnects (SSE flap → reconnect exhaustion, + // POST send failure on a dead session) arrive as `onerror` instead. Mirror + // the way the SDK exposes a "the transport is gone" signal there by + // mapping the known-terminal error messages back to an unexpected close; + // everything else is treated as transient and only cached for diagnostics. + this.client.onerror = (error) => { + this.lastTransportError = error; + if (this.closed) return; + // During the handshake, terminal errors (Unauthorized, reconnect + // exhaustion) propagate through `client.connect()` and the manager's + // `shouldMarkNeedsAuth` / `formatStartupError`. Firing here would + // double-report. + if (!this.ready) return; + if (isTerminalTransportError(error)) { + this.fireUnexpectedClose({ error }); + } + }; + } + + private fireUnexpectedClose(reason: UnexpectedCloseReason): void { + if (this.unexpectedCloseFired) return; + this.unexpectedCloseFired = true; + const listener = this.unexpectedCloseListener; + if (listener !== undefined) { + listener(reason); + } else { + this.pendingUnexpectedClose = reason; + } + } +} + +/** + * Returns true when an error reported via `Client.onerror` indicates the + * underlying HTTP transport is dead. The streamable-http SDK does not call + * `onclose` for remote disconnects; instead it surfaces them through + * `onerror`, but only a few specific messages mean "give up" rather than + * "we will retry": + * + * - `UnauthorizedError` — RFC 9728/8414 auth flow gave up; the SDK won't + * retry without a fresh provider call. + * - "Maximum reconnection attempts ... exceeded." — emitted from + * `_scheduleReconnection` after the SSE reconnect budget is gone + * (`streamableHttp.js`, `_scheduleReconnection`). + * + * Transient signals (per-request fetch failures, single SSE flaps that the + * SDK is about to reconnect from) MUST NOT match; otherwise a brief network + * blip would tear down every HTTP MCP entry. + */ +export function isTerminalTransportError(error: Error): boolean { + if (error.name === 'UnauthorizedError') return true; + if (/Maximum reconnection attempts/i.test(error.message)) return true; + return false; +} + +export function buildMcpHttpHeaders( + config: McpServerHttpConfig, + envLookup: (name: string) => string | undefined, +): Record | undefined { + const headers: Record = { ...config.headers }; + if (config.bearerTokenEnvVar !== undefined) { + const token = envLookup(config.bearerTokenEnvVar); + if (token === undefined || token.length === 0) { + throw new KimiError(ErrorCodes.CONFIG_INVALID, `MCP HTTP bearer token env var "${config.bearerTokenEnvVar}" is not set or is empty`); + } + // Strip any case-variant 'authorization' static header before injecting the + // bearer; Fetch Headers folds duplicate keys into a comma-joined value, + // which produces an invalid auth header rather than letting the bearer win. + for (const key of Object.keys(headers)) { + if (key.toLowerCase() === 'authorization') { + delete headers[key]; + } + } + headers['Authorization'] = `Bearer ${token}`; + } + return Object.keys(headers).length > 0 ? headers : undefined; +} diff --git a/packages/agent-core/src/mcp/client-shared.ts b/packages/agent-core/src/mcp/client-shared.ts new file mode 100644 index 000000000..b36d5fad8 --- /dev/null +++ b/packages/agent-core/src/mcp/client-shared.ts @@ -0,0 +1,91 @@ +import { getCoreVersion } from '#/version'; + +import type { MCPToolDefinition, MCPToolResult } from './types'; + +export const KIMI_MCP_CLIENT_NAME = 'kimi-code'; +// Resolved from agent-core's package.json so MCP servers see the real version +// in `initialize` (used for compatibility checks, telemetry, debugging). +// `getCoreVersion()` falls back to '0.0.0' if the package.json read fails. +export const KIMI_MCP_CLIENT_VERSION = getCoreVersion(); + +/** + * Why-context attached when a runtime client notices its underlying transport + * has gone away on its own — i.e. {@link RuntimeMcpClient.close} was NOT + * called. The connection manager turns this into a `failed` status so the + * UI/SDK do not keep advertising tools backed by a dead transport. + * + * - `error` is the last error reported via the SDK's `onerror` channel, if + * any. Useful for HTTP where there is no stderr. + * - `stderr` is the tail of bytes captured from the child process's stderr; + * populated only for the stdio transport. + */ +export interface UnexpectedCloseReason { + readonly error?: Error; + readonly stderr?: string; +} + +export type UnexpectedCloseListener = (reason: UnexpectedCloseReason) => void; + +export interface McpRequestOptions { + readonly timeout?: number; + readonly signal?: AbortSignal; +} + +/** + * Build the `RequestOptions` object accepted by the MCP SDK's `callTool`, + * including either the configured tool-call timeout, an in-flight abort + * signal, both, or neither. Returns `undefined` when nothing needs to be + * passed so the SDK falls back to its defaults. + */ +export function buildRequestOptions( + toolCallTimeoutMs: number | undefined, + signal: AbortSignal | undefined, +): McpRequestOptions | undefined { + if (toolCallTimeoutMs === undefined && signal === undefined) return undefined; + return { timeout: toolCallTimeoutMs, signal }; +} + +interface SdkListedTool { + readonly name: string; + readonly description?: string; + readonly inputSchema: Record; +} + +export function toMcpToolDefinition(tool: SdkListedTool): MCPToolDefinition { + return { + name: tool.name, + description: tool.description ?? '', + inputSchema: tool.inputSchema, + }; +} + +/** + * Normalise the SDK's `callTool` return into kosong's {@link MCPToolResult}. + * The SDK can return either the modern `{ content, isError }` shape or a + * legacy `{ toolResult }` shape; we collapse the legacy shape to a single + * text content block. + */ +export function toMcpToolResult(result: unknown): MCPToolResult { + if (typeof result === 'object' && result !== null && 'content' in result) { + const typed = result as { content: unknown; isError?: unknown }; + if (Array.isArray(typed.content)) { + return { + content: typed.content as MCPToolResult['content'], + isError: typed.isError === true, + }; + } + } + if (typeof result === 'object' && result !== null && 'toolResult' in result) { + const legacy = (result as { toolResult: unknown }).toolResult; + return { + content: [ + { + type: 'text', + text: typeof legacy === 'string' ? legacy : JSON.stringify(legacy), + }, + ], + isError: false, + }; + } + return { content: [], isError: false }; +} diff --git a/packages/agent-core/src/mcp/client-stdio.ts b/packages/agent-core/src/mcp/client-stdio.ts new file mode 100644 index 000000000..55c31aeb1 --- /dev/null +++ b/packages/agent-core/src/mcp/client-stdio.ts @@ -0,0 +1,228 @@ +import { ErrorCodes, KimiError } from '#/errors'; +import type { McpServerStdioConfig } from '#/config/schema'; +import { Client } from '@modelcontextprotocol/sdk/client/index.js'; +import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'; + +import { + buildRequestOptions, + KIMI_MCP_CLIENT_NAME, + KIMI_MCP_CLIENT_VERSION, + toMcpToolDefinition, + toMcpToolResult, + type UnexpectedCloseListener, + type UnexpectedCloseReason, +} from './client-shared'; +import type { MCPClient, MCPToolDefinition, MCPToolResult } from './types'; + +export interface StdioMcpClientOptions { + readonly clientName?: string; + readonly clientVersion?: string; + readonly toolCallTimeoutMs?: number; +} + +const STDERR_BUFFER_CAPACITY = 4 * 1024; + +/** + * Wraps the `@modelcontextprotocol/sdk` stdio client and exposes the small + * surface required by kosong's {@link MCPClient}. Lifecycle is explicit: + * the caller must `connect()` before use and `close()` to terminate the + * child process. + */ +export class StdioMcpClient implements MCPClient { + private readonly client: Client; + private readonly transport: StdioClientTransport; + private readonly toolCallTimeoutMs?: number; + private readonly stderrBuffer = new BoundedTail(STDERR_BUFFER_CAPACITY); + private started = false; + private closed = false; + // Flips to true only after `client.connect()` resolves AND the caller has + // not torn things down mid-startup. The `onclose` hook uses this to + // distinguish "transport died after the handshake" (→ unexpected close) + // from "transport died during the handshake" (→ `connect()` throws; the + // manager surfaces the failure via `formatStartupError`). + private ready = false; + private hooksInstalled = false; + private unexpectedCloseListener: UnexpectedCloseListener | undefined; + private lastTransportError: Error | undefined; + // Buffered when the transport closes before a listener is installed (e.g. + // a server that exits seconds after answering `tools/list`). Replayed when + // `onUnexpectedClose` registers so the close is never silently dropped. + private pendingUnexpectedClose: UnexpectedCloseReason | undefined; + + /** Capacity (in characters) of the stderr tail captured for diagnostics. */ + static readonly stderrBufferCapacity = STDERR_BUFFER_CAPACITY; + + constructor(config: McpServerStdioConfig, options: StdioMcpClientOptions = {}) { + if (config.executor !== undefined && config.executor !== 'local') { + throw new KimiError(ErrorCodes.NOT_IMPLEMENTED, `MCP stdio executor '${config.executor}' is not yet implemented`); + } + this.transport = new StdioClientTransport({ + command: config.command, + args: config.args, + env: mergeStdioEnv(config.env), + cwd: config.cwd, + stderr: 'pipe', + }); + // `stderr: 'pipe'` means we MUST drain the stream — otherwise the child + // can block on a full pipe. We also keep the last few KB around so the + // connection manager can attach it to user-facing failure messages + // (`Timed out after 30000ms` on its own tells the user nothing). + this.transport.stderr?.on('data', (chunk: Buffer | string) => { + this.stderrBuffer.push(typeof chunk === 'string' ? chunk : chunk.toString('utf8')); + }); + this.client = new Client({ + name: options.clientName ?? KIMI_MCP_CLIENT_NAME, + version: options.clientVersion ?? KIMI_MCP_CLIENT_VERSION, + }); + this.toolCallTimeoutMs = options.toolCallTimeoutMs; + } + + async connect(): Promise { + if (this.closed) { + throw new Error('MCP stdio client is closed'); + } + if (this.started) return; + this.started = true; + // Install transport hooks BEFORE the SDK handshake so we never lose an + // onclose that fires between handshake completion and our wiring. The + // hooks themselves gate on `this.ready`, so a close that happens DURING + // the handshake still flows through `client.connect()` rejecting. + this.installTransportHooks(); + try { + await this.client.connect(this.transport); + } catch (error) { + await this.closeStartedClient(); + throw error; + } + if (this.closed) { + await this.closeStartedClient(); + throw new Error('MCP stdio client was closed during startup'); + } + this.ready = true; + } + + async close(): Promise { + if (this.closed) return; + this.closed = true; + await this.closeStartedClient(); + } + + /** + * Register a listener that fires when the underlying transport closes on + * its own — i.e. the caller has not yet invoked {@link close}. At most one + * listener can be installed; later registrations replace earlier ones. + * Intentional closes never invoke the listener. + * + * If the transport already closed before this method was called, the + * buffered reason is replayed synchronously so the close is never dropped. + */ + onUnexpectedClose(listener: UnexpectedCloseListener): void { + this.unexpectedCloseListener = listener; + const pending = this.pendingUnexpectedClose; + if (pending !== undefined) { + this.pendingUnexpectedClose = undefined; + listener(pending); + } + } + + /** + * Returns the tail of bytes captured from the child's stderr since spawn. + * Bounded by {@link StdioMcpClient.stderrBufferCapacity} so a noisy server + * cannot exhaust memory. + */ + stderrSnapshot(): string { + return this.stderrBuffer.snapshot(); + } + + async listTools(): Promise { + const result = await this.client.listTools(); + return result.tools.map(toMcpToolDefinition); + } + + async callTool( + name: string, + args: Record, + signal?: AbortSignal, + ): Promise { + const requestOptions = buildRequestOptions(this.toolCallTimeoutMs, signal); + const result = await this.client.callTool({ name, arguments: args }, undefined, requestOptions); + return toMcpToolResult(result); + } + + private async closeStartedClient(): Promise { + if (!this.started) return; + this.started = false; + await this.client.close(); + } + + private installTransportHooks(): void { + // Idempotent: `connect()` is the only caller and is itself guarded by + // `started`, but defending here lets future refactors call this freely. + if (this.hooksInstalled) return; + this.hooksInstalled = true; + // `Client.onclose` fires for THREE situations: + // 1. The intentional `close()` path → gated by `this.closed`. + // 2. Transport dying during the SDK handshake → gated by `!this.ready`; + // the failure already surfaces via `client.connect()` rejecting, and + // `formatStartupError` attaches stderr at the manager layer. + // 3. Transport dying after the handshake succeeded → the case we care + // about: fire or buffer for the manager's watch listener. + this.client.onclose = () => { + if (this.closed) return; + if (!this.ready) return; + const stderr = this.stderrBuffer.snapshot(); + const reason: UnexpectedCloseReason = { + error: this.lastTransportError, + stderr: stderr.length > 0 ? stderr : undefined, + }; + const listener = this.unexpectedCloseListener; + if (listener !== undefined) { + listener(reason); + } else { + // Buffer so a listener registered moments later still sees the close. + this.pendingUnexpectedClose = reason; + } + }; + this.client.onerror = (error) => { + // Errors are informational on their own — `_onclose` is what tells us + // the transport is gone — so just remember the latest one and let the + // close handler decide whether to surface it. During startup the thrown + // error from `client.connect()` already carries the message, so this + // capture is only load-bearing post-`ready`. + this.lastTransportError = error; + }; + } +} + +/** + * A bounded "tail" buffer: appends characters and drops the oldest when the + * total exceeds `capacity`. Used to keep the last few KB of child-process + * stderr around without unbounded growth. + */ +class BoundedTail { + private buffer = ''; + constructor(private readonly capacity: number) {} + + push(chunk: string): void { + this.buffer += chunk; + if (this.buffer.length > this.capacity) { + this.buffer = this.buffer.slice(this.buffer.length - this.capacity); + } + } + + snapshot(): string { + return this.buffer; + } +} + +// Inherit the parent's env so PATH/HOME/etc. survive — otherwise `npx`/`uvx` +// style stdio servers fail to launch even with a valid config. Explicit +// `config.env` entries still override on conflict. +function mergeStdioEnv(configEnv?: Record): Record { + const merged: Record = {}; + for (const [key, value] of Object.entries(process.env)) { + if (value !== undefined) merged[key] = value; + } + if (configEnv !== undefined) Object.assign(merged, configEnv); + return merged; +} diff --git a/packages/agent-core/src/mcp/config-loader.ts b/packages/agent-core/src/mcp/config-loader.ts new file mode 100644 index 000000000..0c29abffb --- /dev/null +++ b/packages/agent-core/src/mcp/config-loader.ts @@ -0,0 +1,95 @@ +import { readFile } from 'node:fs/promises'; +import { join } from 'node:path'; + +import { resolveKimiHome } from '#/config/path'; +import { McpServerConfigSchema, type McpServerConfig } from '#/config/schema'; +import { ErrorCodes, KimiError } from '#/errors'; +import { z } from 'zod'; + +const McpJsonFileSchema = z.object({ + mcpServers: z.record(z.string(), McpServerConfigSchema).default({}), +}); + +export interface McpJsonPaths { + readonly user: string; + readonly project: string; +} + +export interface ResolveMcpJsonPathsInput { + readonly cwd: string; + readonly homeDir?: string; +} + +export function resolveMcpJsonPaths(input: ResolveMcpJsonPathsInput): McpJsonPaths { + return { + user: join(resolveKimiHome(input.homeDir), 'mcp.json'), + project: join(input.cwd, '.kimi-code', 'mcp.json'), + }; +} + +export interface LoadMcpServersInput { + readonly cwd: string; + readonly homeDir?: string; +} + +/** + * Load MCP server declarations from the user-global `~/.kimi-code/mcp.json` + * and the project-local `/.kimi-code/mcp.json`. Entries in the project + * file override user-global entries with the same key, so a repo can specialise + * or replace a shared definition. + * + * Note: project-local entries may spawn stdio commands at session start, so + * opening a session inside an untrusted checkout will execute whatever its + * `mcp.json` declares. Only enable this in repos you trust. + */ +export async function loadMcpServers( + input: LoadMcpServersInput, +): Promise> { + const paths = resolveMcpJsonPaths({ cwd: input.cwd, homeDir: input.homeDir }); + const [user, project] = await Promise.all([readMcpJson(paths.user), readMcpJson(paths.project)]); + return { ...user, ...project }; +} + +async function readMcpJson(filePath: string): Promise> { + let text: string; + try { + text = await readFile(filePath, 'utf-8'); + } catch (error: unknown) { + if (isFileNotFound(error)) return {}; + throw new KimiError(ErrorCodes.CONFIG_INVALID, `Failed to read ${filePath}: ${describeError(error)}`, { + cause: error, + }); + } + + if (text.trim().length === 0) return {}; + + let data: unknown; + try { + data = JSON.parse(text); + } catch (error: unknown) { + throw new KimiError(ErrorCodes.CONFIG_INVALID, `Invalid JSON in ${filePath}: ${describeError(error)}`, { + cause: error, + }); + } + + try { + return McpJsonFileSchema.parse(data).mcpServers; + } catch (error: unknown) { + throw new KimiError(ErrorCodes.CONFIG_INVALID, `Invalid MCP server config in ${filePath}: ${describeError(error)}`, { + cause: error, + }); + } +} + +function isFileNotFound(error: unknown): boolean { + return ( + typeof error === 'object' && + error !== null && + 'code' in error && + (error as { code: unknown }).code === 'ENOENT' + ); +} + +function describeError(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} diff --git a/packages/agent-core/src/mcp/connection-manager.ts b/packages/agent-core/src/mcp/connection-manager.ts new file mode 100644 index 000000000..fb3b861f2 --- /dev/null +++ b/packages/agent-core/src/mcp/connection-manager.ts @@ -0,0 +1,462 @@ +import { ErrorCodes, KimiError } from '#/errors'; +import type { McpServerConfig } from '#/config/schema'; +import { log as defaultLog } from '#/logging/logger'; +import type { Logger } from '#/logging/types'; +import type { Tool } from '@moonshot-ai/kosong'; + +import { abortable } from '../utils/abort'; +import { HttpMcpClient } from './client-http'; +import type { UnexpectedCloseReason } from './client-shared'; +import { StdioMcpClient } from './client-stdio'; +import type { McpOAuthService } from './oauth'; +import { assertMcpInputSchema, type MCPClient } from './types'; + +export type McpServerStatus = 'pending' | 'connected' | 'failed' | 'disabled' | 'needs-auth'; + +export interface McpServerEntry { + readonly name: string; + readonly transport: 'stdio' | 'http'; + readonly status: McpServerStatus; + readonly toolCount: number; + readonly error?: string; +} + +interface InternalEntry { + readonly name: string; + readonly config: McpServerConfig; + attemptId: number; + status: McpServerStatus; + tools?: readonly Tool[]; + enabledNames?: ReadonlySet; + error?: string; + client?: RuntimeMcpClient; +} + +export type McpStatusListener = (entry: McpServerEntry) => void; + +const DEFAULT_STARTUP_TIMEOUT_MS = 30_000; + +type RuntimeMcpClient = StdioMcpClient | HttpMcpClient; + +export interface McpConnectionManagerOptions { + readonly envLookup?: (name: string) => string | undefined; + /** + * Optional OAuth orchestrator. When provided, HTTP servers without a + * static bearer token participate in the OAuth-via-synthetic-tool flow: + * - If `oauthService.hasTokens(name, url)` is true, the provider is + * attached to the transport so the SDK can refresh tokens on 401. + * - Connection failures that look like 401 / `UnauthorizedError` flip + * the entry into `needs-auth` instead of `failed`; `/mcp-config` + * drives the browser flow through the synthetic auth tool. + */ + readonly oauthService?: McpOAuthService; + /** + * Parent logger. Defaults to the global `log`; Session passes its own + * `session.log` so MCP events land in the session log too. + */ + readonly log?: Logger; +} + +/** + * Owns the lifecycle of every configured MCP server for a Session. + * + * Servers are connected in parallel; per-server failures are isolated so a + * crashed or misconfigured entry never blocks Session startup. State + * transitions are surfaced through {@link onStatusChange} so callers (the + * Session) can react — registering tools onto the main agent, emitting + * wire events, or updating the TUI. + */ +export class McpConnectionManager { + private readonly entries = new Map(); + private readonly listeners = new Set(); + private initialLoad: Promise = Promise.resolve(); + private initialLoadAttemptId = 0; + private initialLoadStartedAt: number | undefined; + private initialLoadFinishedAt: number | undefined; + + /** + * OAuth orchestrator injected at construction time. Consumed by the + * {@link ToolManager} `needs-auth` branch to build the synthetic + * `authenticate` tool. + */ + readonly oauthService: McpOAuthService | undefined; + private readonly log: Logger; + + constructor(private readonly options: McpConnectionManagerOptions = {}) { + this.oauthService = options.oauthService; + this.log = options.log ?? defaultLog; + } + + /** + * Returns the URL of an HTTP MCP server by name, or `undefined` for + * unknown / non-HTTP / disabled entries. Used by the synthetic auth tool + * to drive OAuth discovery against the right base URL. + */ + getHttpServerUrl(name: string): string | undefined { + const entry = this.entries.get(name); + if (entry === undefined) return undefined; + if (entry.config.transport !== 'http') return undefined; + return entry.config.url; + } + + onStatusChange(listener: McpStatusListener): () => void { + this.listeners.add(listener); + return () => { + this.listeners.delete(listener); + }; + } + + list(): readonly McpServerEntry[] { + return Array.from(this.entries.values(), toPublicEntry); + } + + get(name: string): McpServerEntry | undefined { + const entry = this.entries.get(name); + return entry !== undefined ? toPublicEntry(entry) : undefined; + } + + /** + * Returns the MCP client, the discovered tools, and the allow-list of tool + * names for a given connected server, or `undefined` if the server is not + * currently connected. The allow-list combines the server's `enabledTools` + * and `disabledTools` filters; callers should only register names in the + * set. + */ + resolved( + name: string, + ): + | { client: MCPClient; tools: readonly Tool[]; enabledNames: ReadonlySet } + | undefined { + const entry = this.entries.get(name); + if ( + entry?.status !== 'connected' || + entry.tools === undefined || + entry.client === undefined + ) { + return undefined; + } + return { + client: entry.client, + tools: entry.tools, + enabledNames: entry.enabledNames ?? new Set(entry.tools.map((t) => t.name)), + }; + } + + connectAll(configs: Record): Promise { + const attemptId = ++this.initialLoadAttemptId; + this.initialLoadStartedAt = Date.now(); + this.initialLoadFinishedAt = undefined; + const initialLoad = this.connectAllNow(configs).finally(() => { + if (this.initialLoadAttemptId === attemptId) { + this.initialLoadFinishedAt = Date.now(); + } + }); + this.initialLoad = initialLoad; + return initialLoad; + } + + waitForInitialLoad(signal?: AbortSignal): Promise { + signal?.throwIfAborted(); + if (signal === undefined) return this.initialLoad; + return abortable(this.initialLoad, signal); + } + + initialLoadDurationMs(): number { + if (this.initialLoadStartedAt === undefined) return 0; + const endedAt = this.initialLoadFinishedAt ?? Date.now(); + return Math.max(0, endedAt - this.initialLoadStartedAt); + } + + private async connectAllNow(configs: Record): Promise { + const tasks: Promise[] = []; + for (const [name, config] of Object.entries(configs)) { + const disabled = config.enabled === false; + const entry: InternalEntry = { + name, + config, + attemptId: 0, + status: disabled ? 'disabled' : 'pending', + }; + this.entries.set(name, entry); + this.emit(entry); + if (!disabled) { + tasks.push(this.connectOne(entry, this.beginConnectAttempt(entry))); + } + } + await Promise.allSettled(tasks); + } + + async reconnect(name: string): Promise { + const entry = this.entries.get(name); + if (entry === undefined) { + throw new KimiError(ErrorCodes.MCP_SERVER_NOT_FOUND, `Unknown MCP server: ${name}`); + } + if (entry.config.enabled === false) { + throw new KimiError(ErrorCodes.MCP_SERVER_DISABLED, `MCP server is disabled: ${name}`); + } + const attemptId = this.beginConnectAttempt(entry); + await this.closeClient(entry); + if (!this.isCurrent(entry, attemptId)) return; + entry.status = 'pending'; + entry.tools = undefined; + entry.enabledNames = undefined; + entry.error = undefined; + this.emit(entry); + await this.connectOne(entry, attemptId); + } + + async shutdown(): Promise { + const entries = Array.from(this.entries.values()); + this.entries.clear(); + const tasks = entries.map((entry) => this.closeClient(entry)); + await Promise.allSettled(tasks); + } + + private async connectOne(entry: InternalEntry, attemptId: number): Promise { + const timeoutMs = entry.config.startupTimeoutMs ?? DEFAULT_STARTUP_TIMEOUT_MS; + + let client: RuntimeMcpClient | undefined; + try { + const startupClient = this.createClient(entry.config, entry.name); + client = startupClient; + entry.client = startupClient; + const tools = await withTimeout( + this.connectAndDiscoverTools(startupClient), + timeoutMs, + () => { + // Best-effort cleanup if the startup promise is still racing. + void this.closeRuntimeClient(startupClient); + }, + ); + if (!this.isCurrent(entry, attemptId)) { + await this.closeRuntimeClient(startupClient); + return; + } + entry.tools = tools; + entry.enabledNames = computeEnabledNames(entry.config, tools); + entry.status = 'connected'; + this.watchForUnexpectedClose(entry, startupClient, attemptId); + } catch (error) { + if (!this.isCurrent(entry, attemptId)) { + if (client !== undefined) { + await this.closeRuntimeClient(client); + } + return; + } + if (this.shouldMarkNeedsAuth(entry, error)) { + entry.status = 'needs-auth'; + entry.error = `${entry.name} requires OAuth — run /mcp-config login ${entry.name}`; + } else { + entry.status = 'failed'; + entry.error = formatStartupError(error, client); + } + entry.tools = undefined; + entry.enabledNames = undefined; + // Drop the client reference so a later reconnect builds a fresh one. + await this.closeClient(entry); + } + if (!this.isCurrent(entry, attemptId)) return; + this.emit(entry); + } + + private watchForUnexpectedClose( + entry: InternalEntry, + client: RuntimeMcpClient, + attemptId: number, + ): void { + client.onUnexpectedClose((reason) => { + // The client may have outlived its entry (shutdown / reconnect already + // moved on). Drop the event if so — the new attempt owns the state. + if (!this.isCurrent(entry, attemptId)) return; + if (entry.client !== client) return; + entry.status = 'failed'; + entry.error = formatUnexpectedCloseError(entry.name, reason); + entry.tools = undefined; + entry.enabledNames = undefined; + entry.client = undefined; + // Best-effort close; the transport is already gone, but this lets the + // SDK release timers and pending request handlers. + void this.closeRuntimeClient(client); + this.emit(entry); + }); + } + + private beginConnectAttempt(entry: InternalEntry): number { + entry.attemptId += 1; + return entry.attemptId; + } + + private createClient(config: McpServerConfig, name: string): RuntimeMcpClient { + const toolCallTimeoutMs = config.toolTimeoutMs; + if (config.transport === 'stdio') { + return new StdioMcpClient(config, { toolCallTimeoutMs }); + } + return new HttpMcpClient(config, { + toolCallTimeoutMs, + envLookup: this.options.envLookup, + oauthProvider: this.resolveOAuthProvider(config, name), + }); + } + + private resolveOAuthProvider( + config: McpServerConfig, + name: string, + ): ReturnType | undefined { + const oauthService = this.oauthService; + if (oauthService === undefined) return undefined; + if (config.transport !== 'http') return undefined; + if (config.bearerTokenEnvVar !== undefined) return undefined; + // Only attach the provider once tokens have been minted; before that, + // the transport should propagate a clean 401 so we can flip the entry + // into `needs-auth` rather than getting tangled in the SDK's auth() + // flow (which would try DCR before we have an active redirect URL). + if (!oauthService.hasTokens(name, config.url)) return undefined; + return oauthService.getProvider(name, config.url); + } + + private shouldMarkNeedsAuth(entry: InternalEntry, error: unknown): boolean { + if (this.oauthService === undefined) return false; + if (entry.config.transport !== 'http') return false; + if (entry.config.bearerTokenEnvVar !== undefined) return false; + // If the user pinned a static `headers` block, treat 401s as a bad header + // rather than hijacking them into the OAuth flow — the real error is more + // actionable than "run /mcp-config login" for a server that doesn't speak + // OAuth. + if (entry.config.headers !== undefined) return false; + return isUnauthorizedLikeError(error); + } + + private async connectAndDiscoverTools(client: RuntimeMcpClient): Promise { + await client.connect(); + const mcpTools = await client.listTools(); + return mcpTools.map((mcpTool) => ({ + name: mcpTool.name, + description: mcpTool.description, + parameters: assertMcpInputSchema(mcpTool.name, mcpTool.inputSchema), + })); + } + + private async closeClient(entry: InternalEntry): Promise { + if (entry.client === undefined) return; + const client = entry.client; + entry.client = undefined; + await this.closeRuntimeClient(client); + } + + private async closeRuntimeClient(client: RuntimeMcpClient): Promise { + try { + await client.close(); + } catch { + // Suppress close errors — the server is going away regardless and we + // don't want them masking the original startup failure. + } + } + + private isCurrent(entry: InternalEntry, attemptId: number): boolean { + return this.entries.get(entry.name) === entry && entry.attemptId === attemptId; + } + + private emit(entry: InternalEntry): void { + const view = toPublicEntry(entry); + if (view.status === 'failed' || view.status === 'needs-auth') { + this.log.error('mcp server unavailable', { + server: view.name, + transport: view.transport, + status: view.status, + reason: view.error, + }); + } + for (const listener of this.listeners) { + try { + listener(view); + } catch { + // Listener faults must not break the connection manager. + } + } + } +} + +function toPublicEntry(entry: InternalEntry): McpServerEntry { + return { + name: entry.name, + transport: entry.config.transport, + status: entry.status, + toolCount: + entry.status === 'connected' && entry.enabledNames !== undefined + ? entry.enabledNames.size + : 0, + error: entry.error, + }; +} + +function computeEnabledNames(config: McpServerConfig, tools: readonly Tool[]): Set { + const all = tools.map((t) => t.name); + const enabledFilter = + config.enabledTools !== undefined ? new Set(config.enabledTools) : undefined; + const disabledFilter = + config.disabledTools !== undefined ? new Set(config.disabledTools) : undefined; + const allowed = new Set(); + for (const name of all) { + if (enabledFilter !== undefined && !enabledFilter.has(name)) continue; + if (disabledFilter !== undefined && disabledFilter.has(name)) continue; + allowed.add(name); + } + return allowed; +} + +function isUnauthorizedLikeError(error: unknown): boolean { + if (!(error instanceof Error)) return false; + if (error.name === 'UnauthorizedError') return true; + // SDK transport errors typically expose the HTTP status as `.code`. + const code = (error as { code?: unknown }).code; + if (typeof code === 'number' && code === 401) return true; + if (typeof code === 'string' && code === '401') return true; + // Fall back to a message sniff so server-specific error shapes still flip + // us into needs-auth instead of failed. + return /\b401\b/.test(error.message) || /unauthorized/i.test(error.message); +} + +function formatStartupError(error: unknown, client: RuntimeMcpClient | undefined): string { + const base = error instanceof Error ? error.message : String(error); + const tail = stderrTail(client); + if (tail === undefined) return base; + return `${base}\nstderr: ${tail}`; +} + +function formatUnexpectedCloseError(name: string, reason: UnexpectedCloseReason): string { + const parts = [`MCP server "${name}" closed unexpectedly`]; + if (reason.error !== undefined) { + parts.push(reason.error.message); + } + if (reason.stderr !== undefined && reason.stderr.length > 0) { + parts.push(`stderr: ${reason.stderr.trimEnd()}`); + } + return parts.join('\n'); +} + +function stderrTail(client: RuntimeMcpClient | undefined): string | undefined { + if (client === undefined) return undefined; + if (!(client instanceof StdioMcpClient)) return undefined; + const snapshot = client.stderrSnapshot(); + if (snapshot.length === 0) return undefined; + return snapshot.trimEnd(); +} + +async function withTimeout( + promise: Promise, + timeoutMs: number, + onTimeout?: () => void, +): Promise { + let timer: NodeJS.Timeout | undefined; + try { + return await new Promise((resolve, reject) => { + timer = setTimeout(() => { + onTimeout?.(); + reject(new Error(`Timed out after ${timeoutMs}ms`)); + }, timeoutMs); + promise.then(resolve, reject); + }); + } finally { + if (timer !== undefined) clearTimeout(timer); + } +} diff --git a/packages/agent-core/src/mcp/index.ts b/packages/agent-core/src/mcp/index.ts new file mode 100644 index 000000000..d0fef23f6 --- /dev/null +++ b/packages/agent-core/src/mcp/index.ts @@ -0,0 +1,5 @@ +export * from './connection-manager'; +export * from './oauth'; +export * from './session-config'; +export * from './tool-naming'; +export * from './types'; diff --git a/packages/agent-core/src/mcp/oauth/callback-server.ts b/packages/agent-core/src/mcp/oauth/callback-server.ts new file mode 100644 index 000000000..a1c902e28 --- /dev/null +++ b/packages/agent-core/src/mcp/oauth/callback-server.ts @@ -0,0 +1,164 @@ +/** + * One-shot localhost OAuth callback listener. + * + * `startCallbackServer()` binds 127.0.0.1 on a random free port and returns a + * handle exposing the resulting `redirect_uri` and an awaitable + * `waitForCode()` that resolves with `{ code, state }` from the first + * `/callback` request. Any subsequent requests get a generic 404 and a + * non-callback path is ignored. The server is closed automatically once a + * code has been delivered (or `close()` is called explicitly). + */ + +import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'node:http'; +import type { AddressInfo } from 'node:net'; + +export interface CallbackResult { + readonly code: string; + readonly state: string | undefined; +} + +export interface CallbackServer { + readonly redirectUri: string; + /** + * Resolves with the OAuth callback payload, or rejects when: + * - `signal` aborts → AbortError + * - `timeoutMs` elapses → Error('OAuth callback timed out') + * - the user's authorization server returns an error → Error('OAuth error: ') + */ + waitForCode(opts: { signal?: AbortSignal; timeoutMs?: number }): Promise; + close(): Promise; +} + +const SUCCESS_HTML = + 'Authorized' + + '' + + '

    Sign-in complete

    ' + + '

    You can close this tab and return to kimi-code.

    ' + + ''; + +const ERROR_HTML = + 'OAuth error' + + '' + + '

    Sign-in failed

    ' + + '

    The authorization server reported an error. Return to kimi-code for details.

    ' + + ''; + +export async function startCallbackServer(): Promise { + let resolveCode: ((value: CallbackResult) => void) | undefined; + let rejectCode: ((reason: Error) => void) | undefined; + let settled = false; + + const settle = (fn: () => void) => { + if (settled) return; + settled = true; + fn(); + }; + + const server: Server = createServer((req, res) => { + handle(req, res); + }); + + function handle(req: IncomingMessage, res: ServerResponse): void { + if (req.method !== 'GET' || req.url === undefined) { + res.writeHead(404).end(); + return; + } + let url: URL; + try { + url = new URL(req.url, 'http://localhost'); + } catch { + res.writeHead(404).end(); + return; + } + if (url.pathname !== '/callback') { + res.writeHead(404).end(); + return; + } + const errorParam = url.searchParams.get('error'); + if (errorParam !== null) { + const description = url.searchParams.get('error_description') ?? ''; + res.writeHead(400, { 'content-type': 'text/html; charset=utf-8' }).end(ERROR_HTML); + settle(() => { + rejectCode?.( + new Error(`OAuth error: ${errorParam}${description ? ` — ${description}` : ''}`), + ); + }); + return; + } + const code = url.searchParams.get('code'); + if (code === null || code.length === 0) { + res.writeHead(400, { 'content-type': 'text/html; charset=utf-8' }).end(ERROR_HTML); + settle(() => { + rejectCode?.(new Error('OAuth callback missing authorization code')); + }); + return; + } + const state = url.searchParams.get('state') ?? undefined; + res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' }).end(SUCCESS_HTML); + settle(() => { + resolveCode?.({ code, state }); + }); + } + + await new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(0, '127.0.0.1', () => { + server.off('error', reject); + resolve(); + }); + }); + const port = (server.address() as AddressInfo).port; + const redirectUri = `http://127.0.0.1:${port}/callback`; + + let closed = false; + const close = async () => { + if (closed) return; + closed = true; + await new Promise((resolve) => { + server.close(() => { + resolve(); + }); + }); + }; + + const waitForCode: CallbackServer['waitForCode'] = ({ signal, timeoutMs } = {}) => { + return new Promise((resolve, reject) => { + let timer: NodeJS.Timeout | undefined; + const onAbort = () => { + settle(() => + rejectCode?.( + signal?.reason instanceof Error ? signal.reason : new Error('OAuth flow aborted'), + ), + ); + }; + const cleanup = () => { + if (timer !== undefined) clearTimeout(timer); + signal?.removeEventListener('abort', onAbort); + }; + resolveCode = (value) => { + cleanup(); + void close(); + resolve(value); + }; + rejectCode = (reason) => { + cleanup(); + void close(); + reject(reason); + }; + if (timeoutMs !== undefined) { + timer = setTimeout(() => { + settle(() => rejectCode?.(new Error('OAuth callback timed out'))); + }, timeoutMs); + } + if (signal !== undefined) { + if (signal.aborted) { + onAbort(); + return; + } + signal.addEventListener('abort', onAbort, { once: true }); + } + }); + }; + + return { redirectUri, waitForCode, close }; +} diff --git a/packages/agent-core/src/mcp/oauth/index.ts b/packages/agent-core/src/mcp/oauth/index.ts new file mode 100644 index 000000000..87c98552a --- /dev/null +++ b/packages/agent-core/src/mcp/oauth/index.ts @@ -0,0 +1,4 @@ +export * from './callback-server'; +export * from './provider'; +export * from './service'; +export * from './store'; diff --git a/packages/agent-core/src/mcp/oauth/provider.ts b/packages/agent-core/src/mcp/oauth/provider.ts new file mode 100644 index 000000000..be90113e7 --- /dev/null +++ b/packages/agent-core/src/mcp/oauth/provider.ts @@ -0,0 +1,188 @@ +/** + * `OAuthClientProvider` implementation backed by per-MCP-server JSON files. + * + * One provider instance per server/resource identity. The provider: + * - Persists OAuth tokens, the registered DCR client info, and discovery + * state under `/credentials/mcp/-*.json` + * (mode 0600; default home is `~/.kimi-code`). + * - Captures the authorization URL when the SDK calls + * `redirectToAuthorization` — the {@link McpOAuthService} reads that field + * after the first `auth()` call returns `'REDIRECT'`. + * - Keeps the PKCE verifier and OAuth `state` in-memory (one flow per + * provider at a time; callers serialize via the service). + * + * The provider does **not** open browsers or run servers. The service is the + * orchestrator; the provider is the persistence + flow-state shim. + */ + +import { randomBytes } from 'node:crypto'; + +import type { + OAuthClientProvider, + OAuthDiscoveryState, +} from '@modelcontextprotocol/sdk/client/auth.js'; +import type { + OAuthClientInformationFull, + OAuthClientInformationMixed, + OAuthClientMetadata, + OAuthTokens, +} from '@modelcontextprotocol/sdk/shared/auth.js'; + +import { JsonFileStore, canonicalMcpOAuthResource, mcpOAuthStoreKey } from './store'; + +const TOKENS_SUFFIX = '-tokens.json'; +const CLIENT_SUFFIX = '-client.json'; +const DISCOVERY_SUFFIX = '-discovery.json'; +// Used only when the SDK probes auth during normal transport startup and no +// callback listener is active. Interactive login overrides it with a real URL. +const PASSIVE_REDIRECT_URI = 'http://127.0.0.1:3118/callback'; + +export interface McpOAuthProviderOptions { + /** Friendly name of the MCP server; used in DCR `client_name`. */ + readonly serverName: string; + /** Canonical resource identity used to isolate credentials for this server entry. */ + readonly serverUrl: string | URL; + /** JSON store used for persistence. Tests inject an in-memory dir. */ + readonly store: JsonFileStore; + /** Identifier embedded in DCR `client_name` ("kimi-code (server)"). */ + readonly clientLabel?: string; +} + +export class McpOAuthClientProvider implements OAuthClientProvider { + readonly storeKey: string; + readonly serverUrl: string; + private readonly store: JsonFileStore; + private readonly clientLabel: string; + private _redirectUrl: URL | undefined; + private _codeVerifier: string | undefined; + private _state: string | undefined; + private _lastAuthorizationUrl: URL | undefined; + + constructor(options: McpOAuthProviderOptions) { + this.serverUrl = canonicalMcpOAuthResource(options.serverUrl); + this.storeKey = mcpOAuthStoreKey(options.serverName, this.serverUrl); + this.store = options.store; + this.clientLabel = options.clientLabel ?? `kimi-code (${options.serverName})`; + } + + // ── flow-scoped state, set by McpOAuthService before invoking auth() ──── + + setRedirectUrl(url: URL): void { + this._redirectUrl = url; + } + + /** URL captured from the most recent `redirectToAuthorization` call. */ + takeAuthorizationUrl(): URL | undefined { + const url = this._lastAuthorizationUrl; + this._lastAuthorizationUrl = undefined; + return url; + } + + /** OAuth `state` value generated for the most recent flow, for callback verification. */ + expectedState(): string | undefined { + return this._state; + } + + resetFlow(): void { + this._redirectUrl = undefined; + this._codeVerifier = undefined; + this._state = undefined; + this._lastAuthorizationUrl = undefined; + } + + // ── OAuthClientProvider ───────────────────────────────────────────────── + + get redirectUrl(): string | URL { + return this.effectiveRedirectUri(); + } + + get clientMetadata(): OAuthClientMetadata { + return { + redirect_uris: [this.effectiveRedirectUri()], + token_endpoint_auth_method: 'none', + grant_types: ['authorization_code', 'refresh_token'], + response_types: ['code'], + client_name: this.clientLabel, + }; + } + + state(): string { + this._state ??= randomBytes(16).toString('hex'); + return this._state; + } + + clientInformation(): OAuthClientInformationMixed | undefined { + return this.store.read(`${this.storeKey}${CLIENT_SUFFIX}`); + } + + saveClientInformation(info: OAuthClientInformationMixed): void { + this.store.write(`${this.storeKey}${CLIENT_SUFFIX}`, info); + } + + tokens(): OAuthTokens | undefined { + return this.store.read(`${this.storeKey}${TOKENS_SUFFIX}`); + } + + saveTokens(tokens: OAuthTokens): void { + this.store.write(`${this.storeKey}${TOKENS_SUFFIX}`, tokens); + } + + redirectToAuthorization(url: URL): void { + // Capture the URL for the orchestrator instead of actually opening a + // browser. The synthetic authenticate tool surfaces it to the model so + // the user can complete the flow on their own schedule. + this._lastAuthorizationUrl = url; + } + + saveCodeVerifier(codeVerifier: string): void { + this._codeVerifier = codeVerifier; + } + + codeVerifier(): string { + if (this._codeVerifier === undefined) { + throw new Error('McpOAuthClientProvider: PKCE code verifier not initialized'); + } + return this._codeVerifier; + } + + saveDiscoveryState(state: OAuthDiscoveryState): void { + this.store.write(`${this.storeKey}${DISCOVERY_SUFFIX}`, state); + } + + discoveryState(): OAuthDiscoveryState | undefined { + return this.store.read(`${this.storeKey}${DISCOVERY_SUFFIX}`); + } + + invalidateCredentials(scope: 'all' | 'client' | 'tokens' | 'verifier' | 'discovery'): void { + if (scope === 'verifier') { + this._codeVerifier = undefined; + return; + } + if (scope === 'tokens' || scope === 'all') { + this.store.remove(`${this.storeKey}${TOKENS_SUFFIX}`); + } + if (scope === 'client' || scope === 'all') { + this.store.remove(`${this.storeKey}${CLIENT_SUFFIX}`); + } + if (scope === 'discovery' || scope === 'all') { + this.store.remove(`${this.storeKey}${DISCOVERY_SUFFIX}`); + } + if (scope === 'all') { + this._codeVerifier = undefined; + } + } + + private effectiveRedirectUri(): string { + if (this._redirectUrl !== undefined) { + return this._redirectUrl.toString(); + } + const registered = registeredRedirectUri(this.clientInformation()); + return registered ?? PASSIVE_REDIRECT_URI; + } +} + +function registeredRedirectUri(info: OAuthClientInformationMixed | undefined): string | undefined { + if (info === undefined || !('redirect_uris' in info)) return undefined; + const [redirectUri] = info.redirect_uris; + return redirectUri; +} diff --git a/packages/agent-core/src/mcp/oauth/service.ts b/packages/agent-core/src/mcp/oauth/service.ts new file mode 100644 index 000000000..9b2f807de --- /dev/null +++ b/packages/agent-core/src/mcp/oauth/service.ts @@ -0,0 +1,218 @@ +/** + * Per-process OAuth orchestrator for MCP HTTP servers. + * + * The service owns one {@link McpOAuthClientProvider} per server/resource and + * mediates the synthetic `mcp____authenticate` tool flow: + * + * 1. `getProvider(serverName, serverUrl)` returns the cached provider. + * `HttpMcpClient` hands this to `StreamableHTTPClientTransport.authProvider` + * only when the server has no static bearer token configured **and** the + * provider has stored tokens for that same server URL — first-time + * connections that lack tokens skip the provider entirely so a 401 surfaces + * as `UnauthorizedError` from the transport instead of being swallowed by an + * in-flight `auth()` attempt. + * 2. `beginAuthorization(serverName, serverUrl)` spins up a one-shot + * localhost callback listener, sets the redirect URL on the provider, + * and drives the SDK `auth()` orchestrator forward until it surfaces an + * authorization URL. It returns that URL plus a `complete()` callback + * that finishes the code exchange once the user finishes the browser + * flow. + * 3. After `complete()` resolves successfully the provider has tokens on + * disk; the caller (the synthetic tool) drives a manager-level + * `reconnect` to swap the synthetic tool out for the real MCP tools. + */ + +import { auth, type OAuthClientProvider } from '@modelcontextprotocol/sdk/client/auth.js'; + +import { startCallbackServer, type CallbackServer } from './callback-server'; +import { McpOAuthClientProvider } from './provider'; +import { JsonFileStore, mcpCredentialsDir, mcpOAuthStoreKey } from './store'; + +export interface McpOAuthServiceOptions { + /** Storage backend; overrides `kimiHomeDir` when supplied. */ + readonly store?: JsonFileStore; + /** Resolved Kimi home; credentials default to `/credentials/mcp/`. */ + readonly kimiHomeDir?: string; + /** Override for the label embedded in DCR `client_name`. */ + readonly clientLabel?: string; +} + +export interface BeginAuthorizationOptions { + /** Override the `client_name` embedded in the DCR registration request. */ + readonly clientLabel?: string; +} + +export interface BeginAuthorizationResult { + /** The authorization URL the user must open in their browser. */ + readonly authorizationUrl: URL; + /** + * Awaits the OAuth callback, validates `state`, exchanges the code for + * tokens, and persists them via the provider. Resolves on success; + * rejects on abort, timeout, or auth-server error. + */ + complete(opts?: { signal?: AbortSignal; timeoutMs?: number }): Promise; + /** + * Tears down the callback listener without finishing the flow. Safe to + * call repeatedly; called automatically by `complete()`. + */ + cancel(): Promise; +} + +export class McpOAuthService { + private readonly store: JsonFileStore; + private readonly clientLabel: string | undefined; + private readonly providers = new Map(); + + constructor(options: McpOAuthServiceOptions = {}) { + this.store = + options.store ?? + new JsonFileStore( + options.kimiHomeDir === undefined ? undefined : mcpCredentialsDir(options.kimiHomeDir), + ); + this.clientLabel = options.clientLabel; + } + + /** Returns the cached provider for `serverName` + `serverUrl`, constructing it on first use. */ + getProvider(serverName: string, serverUrl: string | URL): McpOAuthClientProvider { + const storeKey = mcpOAuthStoreKey(serverName, serverUrl); + let provider = this.providers.get(storeKey); + if (provider === undefined) { + provider = new McpOAuthClientProvider({ + serverName, + serverUrl, + store: this.store, + clientLabel: this.clientLabel, + }); + this.providers.set(provider.storeKey, provider); + } + return provider; + } + + /** True once the provider has persisted tokens for this server/resource identity. */ + hasTokens(serverName: string, serverUrl: string | URL): boolean { + return this.getProvider(serverName, serverUrl).tokens() !== undefined; + } + + /** + * Drive the SDK `auth()` orchestrator far enough to surface an + * authorization URL. The caller is responsible for displaying the URL + * (typically via the synthetic authenticate tool) and then awaiting + * `complete()` to finish the code exchange. + */ + async beginAuthorization( + serverName: string, + serverUrl: string | URL, + options: BeginAuthorizationOptions = {}, + ): Promise { + const provider = options.clientLabel === undefined + ? this.getProvider(serverName, serverUrl) + : new McpOAuthClientProvider({ + serverName, + serverUrl, + store: this.store, + clientLabel: options.clientLabel, + }); + if (options.clientLabel !== undefined) { + this.providers.set(provider.storeKey, provider); + } + + provider.resetFlow(); + + let callbackServer: CallbackServer; + try { + callbackServer = await startCallbackServer(); + } catch (error) { + throw wrapAuthError('failed to start OAuth callback listener', error); + } + + provider.setRedirectUrl(new URL(callbackServer.redirectUri)); + + let authorizationUrl: URL | undefined; + try { + const result = await auth(provider as OAuthClientProvider, { serverUrl }); + if (result !== 'REDIRECT') { + // Tokens already valid (e.g. unexpired refresh). Nothing to do. + await callbackServer.close(); + throw new AlreadyAuthorizedError(serverName); + } + authorizationUrl = provider.takeAuthorizationUrl(); + if (authorizationUrl === undefined) { + throw new Error('OAuth provider did not capture an authorization URL'); + } + } catch (error) { + await callbackServer.close().catch(() => undefined); + provider.resetFlow(); + if (error instanceof AlreadyAuthorizedError) throw error; + throw wrapAuthError(`failed to start OAuth flow for "${serverName}"`, error); + } + + let settled = false; + const cancel = async (): Promise => { + if (settled) return; + settled = true; + await callbackServer.close().catch(() => undefined); + provider.resetFlow(); + }; + + const complete: BeginAuthorizationResult['complete'] = async (opts = {}) => { + if (settled) { + throw new Error('OAuth flow already completed or cancelled'); + } + try { + const { code, state } = await callbackServer.waitForCode({ + signal: opts.signal, + timeoutMs: opts.timeoutMs, + }); + const expectedState = provider.expectedState(); + if (expectedState !== undefined && state !== expectedState) { + throw new Error('OAuth state mismatch — possible CSRF; refusing token exchange'); + } + const finalResult = await auth(provider as OAuthClientProvider, { + serverUrl, + authorizationCode: code, + }); + if (finalResult !== 'AUTHORIZED') { + throw new Error(`OAuth code exchange returned "${finalResult}" instead of AUTHORIZED`); + } + } catch (error) { + await cancel(); + throw wrapAuthError(`OAuth flow for "${serverName}" failed`, error); + } + settled = true; + await callbackServer.close().catch(() => undefined); + provider.resetFlow(); + }; + + return { authorizationUrl, complete, cancel }; + } + + /** + * Clear stored credentials for a server. Use `'all'` after the user + * explicitly signs out; use `'tokens'` to force a re-auth while keeping + * the registered DCR client. + */ + invalidate( + serverName: string, + serverUrl: string | URL, + scope: 'all' | 'client' | 'tokens' | 'discovery' = 'all', + ): void { + this.getProvider(serverName, serverUrl).invalidateCredentials(scope); + } +} + +/** Thrown by `beginAuthorization` when stored tokens already satisfy the server. */ +export class AlreadyAuthorizedError extends Error { + constructor(serverName: string) { + super(`"${serverName}" is already authorized; no browser flow needed`); + this.name = 'AlreadyAuthorizedError'; + } +} + +function wrapAuthError(prefix: string, error: unknown): Error { + if (error instanceof Error) { + const wrapped = new Error(`${prefix}: ${error.message}`); + wrapped.cause = error; + return wrapped; + } + return new Error(`${prefix}: ${String(error)}`); +} diff --git a/packages/agent-core/src/mcp/oauth/store.ts b/packages/agent-core/src/mcp/oauth/store.ts new file mode 100644 index 000000000..5c5e07204 --- /dev/null +++ b/packages/agent-core/src/mcp/oauth/store.ts @@ -0,0 +1,130 @@ +/** + * Small atomic JSON file store used by the MCP OAuth provider to persist + * tokens, registered client info, and discovery state under + * `/credentials/mcp/` (default + * `~/.kimi-code/credentials/mcp/`). + * + * Write semantics: write to `.tmp..` → fsync → rename. + * Atomic on POSIX; best-effort on Windows. Files land at mode 0600 (parent + * dir 0700) so other local users cannot read tokens. + * + * Read semantics: missing file → undefined. Corrupt JSON / wrong shape → + * undefined (never throws). The provider treats undefined as "not stored". + */ + +import { createHash, randomBytes } from 'node:crypto'; +import { + chmodSync, + closeSync, + fsyncSync, + mkdirSync, + openSync, + readFileSync, + renameSync, + unlinkSync, + writeSync, +} from 'node:fs'; +import { homedir } from 'node:os'; +import { basename, join } from 'node:path'; + +export function mcpCredentialsDir(kimiHomeDir: string): string { + return join(kimiHomeDir, 'credentials', 'mcp'); +} + +export function defaultMcpCredentialsDir(): string { + return mcpCredentialsDir(join(homedir(), '.kimi-code')); +} + +export function sanitizeStoreKey(name: string): string { + // Strip path-traversal segments. Tokens land under `-.json`, + // so the sanitized value must also be a single filename component. + const safe = basename(name).replaceAll(/[^a-zA-Z0-9_-]/g, '_').replaceAll(/_+/g, '_'); + if (safe.length === 0 || safe.startsWith('.')) { + throw new Error(`Invalid MCP OAuth store key: "${name}"`); + } + return safe; +} + +export function canonicalMcpOAuthResource(serverUrl: string | URL): string { + const url = new URL(serverUrl); + url.hash = ''; + return url.toString(); +} + +export function mcpOAuthStoreKey(serverName: string, serverUrl: string | URL): string { + const safeName = sanitizeStoreKey(serverName); + const resource = canonicalMcpOAuthResource(serverUrl); + const digest = createHash('sha256') + .update(serverName) + .update('\0') + .update(resource) + .digest('hex') + .slice(0, 24); + return `${safeName}-${digest}`; +} + +export class JsonFileStore { + private readonly dir: string; + + constructor(dir: string = defaultMcpCredentialsDir()) { + this.dir = dir; + } + + read(file: string): T | undefined { + const path = join(this.dir, file); + let raw: string; + try { + raw = readFileSync(path, 'utf-8'); + } catch { + return undefined; + } + try { + return JSON.parse(raw) as T; + } catch { + return undefined; + } + } + + write(file: string, data: unknown): void { + mkdirSync(this.dir, { recursive: true, mode: 0o700 }); + try { + chmodSync(this.dir, 0o700); + } catch { + // best-effort; Windows / read-only FS may refuse + } + const target = join(this.dir, file); + const tmp = `${target}.tmp.${process.pid}.${randomBytes(4).toString('hex')}`; + const buf = Buffer.from(`${JSON.stringify(data, null, 2)}\n`, 'utf-8'); + const fd = openSync(tmp, 'w', 0o600); + try { + let written = 0; + while (written < buf.length) { + written += writeSync(fd, buf, written, buf.length - written); + } + fsyncSync(fd); + } finally { + closeSync(fd); + } + try { + chmodSync(tmp, 0o600); + renameSync(tmp, target); + } catch (error) { + try { + unlinkSync(tmp); + } catch { + /* ignore */ + } + throw error; + } + } + + remove(file: string): void { + try { + unlinkSync(join(this.dir, file)); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { + throw error; + } + } + } +} diff --git a/packages/agent-core/src/mcp/output.ts b/packages/agent-core/src/mcp/output.ts new file mode 100644 index 000000000..9832b04d2 --- /dev/null +++ b/packages/agent-core/src/mcp/output.ts @@ -0,0 +1,258 @@ +/** + * MCP tool-call result → ExecutableTool output pipeline. + * + * Owns the full path from "MCP protocol content blocks" to "what the agent + * loop feeds back to the model": + * 1. Convert each {@link MCPContentBlock} to a kosong `ContentPart` + * (dropping unsupported shapes). + * 2. Wrap media-only outputs in `` tags so the + * model can attribute binary output when several tools return media. + * Mirrors the in-tree `ReadMediaFile` convention. + * 3. Apply size limits: text/think share a 100K character budget; binary + * parts (image/audio/video URLs) each carry an independent 10 MB cap and + * collapse to a notice when oversize, so a single screenshot cannot + * evict every text part. + * 4. Collapse a single-text-part result to a plain string output; otherwise + * emit the `ContentPart[]` as-is. + * + * `mcpResultToExecutableOutput` is the single entry point; the per-step + * helpers stay private so callers cannot bypass the limits. + */ + +import type { ContentPart } from '@moonshot-ai/kosong'; + +import type { MCPContentBlock, MCPToolResult } from './types'; + +// MCP servers can produce arbitrarily large outputs; cap what we feed back to +// the model so a single chatty server does not blow up the context window. The +// notice text is fed to the model verbatim so it can react (e.g. paginate), +// which is why the limits live in the agent layer rather than in kosong. +export const MCP_MAX_OUTPUT_CHARS = 100_000; +const MCP_OUTPUT_TRUNCATED_TEXT = `\n\n[Output truncated: exceeded ${String( + MCP_MAX_OUTPUT_CHARS, +)} character limit. Use pagination or more specific queries to get remaining content.]`; + +// Binary parts (image_url / audio_url / video_url) have an independent per-part +// byte cap and do NOT share the text character budget. base64 length is not a +// useful proxy for multimodal model cost, and a single screenshot is enough to +// evict every text part if both compete for the same 100k budget. +export const MCP_MAX_BINARY_PART_BYTES = 10 * 1024 * 1024; +const MCP_MAX_BINARY_PART_CHARS = Math.ceil((MCP_MAX_BINARY_PART_BYTES * 4) / 3); + +function binaryPartTooLargeNotice(kind: 'image' | 'audio' | 'video', urlLength: number): string { + const approxMb = ((urlLength * 3) / 4 / (1024 * 1024)).toFixed(1); + const capMb = String(MCP_MAX_BINARY_PART_BYTES / (1024 * 1024)); + return `[${kind}_url dropped: ~${approxMb} MB exceeds ${capMb} MB per-part limit. Try a smaller resource.]`; +} + +/** + * Convert a single MCP content block into a kosong {@link ContentPart}. + * + * Returns `null` for block types that cannot be represented (e.g. unknown + * resource shapes) so the caller can drop them. + */ +export function convertMCPContentBlock(block: MCPContentBlock): ContentPart | null { + if (block.type === 'text' && typeof block.text === 'string') { + return { type: 'text', text: block.text }; + } + + if (block.type === 'image' && typeof block.data === 'string') { + const mimeType = block.mimeType ?? 'image/png'; + return { + type: 'image_url', + imageUrl: { url: `data:${mimeType};base64,${block.data}` }, + }; + } + + if (block.type === 'audio' && typeof block.data === 'string') { + const mimeType = block.mimeType ?? 'audio/mpeg'; + return { + type: 'audio_url', + audioUrl: { url: `data:${mimeType};base64,${block.data}` }, + }; + } + + // EmbeddedResource: payload is nested under `resource`, as + // TextResourceContents (`text`) or BlobResourceContents (`blob`). + if (block.type === 'resource' && typeof block.resource === 'object' && block.resource !== null) { + const res = block.resource; + if (typeof res.text === 'string') { + return { type: 'text', text: res.text }; + } + if (typeof res.blob === 'string') { + const mimeType = res.mimeType ?? 'application/octet-stream'; + if (mimeType.startsWith('image/')) { + return { + type: 'image_url', + imageUrl: { url: `data:${mimeType};base64,${res.blob}` }, + }; + } + if (mimeType.startsWith('audio/')) { + return { + type: 'audio_url', + audioUrl: { url: `data:${mimeType};base64,${res.blob}` }, + }; + } + if (mimeType.startsWith('video/')) { + return { + type: 'video_url', + videoUrl: { url: `data:${mimeType};base64,${res.blob}` }, + }; + } + return null; + } + return null; + } + + // ResourceLink: URL reference, not an inline blob. + if (block.type === 'resource_link' && typeof block.uri === 'string') { + const mimeType = block.mimeType ?? 'application/octet-stream'; + if (mimeType.startsWith('image/')) { + return { type: 'image_url', imageUrl: { url: block.uri } }; + } + if (mimeType.startsWith('audio/')) { + return { type: 'audio_url', audioUrl: { url: block.uri } }; + } + if (mimeType.startsWith('video/')) { + return { type: 'video_url', videoUrl: { url: block.uri } }; + } + return null; + } + + return null; +} + +/** + * Convert an `MCPToolResult` into the success-shape `ExecutableToolResult` + * output the agent loop expects. + * + * `qualifiedToolName` is the agent-side qualified name (e.g. + * `mcp__github__create_pr`) — embedded into the `` + * wrap when the result is media-only, so the model can attribute binary parts. + */ +export function mcpResultToExecutableOutput( + result: MCPToolResult, + qualifiedToolName: string, +): { output: string | ContentPart[]; isError: boolean } { + const converted: ContentPart[] = []; + for (const block of result.content) { + const part = convertMCPContentBlock(block); + if (part !== null) { + converted.push(part); + } + } + + const wrapped = wrapMediaOnly(converted, qualifiedToolName); + const limited = applyOutputLimits(wrapped); + const output = collapseSingleText(limited); + return { output, isError: result.isError }; +} + +/** + * If `parts` contains media but no non-empty text, surround it with + * `` text tags so the model can attribute the + * binary content. Returns the input untouched otherwise. + */ +function wrapMediaOnly(parts: readonly ContentPart[], qualifiedToolName: string): ContentPart[] { + const hasMedia = parts.some( + (p) => p.type === 'image_url' || p.type === 'audio_url' || p.type === 'video_url', + ); + const hasNonEmptyText = parts.some((p) => p.type === 'text' && p.text.length > 0); + if (!hasMedia || hasNonEmptyText) return [...parts]; + return [ + { type: 'text', text: `` }, + ...parts, + { type: 'text', text: '' }, + ]; +} + +/** + * Apply the 100K text/think budget and the per-part 10 MB binary cap. + * + * When text/think parts get truncated, the truncation notice is appended to + * the last surviving text part — this keeps the single-text-part collapse + * working when the entire (oversized) input is a single text block. + */ +function applyOutputLimits(parts: readonly ContentPart[]): ContentPart[] { + let remaining = MCP_MAX_OUTPUT_CHARS; + let textTruncated = false; + const out: ContentPart[] = []; + + for (const part of parts) { + if (part.type === 'text') { + if (remaining <= 0) { + textTruncated = true; + continue; + } + if (part.text.length > remaining) { + out.push({ type: 'text', text: part.text.slice(0, remaining) }); + remaining = 0; + textTruncated = true; + } else { + out.push(part); + remaining -= part.text.length; + } + continue; + } + + if (part.type === 'think') { + const size = part.think.length + (part.encrypted?.length ?? 0); + if (remaining <= 0) { + textTruncated = true; + continue; + } + if (size > remaining) { + out.push({ type: 'think', think: part.think.slice(0, remaining) }); + remaining = 0; + textTruncated = true; + } else { + out.push(part); + remaining -= size; + } + continue; + } + + // image_url / audio_url / video_url: per-part byte cap, independent of the + // text character budget. Oversized parts collapse into a per-part notice so + // the model can pick a smaller resource instead of silently losing the blob. + const url = + part.type === 'image_url' + ? part.imageUrl.url + : part.type === 'audio_url' + ? part.audioUrl.url + : part.videoUrl.url; + if (url.length > MCP_MAX_BINARY_PART_CHARS) { + const kind = + part.type === 'image_url' ? 'image' : part.type === 'audio_url' ? 'audio' : 'video'; + out.push({ type: 'text', text: binaryPartTooLargeNotice(kind, url.length) }); + continue; + } + out.push(part); + } + + if (textTruncated) { + appendTruncationNotice(out); + } + return out; +} + +function appendTruncationNotice(out: ContentPart[]): void { + // Merge the notice into the last text part so the very common + // "single oversized text" case still collapses to a plain string. Falls + // back to a standalone notice part if there is no text part to merge with. + for (let i = out.length - 1; i >= 0; i--) { + const candidate = out[i]; + if (candidate?.type === 'text') { + out[i] = { type: 'text', text: candidate.text + MCP_OUTPUT_TRUNCATED_TEXT }; + return; + } + } + out.push({ type: 'text', text: MCP_OUTPUT_TRUNCATED_TEXT }); +} + +function collapseSingleText(parts: readonly ContentPart[]): string | ContentPart[] { + if (parts.length === 1 && parts[0]?.type === 'text') { + return parts[0].text; + } + return [...parts]; +} diff --git a/packages/agent-core/src/mcp/session-config.ts b/packages/agent-core/src/mcp/session-config.ts new file mode 100644 index 000000000..874de1ea1 --- /dev/null +++ b/packages/agent-core/src/mcp/session-config.ts @@ -0,0 +1,23 @@ +import type { McpServerConfig } from '#/config/schema'; + +import { loadMcpServers } from './config-loader'; + +export interface SessionMcpConfig { + readonly servers: Record; +} + +export interface ResolveSessionMcpConfigInput { + readonly cwd: string; + readonly homeDir?: string; +} + +export async function resolveSessionMcpConfig( + input: ResolveSessionMcpConfigInput, +): Promise { + const servers = await loadMcpServers({ + cwd: input.cwd, + homeDir: input.homeDir, + }); + if (Object.keys(servers).length === 0) return undefined; + return { servers }; +} diff --git a/packages/agent-core/src/mcp/tool-naming.ts b/packages/agent-core/src/mcp/tool-naming.ts new file mode 100644 index 000000000..87dca29a5 --- /dev/null +++ b/packages/agent-core/src/mcp/tool-naming.ts @@ -0,0 +1,49 @@ +const MCP_NAME_PREFIX = 'mcp__'; +const MCP_NAME_SEPARATOR = '__'; +/** + * Most LLM providers cap tool names around 64 characters. Leave headroom + * for the prefix and a separator and truncate longer names with a stable + * hash suffix so collisions remain extremely unlikely. + */ +const MAX_QUALIFIED_LENGTH = 64; + +/** + * Replace any character outside the safe ASCII set with `_`, then collapse + * any run of `_` into a single underscore. The collapse step guarantees neither the sanitized server + * nor tool name contains the `__` separator used by {@link qualifyMcpToolName}, + * which lets {@link isMcpToolName}-aware decoders split unambiguously on the + * first `__` after the prefix. + */ +export function sanitizeMcpNamePart(part: string): string { + return part.replaceAll(/[^a-zA-Z0-9_-]/g, '_').replaceAll(/_+/g, '_'); +} + +export function isMcpToolName(name: string): boolean { + return name.startsWith(MCP_NAME_PREFIX); +} + +/** + * Produce the qualified MCP tool name used inside the agent and on the wire. + * If the result would exceed {@link MAX_QUALIFIED_LENGTH}, a deterministic + * 8-char hash suffix replaces the tail so the prefix structure stays intact. + */ +export function qualifyMcpToolName(serverName: string, toolName: string): string { + const full = `${MCP_NAME_PREFIX}${sanitizeMcpNamePart(serverName)}${MCP_NAME_SEPARATOR}${sanitizeMcpNamePart(toolName)}`; + if (full.length <= MAX_QUALIFIED_LENGTH) return full; + + const hash = stableHash8(full); + const head = full.slice(0, MAX_QUALIFIED_LENGTH - hash.length - 1); + return `${head}_${hash}`; +} + +function stableHash8(input: string): string { + // 32-bit FNV-1a — enough to disambiguate truncated tool names within a + // single server's tool list. Not cryptographic; only used for collision + // resistance among a handful of strings. + let hash = 0x811c9dc5; + for (let i = 0; i < input.length; i++) { + hash ^= input.codePointAt(i)!; + hash = Math.trunc(Math.imul(hash, 0x01000193)); + } + return hash.toString(16).padStart(8, '0'); +} diff --git a/packages/agent-core/src/mcp/types.ts b/packages/agent-core/src/mcp/types.ts new file mode 100644 index 000000000..dee8cf4eb --- /dev/null +++ b/packages/agent-core/src/mcp/types.ts @@ -0,0 +1,105 @@ +/** + * MCP protocol types and the minimal client contract `ToolManager` consumes. + * + * Lives in its own file (rather than `toolset.ts`) because the agent-side + * tool-runtime layer is `ExecutableTool`, not the legacy `Toolset` interface. + * What remains here is the wire-level surface: tool definitions returned by + * `tools/list`, the `tools/call` result shape, and the small interface that + * lets tests inject a fake transport without pulling in the MCP SDK type graph. + */ + +/** + * Inline resource contents nested under an EmbeddedResource block. + * Exactly one of `text` or `blob` is populated, per the MCP schema's + * `TextResourceContents | BlobResourceContents` union. + */ +export interface MCPEmbeddedResourceContents { + uri: string; + mimeType?: string; + text?: string; + blob?: string; + [key: string]: unknown; +} + +/** + * A content block as returned by an MCP tool call (`tools/call`). + * + * This is a structural subset of the MCP protocol `ContentBlock` union, + * covering the shapes that {@link convertMCPContentBlock} knows how to convert + * into kosong `ContentPart`s. Additional fields are ignored. + */ +export interface MCPContentBlock { + // Known values: 'text' | 'image' | 'audio' | 'resource' | 'resource_link'. + // Declared as `string` to also accept future MCP content types without a + // type assertion. + type: string; + text?: string; + data?: string; + mimeType?: string; + uri?: string; + // EmbeddedResource carries its payload nested under `resource`, per the + // MCP spec — never as top-level `data`/`mimeType`. + resource?: MCPEmbeddedResourceContents; + [key: string]: unknown; +} + +/** + * Result of a single MCP tool invocation. + * + * Matches the shape returned by the MCP protocol's `tools/call` method. + */ +export interface MCPToolResult { + content: MCPContentBlock[]; + isError: boolean; +} + +/** + * An MCP tool definition as returned by an MCP server's `tools/list` method. + */ +export interface MCPToolDefinition { + name: string; + description: string; + inputSchema: unknown; +} + +/** + * Minimal MCP client interface consumed by {@link McpConnectionManager} and + * {@link ToolManager}. + * + * This is a transport-agnostic seam: implementations can wrap + * `@modelcontextprotocol/sdk`, a bespoke stdio client, an HTTP SSE client, + * or a mock for testing. Keeping the surface small lets tests inject fakes + * without pulling in the full SDK type graph. + */ +export interface MCPClient { + /** List the tools advertised by the MCP server. */ + listTools(): Promise; + /** + * Invoke a tool by name with the given JSON arguments. + * + * `signal`, when provided, is forwarded to the underlying transport so an + * abort from the loop (e.g. user cancellation) propagates all the way to + * the server instead of leaving the request running in the background. + */ + callTool( + name: string, + args: Record, + signal?: AbortSignal, + ): Promise; +} + +/** + * Validate the `inputSchema` field of an MCP tool definition. MCP advertises + * input schemas as JSON Schema objects; reject anything that is not a plain + * object so the validator compiler downstream never sees `null` or a + * primitive. + */ +export function assertMcpInputSchema( + toolName: string, + inputSchema: unknown, +): Record { + if (typeof inputSchema === 'object' && inputSchema !== null && !Array.isArray(inputSchema)) { + return inputSchema as Record; + } + throw new Error(`Invalid inputSchema for MCP tool "${toolName}": schema must be a JSON object`); +} diff --git a/packages/agent-core/src/profile/context.ts b/packages/agent-core/src/profile/context.ts new file mode 100644 index 000000000..ba22806fc --- /dev/null +++ b/packages/agent-core/src/profile/context.ts @@ -0,0 +1,192 @@ +import * as posixPath from 'node:path/posix'; +import * as win32Path from 'node:path/win32'; + +import type { Kaos } from '@moonshot-ai/kaos'; + +import { listDirectory } from '../tools/support/list-directory'; +import type { SystemPromptContext } from './types'; + +const AGENTS_MD_MAX_BYTES = 32 * 1024; +const S_IFMT = 0o170000; +const S_IFREG = 0o100000; + +export type PreparedSystemPromptContext = Pick< + SystemPromptContext, + 'cwd' | 'cwdListing' | 'agentsMd' +>; + +export function resolveSystemPromptCwd(kaos: Kaos, cwd: string): string { + return cwd === '' ? kaos.getcwd() : cwd; +} + +export async function prepareSystemPromptContext( + kaos: Kaos, + cwd: string, +): Promise { + const resolvedCwd = resolveSystemPromptCwd(kaos, cwd); + const [cwdListing, agentsMd] = await Promise.all([ + listDirectory(kaos, resolvedCwd), + loadAgentsMd(kaos, resolvedCwd), + ]); + + return { + cwd: resolvedCwd, + cwdListing, + agentsMd, + }; +} + +export async function loadAgentsMd(kaos: Kaos, workDir: string): Promise { + const projectRoot = await findProjectRoot(kaos, workDir); + const dirs = dirsRootToLeaf(kaos, workDir, projectRoot); + const discovered: AgentFile[] = []; + const seen = new Set(); + + const collect = async (path: string): Promise => { + const file = await readAgentFile(kaos, path); + if (file === undefined) return false; + const key = kaos.normpath(file.path); + if (seen.has(key)) return false; + seen.add(key); + discovered.push(file); + return true; + }; + + // User-level files come first so any project-level AGENTS.md overrides them. + const home = kaos.gethome(); + await collect(joinPath(kaos, home, '.kimi-code', 'AGENTS.md')); + + // Generic user-level dir (.agents) matches skill discovery. + const genericDirs = [joinPath(kaos, home, '.agents')]; + const genericFiles = genericDirs.flatMap((dir) => + ['AGENTS.md', 'agents.md'].map((name) => joinPath(kaos, dir, name)), + ); + for (const file of genericFiles) { + if (await collect(file)) break; + } + + for (const dir of dirs) { + await collect(joinPath(kaos, dir, '.kimi-code', 'AGENTS.md')); + for (const fileName of ['AGENTS.md', 'agents.md']) { + if (await collect(joinPath(kaos, dir, fileName))) break; + } + } + + return renderAgentFiles(discovered); +} + +async function findProjectRoot(kaos: Kaos, workDir: string): Promise { + const path = pathMod(kaos); + const initial = kaos.normpath(workDir); + let current = initial; + + while (true) { + if (await pathExists(kaos, path.join(current, '.git'))) return current; + const parent = path.dirname(current); + if (parent === current) return initial; + current = parent; + } +} + +function dirsRootToLeaf(kaos: Kaos, workDir: string, projectRoot: string): string[] { + const path = pathMod(kaos); + const dirs: string[] = []; + let current = kaos.normpath(workDir); + + while (true) { + dirs.push(current); + if (current === projectRoot) break; + const parent = path.dirname(current); + if (parent === current) break; + current = parent; + } + + return dirs.toReversed(); +} + +interface AgentFile { + readonly path: string; + readonly content: string; +} + +async function readAgentFile(kaos: Kaos, path: string): Promise { + if (!(await isFile(kaos, path))) return undefined; + const content = (await kaos.readText(path, { errors: 'ignore' })).trim(); + if (content.length === 0) return undefined; + return { path, content }; +} + +async function pathExists(kaos: Kaos, path: string): Promise { + try { + await kaos.stat(path); + return true; + } catch { + return false; + } +} + +async function isFile(kaos: Kaos, path: string): Promise { + try { + const stat = await kaos.stat(path); + return (stat.stMode & S_IFMT) === S_IFREG; + } catch { + return false; + } +} + +function renderAgentFiles(files: readonly AgentFile[]): string { + if (files.length === 0) return ''; + + let remaining = AGENTS_MD_MAX_BYTES; + const budgeted: Array = Array.from({ length: files.length }); + + for (let i = files.length - 1; i >= 0; i--) { + const file = files[i]; + if (file === undefined) continue; + + const annotation = annotationFor(file.path); + const separator = i < files.length - 1 ? '\n\n' : ''; + remaining -= byteLength(annotation) + byteLength(separator); + if (remaining <= 0) { + budgeted[i] = { path: file.path, content: '' }; + remaining = 0; + continue; + } + + let content = file.content; + if (byteLength(content) > remaining) { + content = truncateUtf8(content, remaining).trim(); + } + remaining -= byteLength(content); + budgeted[i] = { path: file.path, content }; + } + + return budgeted + .filter((file): file is AgentFile => file !== undefined && file.content.length > 0) + .map((file) => `${annotationFor(file.path)}${file.content}`) + .join('\n\n'); +} + +function truncateUtf8(text: string, maxBytes: number): string { + let result = text; + while (byteLength(result) > maxBytes) { + result = result.slice(0, -1); + } + return result; +} + +function byteLength(text: string): number { + return Buffer.byteLength(text, 'utf8'); +} + +function annotationFor(path: string): string { + return `\n`; +} + +function joinPath(kaos: Kaos, ...parts: string[]): string { + return pathMod(kaos).join(...parts); +} + +function pathMod(kaos: Kaos): typeof posixPath { + return kaos.pathClass() === 'win32' ? win32Path : posixPath; +} diff --git a/packages/agent-core/src/profile/default.ts b/packages/agent-core/src/profile/default.ts new file mode 100644 index 000000000..d3ce92304 --- /dev/null +++ b/packages/agent-core/src/profile/default.ts @@ -0,0 +1,26 @@ +import agentYaml from './default/agent.yaml'; +import coderYaml from './default/coder.yaml'; +import exploreYaml from './default/explore.yaml'; +import initMd from './default/init.md'; +import planYaml from './default/plan.yaml'; +import systemMd from './default/system.md'; +import { loadAgentProfilesFromSources } from './load'; + +// Keyed by the source path the profile loader expects: profile YAML files +// plus any file referenced through `systemPromptPath`. +const PROFILE_SOURCES: Record = { + 'profile/default/agent.yaml': agentYaml, + 'profile/default/coder.yaml': coderYaml, + 'profile/default/explore.yaml': exploreYaml, + 'profile/default/plan.yaml': planYaml, + 'profile/default/system.md': systemMd, +}; + +export const DEFAULT_INIT_PROMPT = initMd; + +export const DEFAULT_AGENT_PROFILES = loadAgentProfilesFromSources( + ['agent.yaml', 'coder.yaml', 'explore.yaml', 'plan.yaml'].map( + (file) => `profile/default/${file}`, + ), + PROFILE_SOURCES, +); diff --git a/packages/agent-core/src/profile/default/agent.yaml b/packages/agent-core/src/profile/default/agent.yaml new file mode 100644 index 000000000..0c0490bec --- /dev/null +++ b/packages/agent-core/src/profile/default/agent.yaml @@ -0,0 +1,35 @@ +name: agent +description: Default Kimi Code agent + +systemPromptPath: ./system.md +promptVars: + roleAdditional: '' + +tools: + - Read + - Write + - Edit + - Grep + - Glob + - Bash + - TaskList + - TaskOutput + - TaskStop + - ReadMediaFile + - TodoList + - Skill + - WebSearch + - Agent + - FetchURL + - AskUserQuestion + - EnterPlanMode + - ExitPlanMode + - mcp__* + +subagents: + coder: + description: Good at general software engineering tasks. + explore: + description: Fast codebase exploration with prompt-enforced read-only behavior. + plan: + description: Read-only implementation planning and architecture design. diff --git a/packages/agent-core/src/profile/default/coder.yaml b/packages/agent-core/src/profile/default/coder.yaml new file mode 100644 index 000000000..22780c65c --- /dev/null +++ b/packages/agent-core/src/profile/default/coder.yaml @@ -0,0 +1,18 @@ +extends: agent +name: coder +promptVars: + roleAdditional: | + You are now running as a subagent. All the `user` messages are sent by the main agent. The main agent cannot see your context, it can only see your last message when you finish the task. You must treat the parent agent as your caller. Do not directly ask the end user questions. If something is unclear, explain the ambiguity in your final summary to the parent agent. +whenToUse: | + Use this agent for non-trivial software engineering work that may require reading files, editing code, running commands, and returning a compact but technically complete summary to the parent agent. +tools: + - Bash + - Read + - ReadMediaFile + - Glob + - Grep + - Write + - Edit + - WebSearch + - FetchURL + - mcp__* diff --git a/packages/agent-core/src/profile/default/explore.yaml b/packages/agent-core/src/profile/default/explore.yaml new file mode 100644 index 000000000..84d024e58 --- /dev/null +++ b/packages/agent-core/src/profile/default/explore.yaml @@ -0,0 +1,36 @@ +extends: agent +name: explore +promptVars: + roleAdditional: | + You are now running as a subagent. All the `user` messages are sent by the main agent. The main agent cannot see your context, it can only see your last message when you finish the task. You must treat the parent agent as your caller. Do not directly ask the end user questions. If something is unclear, explain the ambiguity in your final summary to the parent agent. + + You are a codebase exploration specialist. Your role is EXCLUSIVELY to search, read, and analyze existing code and resources. You do NOT have access to file editing tools. + + Your strengths: + - Rapidly finding files using glob patterns + - Searching code and text with powerful regex patterns + - Reading and analyzing file contents + - Running read-only shell commands (git log, git diff, ls, find, etc.) + + Guidelines: + - Use Glob for broad file pattern matching. Patterns MUST contain a literal anchor (extension or subdirectory); pure wildcards like `*` or `**/*` are rejected by the tool. + - Use Grep for searching file contents with regex + - Use Read when you know the specific file path + - Use Bash ONLY for read-only operations (ls, git status, git log, git diff, find) + - NEVER use Bash for any file creation or modification commands + - Adapt your search depth based on the thoroughness level specified by the caller + - Wherever possible, spawn multiple parallel tool calls for grepping and reading files to maximize speed + + If the prompt includes a block, use it to orient yourself about the repository state before starting your investigation. + + You are meant to be a fast agent. Complete the search request efficiently and report your findings clearly in a structured format. +whenToUse: | + Fast agent specialized for exploring codebases. Use this when you need to quickly find files by patterns (e.g. "src/**/*.yaml"), search code for keywords (e.g. "database connection"), or answer questions about the codebase (e.g. "how does the auth module work?"). When calling this agent, specify the desired thoroughness level: "quick" for basic searches, "medium" for moderate exploration, or "thorough" for comprehensive analysis across multiple locations and naming conventions. Use this agent for any read-only exploration that will clearly require more than 3 search queries. Prefer launching multiple explore agents concurrently when investigating independent questions. +tools: + - Bash + - Read + - ReadMediaFile + - Glob + - Grep + - WebSearch + - FetchURL diff --git a/packages/agent-core/src/profile/default/init.md b/packages/agent-core/src/profile/default/init.md new file mode 100644 index 000000000..25d6c561a --- /dev/null +++ b/packages/agent-core/src/profile/default/init.md @@ -0,0 +1,21 @@ +You are a software engineering expert with many years of programming experience. Please explore the current project directory to understand the project's architecture and main details. + +Task requirements: +1. Analyze the project structure and identify key configuration files (such as pyproject.toml, package.json, Cargo.toml, etc.). +2. Understand the project's technology stack, build process and runtime architecture. +3. Identify how the code is organized and main module divisions. +4. Discover project-specific development conventions, testing strategies, and deployment processes. + +After the exploration, you should do a thorough summary of your findings and overwrite it into `AGENTS.md` file in the project root. You need to refer to what is already in the file when you do so. + +For your information, `AGENTS.md` is a file intended to be read by AI coding agents. Expect the reader of this file know nothing about the project. + +You should compose this file according to the actual project content. Do not make any assumptions or generalizations. Ensure the information is accurate and useful. You must use the natural language that is mainly used in the project's comments and documentation. + +Popular sections that people usually write in `AGENTS.md` are: + +- Project overview +- Build and test commands +- Code style guidelines +- Testing instructions +- Security considerations diff --git a/packages/agent-core/src/profile/default/plan.yaml b/packages/agent-core/src/profile/default/plan.yaml new file mode 100644 index 000000000..beb3e1c2d --- /dev/null +++ b/packages/agent-core/src/profile/default/plan.yaml @@ -0,0 +1,19 @@ +extends: agent +name: plan +promptVars: + roleAdditional: | + You are now running as a subagent. All the `user` messages are sent by the main agent. The main agent cannot see your context, it can only see your last message when you finish the task. You must treat the parent agent as your caller. Do not directly ask the end user questions. If something is unclear, explain the ambiguity in your final summary to the parent agent. + + Before designing your implementation plan, consider whether you fully understand the codebase areas relevant to the task. If not, recommend the parent agent to use the explore agent (subagent_type="explore") to investigate key questions first. In your response, clearly state: + 1. What you already know from the information provided + 2. What questions remain unanswered that would benefit from explore agent investigation + 3. Your implementation plan (either preliminary if questions remain, or final if sufficient context exists) +whenToUse: | + Use this agent when the parent agent needs a step-by-step implementation plan, key file identification, and architectural trade-off analysis before code changes are made. +tools: + - Read + - ReadMediaFile + - Glob + - Grep + - WebSearch + - FetchURL diff --git a/packages/agent-core/src/profile/default/system.md b/packages/agent-core/src/profile/default/system.md new file mode 100644 index 000000000..0436f944b --- /dev/null +++ b/packages/agent-core/src/profile/default/system.md @@ -0,0 +1,165 @@ +You are Kimi Code CLI, an interactive general AI agent running on a user's computer. + +Your primary goal is to help users with software engineering tasks by taking action — use the tools available to you to make real changes on the user's system. You should also answer questions when asked. Always adhere strictly to the following system instructions and the user's requirements. + +{{ ROLE_ADDITIONAL }} + +# Prompt and Tool Use + +The user's messages may contain questions and/or task descriptions in natural language, code snippets, logs, file paths, or other forms of information. Read them, understand them and do what the user requested. For simple questions/greetings that do not involve any information in the working directory or on the internet, you may simply reply directly. For anything else, default to taking action with tools. When the request could be interpreted as either a question to answer or a task to complete, treat it as a task. + +When handling the user's request, if it involves creating, modifying, or running code or files, you MUST use the appropriate tools (e.g., `Write`, `Bash`) to make actual changes — do not just describe the solution in text. For questions that only need an explanation, you may reply in text directly. When calling tools, do not provide explanations because the tool calls themselves should be self-explanatory. You MUST follow the description of each tool and its parameters when calling tools. + +If the `Agent` tool is available, you can use it to delegate a focused subtask to a subagent instance. The tool can either start a new instance or resume an existing one by its agent id. Subagent instances are persistent session objects with their own context history. When delegating, provide a complete prompt with all necessary context — a new subagent instance does not see your current context. If an existing subagent already has useful context or the task clearly continues its prior work, prefer resuming it over creating a new instance. Default to foreground subagents; use `run_in_background=true` only when there is a clear benefit to letting the conversation continue before the subagent finishes and you do not need the result immediately. + +You have the capability to output any number of tool calls in a single response. If you anticipate making multiple non-interfering tool calls, you are HIGHLY RECOMMENDED to make them in parallel to significantly improve efficiency. This is very important to your performance. + +The results of the tool calls will be returned to you in a tool message. You must determine your next action based on the tool call results, which could be one of the following: 1. Continue working on the task, 2. Inform the user that the task is completed or has failed, or 3. Ask the user for more information. + +The system may insert information wrapped in `` tags within user or tool messages. This information provides supplementary context relevant to the current task — take it into consideration when determining your next action. + +Tool results and user messages may also include `` tags. Unlike `` tags, these are **authoritative system directives** that you MUST follow. They bear no direct relation to the specific tool results or user messages in which they appear. Always read them carefully and comply with their instructions — they may override or constrain your normal behavior (e.g., restricting you to read-only actions during plan mode). + +If the `Bash`, `TaskList`, `TaskOutput`, and `TaskStop` tools are available and you are the root agent, you can use background `Bash` for long-running shell commands. Launch it via `Bash` with `run_in_background=true` and a short `description`. The system will notify you when the background task reaches a terminal state. Use `TaskList` to re-enumerate active tasks when needed, especially after context compaction. Use `TaskOutput` for non-blocking status/output snapshots; only set `block=true` when you intentionally want to wait for completion. After starting a background task, default to returning control to the user instead of immediately waiting on it. Use `TaskStop` only when you need to cancel the task. For human users in the interactive shell, the only task-management slash command is `/tasks`. Do not tell users to run `/task`, `/tasks list`, `/tasks output`, `/tasks stop`, or any other invented slash subcommands. If you are a subagent or these tools are not available, do not assume you can create or control background tasks. + +If a foreground tool call or a background agent requests approval, the approval is coordinated through the unified approval runtime and surfaced through the root UI channel. Do not assume approvals are local to a single subagent turn. + +When responding to the user, you MUST use the SAME language as the user, unless explicitly instructed to do otherwise. + +# General Guidelines for Coding + +When building something from scratch, you should: + +- Understand the user's requirements. +- Ask the user for clarification if there is anything unclear. +- Design the architecture and make a plan for the implementation. +- Write the code in a modular and maintainable way. + +Always use tools to implement your code changes: + +- Use `Write` to create or overwrite source files. Code that only appears in your text response is NOT saved to the file system and will not take effect. +- Use `Bash` to run and test your code after writing it. +- Iterate: if tests fail, read the error, fix the code with `Write` or `Edit`, and re-test with `Bash`. + +When working on an existing codebase, you should: + +- Understand the codebase by reading it with tools (`Read`, `Glob`, `Grep`) before making changes. Identify the ultimate goal and the most important criteria to achieve the goal. +- When using `Glob`, include a literal anchor (file extension or subdirectory) in the pattern. Pure wildcards like `*` or `**/*` are rejected by the tool. +- For a bug fix, you typically need to check error logs or failed tests, scan over the codebase to find the root cause, and figure out a fix. If user mentioned any failed tests, you should make sure they pass after the changes. +- For a feature, you typically need to design the architecture, and write the code in a modular and maintainable way, with minimal intrusions to existing code. Add new tests if the project already has tests. +- For a code refactoring, you typically need to update all the places that call the code you are refactoring if the interface changes. DO NOT change any existing logic especially in tests, focus only on fixing any errors caused by the interface changes. +- Make MINIMAL changes to achieve the goal. This is very important to your performance. +- Follow the coding style of existing code in the project. +- For broader codebase exploration and deep research, use `Agent` with `subagent_type="explore"` — a fast, read-only agent specialized for searching and understanding codebases. Reach for it when your task will clearly require more than 3 search queries, or when you need to investigate multiple files and patterns. Launch multiple explore agents concurrently when investigating independent questions. + +DO NOT run `git commit`, `git push`, `git reset`, `git rebase` and/or do any other git mutations unless explicitly asked to do so. Ask for confirmation each time when you need to do git mutations, even if the user has confirmed in earlier conversations. + +# General Guidelines for Research and Data Processing + +The user may ask you to research on certain topics, process or generate certain multimedia files. When doing such tasks, you must: + +- Understand the user's requirements thoroughly, ask for clarification before you start if needed. +- Make plans before doing deep or wide research, to ensure you are always on track. +- Search on the Internet if possible, with carefully-designed search queries to improve efficiency and accuracy. +- Use proper tools or shell commands or Python packages to process or generate images, videos, PDFs, docs, spreadsheets, presentations, or other multimedia files. Detect if there are already such tools in the environment. If you have to install third-party tools/packages, you MUST ensure that they are installed in a virtual/isolated environment. +- Once you generate or edit any images, videos or other media files, try to read it again before proceed, to ensure that the content is as expected. +- Avoid installing or deleting anything to/from outside of the current working directory. If you have to do so, ask the user for confirmation. + +# Working Environment + +## Operating System + +You are running on **{{ KIMI_OS }}**. The Bash tool executes commands using **{{ KIMI_SHELL }}**. +{% if KIMI_OS == "Windows" %} + +IMPORTANT: You are on Windows. The Bash tool runs through Git Bash, so use Unix shell syntax inside Bash commands — `/dev/null` not `NUL`, and forward slashes in paths. For file operations, always prefer the built-in tools (Read, Write, Edit, Glob, Grep) over Bash commands — they work reliably across all platforms. +{% endif %} + +The operating environment is not in a sandbox. Any actions you do will immediately affect the user's system. So you MUST be extremely cautious. Unless being explicitly instructed to do so, you should never access (read/write/execute) files outside of the working directory. + +## Date and Time + +The current date and time in ISO format is `{{ KIMI_NOW }}`. This is only a reference for you when searching the web, or checking file modification time, etc. If you need the exact time, use Bash tool with proper command. + +## Working Directory + +The current working directory is `{{ KIMI_WORK_DIR }}`. This should be considered as the project root if you are instructed to perform tasks on the project. Every file system operation will be relative to the working directory if you do not explicitly specify the absolute path. Tools may require absolute paths for some parameters, IF SO, YOU MUST use absolute paths for these parameters. + +The directory listing of current working directory is: + +``` +{{ KIMI_WORK_DIR_LS }} +``` + +Use this as your basic understanding of the project structure. The tree only shows the first two levels; entries marked "... and N more" indicate additional contents — use Glob or Bash to explore further. +{% if KIMI_ADDITIONAL_DIRS_INFO %} + +## Additional Directories + +The following directories have been added to the workspace. You can read, write, search, and glob files in these directories as part of your workspace scope. + +{{ KIMI_ADDITIONAL_DIRS_INFO }} +{% endif %} + +# Project Information + +Markdown files named `AGENTS.md` usually contain the background, structure, coding styles, user preferences and other relevant information about the project. You should use this information to understand the project and the user's preferences. `AGENTS.md` files may exist at different locations in the project, but typically there is one in the project root. + +> Why `AGENTS.md`? +> +> `README.md` files are for humans: quick starts, project descriptions, and contribution guidelines. `AGENTS.md` complements this by containing the extra, sometimes detailed context coding agents need: build steps, tests, and conventions that might clutter a README or aren’t relevant to human contributors. +> +> We intentionally kept it separate to: +> +> - Give agents a clear, predictable place for instructions. +> - Keep `README`s concise and focused on human contributors. +> - Provide precise, agent-focused guidance that complements existing `README` and docs. + +The `AGENTS.md` instructions (merged from all applicable directories): + +````````` +{{ KIMI_AGENTS_MD }} +````````` + +`AGENTS.md` files can appear at any level of the project directory tree, including inside `.kimi-code/` directories. Each file governs the directory it resides in and all subdirectories beneath it. When multiple `AGENTS.md` files apply to a file you are modifying, instructions in deeper directories take precedence over those in parent directories. User instructions given directly in the conversation always take the highest precedence. + +When working on files in subdirectories, always check whether those directories contain their own `AGENTS.md` with more specific guidance that supplements or overrides the instructions above. You may also check `README`/`README.md` files for more information about the project. + +If you modified any files/styles/structures/configurations/workflows/... mentioned in `AGENTS.md` files, you MUST update the corresponding `AGENTS.md` files to keep them up-to-date. + +# Skills + +Skills are reusable, composable capabilities that enhance your abilities. Each skill is either a self-contained directory with a `SKILL.md` file or a standalone `.md` file that contains instructions, examples, and/or reference material. + +## What are skills? + +Skills are modular extensions that provide: + +- Specialized knowledge: Domain-specific expertise (e.g., PDF processing, data analysis) +- Workflow patterns: Best practices for common tasks +- Tool integrations: Pre-configured tool chains for specific operations +- Reference material: Documentation, templates, and examples + +## Available skills + +Skills are grouped by scope (`Project`, `User`, `Extra`, `Built-in`) so you can tell where each came from. When the user refers to "the skill in this project" or "the user-scope skill", use the scope heading to disambiguate. When multiple scopes define a skill with the same name, the more specific scope takes precedence: **Project overrides User overrides Extra overrides Built-in**. + +{{ KIMI_SKILLS }} + +## How to use skills + +Identify the skills that are likely to be useful for the tasks you are currently working on, read the skill file for detailed instructions, guidelines, scripts and more. + +Only read skill details when needed to conserve the context window. + +# Ultimate Reminders + +At any time, you should be HELPFUL, CONCISE, and ACCURATE. Be thorough in your actions — test what you build, verify what you change — not in your explanations. + +- Never diverge from the requirements and the goals of the task you work on. Stay on track. +- Never give the user more than what they want. +- Try your best to avoid any hallucination. Do fact checking before providing any factual information. +- Think about the best approach, then take action decisively. +- Do not give up too early. +- ALWAYS, keep it stupidly simple. Do not overcomplicate things. +- When the task requires creating or modifying files, always use tools to do so. Never treat displaying code in your response as a substitute for actually writing it to the file system. diff --git a/packages/agent-core/src/profile/index.ts b/packages/agent-core/src/profile/index.ts new file mode 100644 index 000000000..80e303814 --- /dev/null +++ b/packages/agent-core/src/profile/index.ts @@ -0,0 +1,5 @@ +export * from './types'; +export * from './context'; +export * from './load'; +export * from './resolve'; +export * from './default'; diff --git a/packages/agent-core/src/profile/load.ts b/packages/agent-core/src/profile/load.ts new file mode 100644 index 000000000..613edcf63 --- /dev/null +++ b/packages/agent-core/src/profile/load.ts @@ -0,0 +1,125 @@ +import { readFile } from 'node:fs/promises'; +import { dirname, join, posix as posixPath } from 'node:path'; + +import { load as loadYaml } from 'js-yaml'; + +import { resolveAgentProfiles } from './resolve'; +import { RawAgentProfileSchema, type RawAgentProfile, type ResolvedAgentProfile } from './types'; + +export async function loadAgentProfilesFromDir( + paths: readonly string[], +): Promise> { + const rawProfiles = await loadRawAgentProfiles(paths); + return resolveAgentProfiles(rawProfiles); +} + +export function loadAgentProfilesFromSources( + paths: readonly string[], + sources: Readonly>, +): Record { + const rawProfiles = paths.map((profilePath) => + finalizeRawAgentProfileSource(readRequiredSource(sources, profilePath), profilePath, sources), + ); + return resolveAgentProfiles(rawProfiles); +} + +async function loadRawAgentProfiles(paths: readonly string[]): Promise { + const profiles: RawAgentProfile[] = []; + + for (const profilePath of paths) { + let content: string; + try { + content = await readFile(profilePath, 'utf-8'); + } catch (error) { + if (isFileNotFound(error)) continue; + throw readError('agent profile', profilePath, error); + } + profiles.push(await finalizeRawAgentProfile(content, profilePath)); + } + + return profiles; +} + +async function finalizeRawAgentProfile( + content: string, + profilePath: string, +): Promise { + const raw = parseAgentProfileYaml(content, profilePath); + if (raw.systemPromptPath === undefined) return raw; + const templatePath = join(dirname(profilePath), raw.systemPromptPath); + try { + return { ...raw, systemPromptTemplate: await readFile(templatePath, 'utf-8') }; + } catch (error) { + throw new Error( + `Failed to read system prompt template for "${raw.name}" at ${templatePath}: ${ + error instanceof Error ? error.message : String(error) + }`, + { cause: error }, + ); + } +} + +function finalizeRawAgentProfileSource( + content: string, + profilePath: string, + sources: Readonly>, +): RawAgentProfile { + const raw = parseAgentProfileYaml(content, profilePath); + if (raw.systemPromptPath === undefined) return raw; + const templatePath = resolveProfileSourcePath(profilePath, raw.systemPromptPath); + return { ...raw, systemPromptTemplate: readRequiredSource(sources, templatePath) }; +} + +function parseAgentProfileYaml(content: string, profilePath: string): RawAgentProfile { + let parsed: unknown; + try { + parsed = loadYaml(content); + } catch (error) { + throw new Error( + `Invalid agent profile YAML at ${profilePath}: ${ + error instanceof Error ? error.message : String(error) + }`, + { cause: error }, + ); + } + const result = RawAgentProfileSchema.safeParse(parsed); + if (!result.success) { + throw new Error(`Invalid agent profile at ${profilePath}`); + } + return result.data; +} + +function resolveProfileSourcePath(profilePath: string, relativePath: string): string { + return normalizeSourcePath( + posixPath.join(posixPath.dirname(normalizeSourcePath(profilePath)), relativePath), + ); +} + +function readRequiredSource(sources: Readonly>, path: string): string { + const normalized = normalizeSourcePath(path); + const content = sources[normalized]; + if (content === undefined) { + throw new Error(`Embedded agent profile source missing: ${normalized}`); + } + return content; +} + +function normalizeSourcePath(path: string): string { + return posixPath.normalize(path.replaceAll('\\', '/')).replace(/^\.\//, ''); +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function isFileNotFound(error: unknown): boolean { + return isRecord(error) && error['code'] === 'ENOENT'; +} + +function readError(label: string, filePath: string, error: unknown): Error { + return new Error( + `Failed to read ${label} at ${filePath}: ${ + error instanceof Error ? error.message : String(error) + }`, + ); +} diff --git a/packages/agent-core/src/profile/resolve.ts b/packages/agent-core/src/profile/resolve.ts new file mode 100644 index 000000000..001f7d19f --- /dev/null +++ b/packages/agent-core/src/profile/resolve.ts @@ -0,0 +1,222 @@ +import { renderPrompt } from '../utils/render-prompt'; +import type { + RawAgentProfile, + RawSubagentProfile, + ResolvedAgentProfile, + SystemPromptContext, + SystemPromptRenderer, +} from './types'; + +interface MergedAgentProfile { + readonly name: string; + readonly description?: string | undefined; + readonly systemPromptTemplate: string; + readonly promptVars: Record; + readonly tools: string[]; + readonly whenToUse?: string | undefined; + readonly subagents?: Record | undefined; +} + +/** + * Resolve agent profiles with extends inheritance. + * + * Each resolved profile exposes its `systemPrompt` as a renderer that + * closes over the merged template and prompt vars. The renderer is + * invoked later with a {@link SystemPromptContext} to produce the + * concrete prompt — this lets context that only exists at runtime + * (cwd listing, AGENTS.md, skills) flow through without re-loading + * profiles. + */ +export function resolveAgentProfiles( + raw: readonly RawAgentProfile[], +): Record { + const profileMap = new Map(); + const mergedCache = new Map(); + const resolvedCache = new Map(); + + for (const profile of raw) { + if (profileMap.has(profile.name)) { + throw new Error(`Duplicate agent profile name: "${profile.name}"`); + } + profileMap.set(profile.name, profile); + } + + for (const profile of raw) { + const merged = resolveMergedProfile(profile.name, profileMap, mergedCache, []); + resolvedCache.set(profile.name, toResolvedProfile(merged)); + } + + applySubagentDescriptions(mergedCache, resolvedCache); + linkResolvedSubagents(mergedCache, resolvedCache); + + const result: Record = {}; + for (const [name, profile] of resolvedCache) { + result[name] = profile; + } + + return result; +} + +function resolveMergedProfile( + name: string, + profileMap: Map, + cache: Map, + stack: string[], +): MergedAgentProfile { + const cached = cache.get(name); + if (cached !== undefined) { + return cached; + } + + const cycleIndex = stack.indexOf(name); + if (cycleIndex !== -1) { + const cycle = [...stack.slice(cycleIndex), name].join(' -> '); + throw new Error(`Agent profile extends cycle detected: ${cycle}`); + } + + const profile = profileMap.get(name); + if (profile === undefined) { + throw new Error(`Agent profile "${name}" not found`); + } + + let parent: MergedAgentProfile | undefined; + if (profile.extends !== undefined) { + if (!profileMap.has(profile.extends)) { + throw new Error( + `Agent profile "${profile.name}" extends "${profile.extends}" but parent profile was not found`, + ); + } + parent = resolveMergedProfile(profile.extends, profileMap, cache, [...stack, name]); + } + + const merged: MergedAgentProfile = { + name: profile.name, + description: profile.description, + systemPromptTemplate: profile.systemPromptTemplate ?? parent?.systemPromptTemplate ?? '', + promptVars: { + ...parent?.promptVars, + ...profile.promptVars, + }, + tools: profile.tools !== undefined ? [...profile.tools] : [...(parent?.tools ?? [])], + whenToUse: profile.whenToUse ?? parent?.whenToUse, + subagents: cloneSubagents(profile.subagents), + }; + + cache.set(profile.name, merged); + return merged; +} + +function toResolvedProfile(merged: MergedAgentProfile): ResolvedAgentProfile { + return { + name: merged.name, + description: merged.description, + systemPrompt: createSystemPromptRenderer(merged), + tools: [...merged.tools], + whenToUse: merged.whenToUse, + }; +} + +/** + * Build a renderer that captures the merged template and prompt vars. + * The runtime SystemPromptContext is mapped to the template variables + * (KIMI_OS, KIMI_AGENTS_MD, ...) at render time. + */ +function createSystemPromptRenderer(merged: MergedAgentProfile): SystemPromptRenderer { + return (context: SystemPromptContext): string => { + const vars = buildTemplateVars(context, merged.promptVars); + try { + return renderPrompt(merged.systemPromptTemplate, vars); + } catch (error) { + throw new Error( + `Failed to render system prompt for agent profile "${merged.name}": ${ + error instanceof Error ? error.message : String(error) + }`, + { cause: error }, + ); + } + }; +} + +function buildTemplateVars( + context: SystemPromptContext, + promptVars: Record, +): Record { + const skills = + typeof context.skills === 'string' + ? context.skills + : (context.skills?.getModelSkillListing() ?? ''); + const now = + context.now instanceof Date + ? context.now.toISOString() + : (context.now ?? new Date().toISOString()); + + return { + ...promptVars, + KIMI_OS: context.osEnv.osKind, + KIMI_SHELL: `${context.osEnv.shellName} (\`${context.osEnv.shellPath}\`)`, + KIMI_NOW: now, + KIMI_WORK_DIR: context.cwd, + KIMI_WORK_DIR_LS: context.cwdListing ?? '', + KIMI_AGENTS_MD: context.agentsMd ?? '', + KIMI_SKILLS: skills, + KIMI_ADDITIONAL_DIRS_INFO: context.additionalDirsInfo ?? '', + ROLE_ADDITIONAL: + context.roleAdditional ?? promptVars['ROLE_ADDITIONAL'] ?? promptVars['roleAdditional'] ?? '', + }; +} + +function applySubagentDescriptions( + mergedProfiles: Map, + resolvedProfiles: Map, +): void { + for (const [ownerName, owner] of mergedProfiles) { + if (owner.subagents === undefined) continue; + for (const [subagentName, subagent] of Object.entries(owner.subagents)) { + const target = resolvedProfiles.get(subagentName); + if (target === undefined) { + throwMissingSubagent(ownerName, subagentName); + } + if (target.description === undefined && subagent.description !== undefined) { + target.description = subagent.description; + } + } + } +} + +function linkResolvedSubagents( + mergedProfiles: Map, + resolvedProfiles: Map, +): void { + for (const [ownerName, owner] of mergedProfiles) { + if (owner.subagents === undefined) continue; + + const subagents: Record = {}; + for (const subagentName of Object.keys(owner.subagents)) { + const target = resolvedProfiles.get(subagentName); + if (target === undefined) { + throwMissingSubagent(ownerName, subagentName); + } + subagents[subagentName] = target; + } + + const resolved = resolvedProfiles.get(ownerName); + if (resolved !== undefined) { + resolved.subagents = subagents; + } + } +} + +function throwMissingSubagent(ownerName: string, subagentName: string): never { + throw new Error( + `Agent profile "${ownerName}" declares subagent "${subagentName}" but that agent profile was not found`, + ); +} + +function cloneSubagents( + subagents: Record | undefined, +): Record | undefined { + if (subagents === undefined) return undefined; + return Object.fromEntries( + Object.entries(subagents).map(([name, subagent]) => [name, { ...subagent }]), + ); +} diff --git a/packages/agent-core/src/profile/types.ts b/packages/agent-core/src/profile/types.ts new file mode 100644 index 000000000..5e9ef5c68 --- /dev/null +++ b/packages/agent-core/src/profile/types.ts @@ -0,0 +1,56 @@ +import { z } from 'zod'; + +import type { SkillRegistry } from '../skill'; +import type { Environment } from '../utils/environment'; + +export const RawSubagentProfileSchema = z.object({ + description: z.string().optional(), +}); + +export type RawSubagentProfile = z.infer; + +export const RawAgentProfileSchema = z.object({ + extends: z.string().optional(), + name: z.string().min(1), + description: z.string().optional(), + systemPromptPath: z.string().optional(), + systemPromptTemplate: z.string().optional(), + promptVars: z.record(z.string(), z.string()).optional(), + // Exact builtin/user tool names, plus optional MCP glob patterns + // (`mcp__*`, `mcp__github__*`) that gate which MCP tools the profile sees. + tools: z.array(z.string()).optional(), + whenToUse: z.string().optional(), + subagents: z.record(z.string(), RawSubagentProfileSchema).optional(), +}); + +export type RawAgentProfile = z.infer; + +/** + * Runtime context supplied to a system prompt renderer. + * + * Captures everything determined at render time rather than at profile-load + * time: the OS/shell, working directory, AGENTS.md instructions, available + * skills, and so on. Loaders return renderers; callers invoke them with + * the live context whenever a concrete prompt is needed. + */ +export interface SystemPromptContext { + readonly osEnv: Environment; + readonly cwd: string; + readonly now?: string | Date; + readonly cwdListing?: string; + readonly agentsMd?: string; + readonly skills?: SkillRegistry | string; + readonly additionalDirsInfo?: string; + readonly roleAdditional?: string; +} + +export type SystemPromptRenderer = (context: SystemPromptContext) => string; + +export interface ResolvedAgentProfile { + name: string; + description?: string; + systemPrompt: SystemPromptRenderer; + tools: string[]; + whenToUse?: string; + subagents?: Record; +} diff --git a/packages/agent-core/src/prompt-modules.d.ts b/packages/agent-core/src/prompt-modules.d.ts new file mode 100644 index 000000000..345a703a0 --- /dev/null +++ b/packages/agent-core/src/prompt-modules.d.ts @@ -0,0 +1,12 @@ +// Raw-string imports for prompt sources. The `raw-text-plugin` (used by both +// tsdown and vitest) loads `.md` / `.yaml` files as their string content. + +declare module '*.md' { + const content: string; + export default content; +} + +declare module '*.yaml' { + const content: string; + export default content; +} diff --git a/packages/agent-core/src/providers/index.ts b/packages/agent-core/src/providers/index.ts new file mode 100644 index 000000000..85cca4b1c --- /dev/null +++ b/packages/agent-core/src/providers/index.ts @@ -0,0 +1,2 @@ +export * from './provider-manager'; +export * from './runtime-provider'; diff --git a/packages/agent-core/src/providers/provider-manager.ts b/packages/agent-core/src/providers/provider-manager.ts new file mode 100644 index 000000000..7c2bc14f1 --- /dev/null +++ b/packages/agent-core/src/providers/provider-manager.ts @@ -0,0 +1,139 @@ +import type { KimiConfig } from '../config'; +import { ErrorCodes, KimiError } from '#/errors'; +import type { Logger } from '#/logging/types'; +import { resolveThinkingEffort, type ThinkingEffort } from '../agent/config/thinking'; +import { + createRuntimeProviderAuthResolver, + resolveRuntimeProvider, + resolveRuntimeProviderWithOAuth, + type OAuthTokenProviderResolver, + type ProviderRequestAuthResolver, + type ResolvedRuntimeProvider, +} from './runtime-provider'; + +export interface ProviderManagerOptions { + readonly config: KimiConfig; + readonly kimiRequestHeaders?: Record | undefined; + readonly resolveOAuthTokenProvider?: OAuthTokenProviderResolver | undefined; + readonly promptCacheKey?: string; +} + +export class ProviderManager { + private readonly state: ProviderManagerState; + + constructor( + private readonly options: ProviderManagerOptions, + state?: ProviderManagerState, + ) { + this.state = state ?? { config: options.config }; + } + + get config(): KimiConfig { + return this.state.config; + } + + get providers(): KimiConfig['providers'] { + return this.state.config.providers; + } + + get models(): NonNullable { + return this.state.config.models ?? {}; + } + + updateConfig(config: KimiConfig): void { + this.state.config = config; + } + + withPromptCacheKey(promptCacheKey?: string): ProviderManager { + return new ProviderManager( + { + ...this.options, + config: this.state.config, + promptCacheKey, + }, + this.state, + ); + } + + resolveProviderConfigForModel( + model: string | undefined, + options?: { readonly promptCacheKey?: string }, + ): ResolvedRuntimeProvider | undefined { + const selectedModel = this.resolveSelectedModel(model); + if (selectedModel === undefined) return undefined; + + return resolveRuntimeProvider({ + config: this.state.config, + model: selectedModel, + kimiRequestHeaders: this.options.kimiRequestHeaders, + promptCacheKey: options?.promptCacheKey ?? this.options.promptCacheKey, + validateCredentials: false, + }); + } + + async resolveProviderForModel( + model: string | undefined, + ): Promise { + const selectedModel = this.resolveSelectedModel(model); + if (selectedModel === undefined) return undefined; + + return resolveRuntimeProviderWithOAuth({ + config: this.state.config, + model: selectedModel, + kimiRequestHeaders: this.options.kimiRequestHeaders, + promptCacheKey: this.options.promptCacheKey, + resolveOAuthTokenProvider: this.options.resolveOAuthTokenProvider, + }); + } + + createAuthResolverForModel( + model: string | undefined, + options?: { readonly log?: Logger }, + ): ProviderRequestAuthResolver | undefined { + const selectedModel = this.resolveSelectedModel(model); + if (selectedModel === undefined) return undefined; + + const resolved = resolveRuntimeProvider({ + config: this.state.config, + model: selectedModel, + kimiRequestHeaders: this.options.kimiRequestHeaders, + promptCacheKey: this.options.promptCacheKey, + }); + return createRuntimeProviderAuthResolver( + { + config: this.state.config, + model: selectedModel, + kimiRequestHeaders: this.options.kimiRequestHeaders, + promptCacheKey: this.options.promptCacheKey, + resolveOAuthTokenProvider: this.options.resolveOAuthTokenProvider, + log: options?.log, + }, + resolved, + ); + } + + resolveThinkingLevel(requestedThinking?: string): ThinkingEffort { + return resolveThinkingEffort(requestedThinking, this.state.config.thinking); + } + + resolveSelectedModel(requestedModel: string | undefined): string | undefined { + if (requestedModel !== undefined) { + const normalized = normalizeString(requestedModel); + if (normalized === undefined) { + throw new KimiError(ErrorCodes.MODEL_CONFIG_INVALID, 'Runtime provider model cannot be empty'); + } + return normalized; + } + return normalizeString(this.state.config.defaultModel); + } +} + +interface ProviderManagerState { + config: KimiConfig; +} + +function normalizeString(value: string | undefined): string | undefined { + if (value === undefined) return undefined; + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : undefined; +} diff --git a/packages/agent-core/src/providers/request-auth.ts b/packages/agent-core/src/providers/request-auth.ts new file mode 100644 index 000000000..31a032177 --- /dev/null +++ b/packages/agent-core/src/providers/request-auth.ts @@ -0,0 +1,46 @@ +import { APIStatusError, type ProviderRequestAuth } from '@moonshot-ai/kosong'; + +import { ErrorCodes, KimiError } from '#/errors'; + +export interface ProviderRequestAuthOptions { + readonly forceRefresh?: boolean; +} + +export type ProviderRequestAuthResolver = ( + options?: ProviderRequestAuthOptions, +) => Promise; + +export async function withProviderRequestAuth( + resolveAuth: ProviderRequestAuthResolver | undefined, + request: (auth: ProviderRequestAuth | undefined) => Promise, +): Promise { + let auth = await resolveAuth?.(); + for (let refreshed = false; ; refreshed = true) { + try { + return await request(auth); + } catch (error) { + if ( + auth === undefined || + !(error instanceof APIStatusError) || + error.statusCode !== 401 + ) { + throw error; + } + if (!refreshed && resolveAuth !== undefined) { + auth = await resolveAuth({ forceRefresh: true }); + continue; + } + throw new KimiError( + ErrorCodes.AUTH_LOGIN_REQUIRED, + 'OAuth provider credentials were rejected. Send /login to login.', + { + cause: error, + details: { + statusCode: error.statusCode, + requestId: error.requestId, + }, + }, + ); + } + } +} diff --git a/packages/agent-core/src/providers/runtime-provider.ts b/packages/agent-core/src/providers/runtime-provider.ts new file mode 100644 index 000000000..01abe39fe --- /dev/null +++ b/packages/agent-core/src/providers/runtime-provider.ts @@ -0,0 +1,409 @@ +import type { KimiConfig, ModelAlias, OAuthRef, ProviderConfig } from '#/config'; +import { ErrorCodes, KimiError, isKimiError } from '#/errors'; +import { log as defaultLog } from '#/logging/logger'; +import type { Logger } from '#/logging/types'; +import { + createProvider, + UNKNOWN_CAPABILITY, + type ModelCapability, + type ProviderConfig as KosongProviderConfig, +} from '@moonshot-ai/kosong'; + +import type { ProviderRequestAuthResolver } from './request-auth'; + +export type { ProviderRequestAuthResolver }; + +export interface ResolveRuntimeProviderInput { + readonly config: KimiConfig; + readonly model?: string | undefined; + readonly kimiRequestHeaders?: Record | undefined; + readonly promptCacheKey?: string; + readonly validateCredentials?: boolean; +} + +export interface BearerTokenProvider { + getAccessToken(options?: { readonly force?: boolean | undefined }): Promise; +} + +export type OAuthTokenProviderResolver = ( + providerName: string, + oauthRef?: OAuthRef | undefined, +) => BearerTokenProvider | undefined; + +export interface ResolveRuntimeProviderWithOAuthInput extends ResolveRuntimeProviderInput { + readonly resolveOAuthTokenProvider?: OAuthTokenProviderResolver | undefined; + /** + * Caller-scoped logger (typically `agent.log`). Used to report OAuth token + * fetch failures so they land in the session log alongside the surrounding + * turn / tool call. Defaults to the global `log` when absent. + */ + readonly log?: Logger; +} + +export interface ResolvedRuntimeProvider { + readonly modelName: string; + readonly providerName?: string | undefined; + readonly provider: KosongProviderConfig; + readonly modelCapabilities: ModelCapability; + readonly resolveAuth?: ProviderRequestAuthResolver; +} + +export function resolveRuntimeProvider( + input: ResolveRuntimeProviderInput, +): ResolvedRuntimeProvider { + const modelName = input.model ?? input.config.defaultModel; + if (modelName === undefined) { + throw new KimiError( + ErrorCodes.CONFIG_INVALID, + 'No model is selected. Set default_model in config.toml or pass a configured model alias.', + ); + } + + const alias = input.config.models?.[modelName]; + if (alias === undefined) { + throw new KimiError( + ErrorCodes.CONFIG_INVALID, + `Model "${modelName}" is not configured in config.toml. Add a [models."${modelName}"] entry with max_context_size.`, + ); + } + + const resolvedModel = alias.model; + const providerName = alias.provider ?? input.config.defaultProvider; + const providerConfig = + providerName === undefined ? undefined : input.config.providers[providerName]; + + if (providerName === undefined) { + throw new KimiError( + ErrorCodes.CONFIG_INVALID, + `Model "${modelName}" must define a provider in config.toml.`, + ); + } + + if (providerConfig === undefined) { + throw new KimiError( + ErrorCodes.CONFIG_INVALID, + `Provider "${providerName}" for model "${modelName}" is not configured.`, + ); + } + + if (!Number.isInteger(alias.maxContextSize) || alias.maxContextSize <= 0) { + throw new KimiError( + ErrorCodes.CONFIG_INVALID, + `Model "${modelName}" must define a positive max_context_size in config.toml.`, + ); + } + + // Fail-fast when an operation explicitly selects/uses a runtime provider. + // Session creation only records the selected model name and does not need + // credential validation. + if ( + input.validateCredentials !== false && + providerConfig.type !== 'vertexai' && + providerConfig.oauth === undefined && + providerApiKey(providerConfig) === undefined + ) { + throw new KimiError( + ErrorCodes.CONFIG_INVALID, + `Provider "${providerName}" has no credentials configured. Set apiKey, oauth, or a provider env API key in config.toml.`, + ); + } + + const provider = toKosongProviderConfig( + providerConfig, + resolvedModel, + input.kimiRequestHeaders, + alias.maxOutputSize, + input.promptCacheKey, + ); + const modelCapabilities = resolveModelCapabilities(alias, provider); + + return { + modelName, + providerName, + modelCapabilities, + provider, + }; +} + +export async function resolveRuntimeProviderWithOAuth( + input: ResolveRuntimeProviderWithOAuthInput, +): Promise { + const resolved = resolveRuntimeProvider(input); + const resolveAuth = createRuntimeProviderAuthResolver(input, resolved); + if (resolveAuth === undefined) return resolved; + + // Validate login eagerly, preserving existing setModel + // behavior, but do not store the short-lived token in provider config. + await resolveAuth(); + return { + ...resolved, + resolveAuth, + }; +} + +export function createRuntimeProviderAuthResolver( + input: ResolveRuntimeProviderWithOAuthInput, + resolved: ResolvedRuntimeProvider = resolveRuntimeProvider(input), +): ProviderRequestAuthResolver | undefined { + const providerName = resolved.providerName; + if (providerName === undefined) return undefined; + + const providerConfig = input.config.providers[providerName]; + if (providerConfig?.oauth === undefined) return undefined; + if (providerApiKey(providerConfig) !== undefined) { + // oauth + apiKey on the same provider makes request auth ambiguous: + // provider construction would prefer apiKey while runtime auth resolves + // OAuth. Reject it so misconfiguration surfaces at model resolution. + throw new KimiError( + ErrorCodes.CONFIG_INVALID, + `Provider "${providerName}" has both apiKey and oauth set in config.toml — they are mutually exclusive. Remove one.`, + ); + } + + const tokenProvider = input.resolveOAuthTokenProvider?.(providerName, providerConfig.oauth); + if (tokenProvider === undefined) { + return async () => { + throw new KimiError( + ErrorCodes.AUTH_LOGIN_REQUIRED, + `OAuth provider "${providerName}" requires login before it can be used.`, + ); + }; + } + + return async (options) => { + let apiKey: string; + try { + apiKey = await tokenProvider.getAccessToken( + options?.forceRefresh === true ? { force: true } : undefined, + ); + } catch (error) { + if (!isAuthLoginRequired(error)) { + (input.log ?? defaultLog).warn('oauth token fetch failed', { providerName, error }); + } + throw new KimiError( + ErrorCodes.AUTH_LOGIN_REQUIRED, + `OAuth provider "${providerName}" requires login before it can be used.`, + { + cause: error, + }, + ); + } + if (apiKey.trim().length === 0) { + throw new KimiError( + ErrorCodes.AUTH_LOGIN_REQUIRED, + `OAuth provider "${providerName}" requires login before it can be used.`, + ); + } + return { apiKey }; + }; +} + +function isAuthLoginRequired(error: unknown): boolean { + return isKimiError(error) && error.code === ErrorCodes.AUTH_LOGIN_REQUIRED; +} + +function resolveModelCapabilities( + alias: ModelAlias & { maxContextSize: number }, + provider: KosongProviderConfig, +): ModelCapability { + const capabilities = new Set( + (alias.capabilities ?? []).map((capability) => capability.trim().toLowerCase()), + ); + const has = (capability: string): boolean => capabilities.has(capability); + const capabilityProvider = createProvider(providerForCapabilityProbe(provider)); + const providerCapability = + capabilityProvider.getCapability?.(provider.model) ?? UNKNOWN_CAPABILITY; + + return { + image_in: has('image_in') || providerCapability.image_in, + video_in: has('video_in') || providerCapability.video_in, + audio_in: has('audio_in') || providerCapability.audio_in, + thinking: has('thinking') || has('always_thinking') || providerCapability.thinking, + tool_use: has('tool_use') || providerCapability.tool_use, + max_context_tokens: alias.maxContextSize, + }; +} + +function toKosongProviderConfig( + provider: ProviderConfig, + model: string, + kimiRequestHeaders?: Record | undefined, + maxOutputSize?: number | undefined, + promptCacheKey?: string, +): KosongProviderConfig { + switch (provider.type) { + case 'anthropic': + return { + type: 'anthropic', + model, + baseUrl: providerValue(provider.baseUrl, provider.env, 'ANTHROPIC_BASE_URL'), + apiKey: providerApiKey(provider), + ...(maxOutputSize !== undefined ? { defaultMaxTokens: maxOutputSize } : {}), + ...defaultHeadersField(provider.customHeaders), + }; + case 'openai': + return { + type: 'openai', + model, + baseUrl: providerValue(provider.baseUrl, provider.env, 'OPENAI_BASE_URL'), + apiKey: providerApiKey(provider), + ...defaultHeadersField(provider.customHeaders), + }; + case 'kimi': { + const defaultHeaders = { + ...kimiRequestHeaders, + ...provider.customHeaders, + }; + if (Object.keys(defaultHeaders).length === 0) { + return { + type: 'kimi', + model, + baseUrl: providerValue(provider.baseUrl, provider.env, 'KIMI_BASE_URL'), + generationKwargs: { + prompt_cache_key: promptCacheKey, + }, + apiKey: providerApiKey(provider), + }; + } + return { + type: 'kimi', + model, + baseUrl: providerValue(provider.baseUrl, provider.env, 'KIMI_BASE_URL'), + generationKwargs: { + prompt_cache_key: promptCacheKey, + }, + defaultHeaders, + apiKey: providerApiKey(provider), + }; + } + case 'google-genai': + return { + type: 'google-genai', + model, + apiKey: providerApiKey(provider), + }; + case 'openai_responses': + return { + type: 'openai_responses', + model, + baseUrl: providerValue(provider.baseUrl, provider.env, 'OPENAI_BASE_URL'), + apiKey: providerApiKey(provider), + ...defaultHeadersField(provider.customHeaders), + }; + case 'vertexai': + return { + type: 'vertexai', + model, + vertexai: hasVertexAIServiceEnv(provider), + apiKey: hasVertexAIServiceEnv(provider) ? undefined : providerApiKey(provider), + project: vertexAIProject(provider), + location: vertexAILocation(provider), + }; + default: { + const exhaustive: never = provider.type; + throw new KimiError( + ErrorCodes.MODEL_CONFIG_INVALID, + `Unsupported provider type: ${String(exhaustive)}`, + ); + } + } +} + +// Spread-ready `defaultHeaders` field for a kosong provider config. Returns a +// fresh copy so resolved provider instances never share a header object, and +// omits the key entirely when there are no headers (callers and tests rely on +// `'defaultHeaders' in provider`). +function defaultHeadersField( + headers: Record | undefined, +): { defaultHeaders?: Record } { + if (headers === undefined || Object.keys(headers).length === 0) return {}; + return { defaultHeaders: { ...headers } }; +} + +function providerForCapabilityProbe(provider: KosongProviderConfig): KosongProviderConfig { + if (provider.type === 'vertexai') { + return { + ...provider, + vertexai: false, + project: undefined, + location: undefined, + apiKey: + provider.apiKey === undefined || provider.apiKey.length === 0 + ? 'capability-probe' + : provider.apiKey, + }; + } + if (provider.apiKey !== undefined && provider.apiKey.length > 0) return provider; + return { ...provider, apiKey: 'capability-probe' }; +} + +function providerApiKey(provider: ProviderConfig): string | undefined { + switch (provider.type) { + case 'anthropic': + return providerValue(provider.apiKey, provider.env, 'ANTHROPIC_API_KEY'); + case 'openai': + case 'openai_responses': + return providerValue(provider.apiKey, provider.env, 'OPENAI_API_KEY'); + case 'kimi': + return providerValue(provider.apiKey, provider.env, 'KIMI_API_KEY'); + case 'google-genai': + return providerValue(provider.apiKey, provider.env, 'GOOGLE_API_KEY'); + case 'vertexai': + return ( + nonEmptyString(provider.apiKey) ?? + envValue(provider.env, 'VERTEXAI_API_KEY') ?? + envValue(provider.env, 'GOOGLE_API_KEY') + ); + default: { + const exhaustive: never = provider.type; + throw new KimiError( + ErrorCodes.MODEL_CONFIG_INVALID, + `Unsupported provider type: ${String(exhaustive)}`, + ); + } + } +} + +function hasVertexAIServiceEnv(provider: ProviderConfig): boolean { + return vertexAIProject(provider) !== undefined && vertexAILocation(provider) !== undefined; +} + +function vertexAIProject(provider: ProviderConfig): string | undefined { + return envValue(provider.env, 'GOOGLE_CLOUD_PROJECT'); +} + +function vertexAILocation(provider: ProviderConfig): string | undefined { + return ( + envValue(provider.env, 'GOOGLE_CLOUD_LOCATION') ?? + locationFromVertexAIBaseUrl(provider.baseUrl) + ); +} + +function providerValue( + configured: string | undefined, + env: Record | undefined, + envKey: string, +): string | undefined { + return nonEmptyString(configured) ?? envValue(env, envKey); +} + +function envValue(env: Record | undefined, key: string): string | undefined { + return nonEmptyString(env?.[key]); +} + +function nonEmptyString(value: string | undefined): string | undefined { + const trimmed = value?.trim(); + return trimmed === undefined || trimmed.length === 0 ? undefined : trimmed; +} + +function locationFromVertexAIBaseUrl(baseUrl: string | undefined): string | undefined { + const url = nonEmptyString(baseUrl); + if (url === undefined) return undefined; + try { + const host = new URL(url).hostname; + const suffix = '-aiplatform.googleapis.com'; + return host.endsWith(suffix) ? nonEmptyString(host.slice(0, -suffix.length)) : undefined; + } catch { + return undefined; + } +} diff --git a/packages/agent-core/src/rpc/client.ts b/packages/agent-core/src/rpc/client.ts new file mode 100644 index 000000000..d50cb5f3f --- /dev/null +++ b/packages/agent-core/src/rpc/client.ts @@ -0,0 +1,108 @@ +import type { PromisableMethods, Promisify } from '#/utils/types'; +import { createControlledPromise, objectMap } from '@antfu/utils'; + +import { + fromKimiErrorPayload, + type KimiErrorPayload, + toKimiErrorPayload, +} from '../errors'; +import { abortable } from '../utils/abort'; +import type { CoreAPI } from './core-api'; +import type { SDKAPI } from './sdk-api'; + +export interface RPCCallOptions { + signal?: AbortSignal; +} + +type RpcResponse = + | { readonly ok: true; readonly value: unknown } + | { readonly ok: false; readonly error: KimiErrorPayload }; + +export type RPCMethods = { + [K in keyof T]: T[K] extends (payload: infer Payload) => infer Return + ? (payload: Payload, options?: RPCCallOptions) => Promisify + : never; +}; + +export type RPCClient, Other extends Record> = ( + self: PromisableMethods, +) => Promise>; + +export function createRPC, Right extends Record>(): [ + RPCClient, + RPCClient, +] { + const left = createControlledPromise>(); + const right = createControlledPromise>(); + + function simulateNetwork(data: T): Promise { + return new Promise((resolve) => { + setTimeout(() => { + const serialized = JSON.stringify(data); + resolve(serialized === undefined ? (undefined as T) : JSON.parse(serialized)); + }, 0); + }); + } + + function abortableRpc(promise: Promise, signal?: AbortSignal): Promise { + return signal === undefined ? promise : abortable(promise, signal); + } + + function mapRpcFunction(fn: Function): Function { + return async (payload: any, options?: RPCCallOptions) => { + const signal = options?.signal; + const rpcPayload = await simulateNetwork(payload); + signal?.throwIfAborted(); + let response: RpcResponse; + try { + const value = await abortableRpc(Promise.resolve(fn(rpcPayload)), signal); + response = { ok: true, value }; + } catch (error) { + signal?.throwIfAborted(); + response = { ok: false, error: toKimiErrorPayload(error) }; + } + const remoteResponse = await simulateNetwork(response); + if (remoteResponse.ok) return remoteResponse.value; + throw fromKimiErrorPayload(remoteResponse.error); + }; + } + + function bindAllFunctions>(obj: T): T { + const bound: Record = {}; + let current: object | null = obj; + + while (current !== null && current !== Object.prototype) { + for (const key of Object.getOwnPropertyNames(current)) { + if (key === 'constructor' || Object.hasOwn(bound, key)) { + continue; + } + + const descriptor = Object.getOwnPropertyDescriptor(current, key); + if (typeof descriptor?.value === 'function') { + bound[key] = descriptor.value.bind(obj); + } + } + + current = Object.getPrototypeOf(current); + } + + return bound as T; + } + + async function leftClient(self: PromisableMethods): Promise> { + left.resolve(bindAllFunctions(self)); + return objectMap(await right, (key, fn) => [key, mapRpcFunction(fn)]) as RPCMethods; + } + + async function rightClient(self: PromisableMethods): Promise> { + right.resolve(bindAllFunctions(self)); + return objectMap(await left, (key, fn) => [key, mapRpcFunction(fn)]) as RPCMethods; + } + + return [leftClient, rightClient]; +} + +export type CoreRPCClient = RPCClient; +export type SDKRPCClient = RPCClient; + +export type CoreRPC = RPCMethods; diff --git a/packages/agent-core/src/rpc/core-api.ts b/packages/agent-core/src/rpc/core-api.ts new file mode 100644 index 000000000..8b80597d7 --- /dev/null +++ b/packages/agent-core/src/rpc/core-api.ts @@ -0,0 +1,273 @@ +import type { AgentConfigData } from '#/agent/config'; +import type { AgentContextData } from '#/agent/context'; +import type { PermissionData, PermissionMode } from '#/agent/permission'; +import type { PlanData } from '#/agent/plan'; +import type { ToolInfo } from '#/agent/tool'; +import type { KimiConfig, KimiConfigPatch } from '#/config'; +import type { ResumeSessionResult } from '#/rpc/resumed'; +import type { SessionMeta } from '#/session'; +import type { BackgroundTaskInfo } from '#/tools/builtin'; +import type { ContentPart } from '@moonshot-ai/kosong'; + +import type { UsageStatus } from './events'; +import type { WithAgentId, WithSessionId } from './types'; + +export type JsonPrimitive = string | number | boolean | null; +export type JsonValue = JsonPrimitive | JsonValue[] | { readonly [key: string]: JsonValue }; +export type JsonObject = { readonly [key: string]: JsonValue }; + +export type Unsubscribe = () => void; + +export type { KimiConfig, KimiConfigPatch }; + +export type TextPromptPart = Extract; +export type PromptPart = Extract; + +export type PromptInput = readonly PromptPart[]; + +export type EmptyPayload = {}; +export type SessionMetadataPatch = Partial>; + +export interface CreateSessionPayload { + readonly id?: string | undefined; + readonly workDir: string; + readonly model?: string | undefined; + readonly thinking?: string | undefined; + readonly permission?: PermissionMode | undefined; + readonly metadata?: JsonObject | undefined; +} + +export interface CloseSessionPayload { + readonly sessionId: string; +} + +export interface ResumeSessionPayload { + readonly sessionId: string; +} + +export interface ForkSessionPayload { + readonly sessionId: string; + readonly id?: string; + readonly title?: string; + readonly metadata?: JsonObject; +} + +export interface ExportSessionPayload { + readonly sessionId: string; + readonly outputPath?: string | undefined; + /** + * When true, the active global diagnostic log (`$KIMI_CODE_HOME/logs/kimi-code.log`) + * is copied into the zip at `logs/global/kimi-code.log`. Off by default to + * avoid bundling events from concurrent sessions / other projects. + */ + readonly includeGlobalLog?: boolean | undefined; + /** Host version to record in the export manifest. */ + readonly version: string; +} + +export interface ExportSessionManifest { + readonly sessionId: string; + readonly exportedAt: string; + readonly kimiCodeVersion: string; + readonly wireProtocolVersion: string; + readonly os: string; + readonly nodejsVersion: string; + readonly sessionFirstActivity?: string | undefined; + readonly sessionLastActivity?: string | undefined; + readonly title?: string | undefined; + readonly workspaceDir?: string | undefined; + /** zip-relative path to the session diagnostic log when present. */ + readonly sessionLogPath?: string | undefined; + /** zip-relative path to the bundled global diagnostic log (only when --include-global-log). */ + readonly globalLogPath?: string | undefined; +} + +export interface ExportSessionResult { + readonly zipPath: string; + readonly entries: readonly string[]; + readonly sessionDir: string; + readonly manifest: ExportSessionManifest; +} + +export interface ListSessionsPayload { + readonly workDir: string; +} + +export interface CoreInfo { + readonly version: string; +} + +export interface SessionSummary { + readonly id: string; + readonly title?: string | undefined; + readonly lastPrompt?: string; + readonly workDir: string; + readonly sessionDir: string; + readonly createdAt: number; + readonly updatedAt: number; + readonly archived?: boolean | undefined; + readonly metadata?: JsonObject | undefined; +} + +export interface PromptPayload { + readonly input: readonly ContentPart[]; +} +export interface SteerPayload { + readonly input: readonly ContentPart[]; +} +export interface CancelPayload { + readonly turnId?: number; +} +export interface SetThinkingPayload { + readonly level: string; +} +export interface SetPermissionPayload { + readonly mode: PermissionMode; +} +export interface SetModelPayload { + readonly model: string; +} +export interface SetModelResult { + readonly model: string; + readonly providerName?: string | undefined; +} +export interface CancelPlanPayload { + readonly id?: string; +} +export interface BeginCompactionPayload { + readonly instruction?: string; +} +export interface RegisterToolPayload { + readonly name: string; + readonly description: string; + readonly parameters: Record; +} +export interface UnregisterToolPayload { + readonly name: string; +} +export interface SetActiveToolsPayload { + readonly names: readonly string[]; +} +export interface StopBackgroundPayload { + readonly taskId: string; + /** Free-form human-readable reason persisted with the task record. */ + readonly reason?: string; +} +export interface GetBackgroundOutputPayload { + readonly taskId: string; + readonly tail?: number; +} +export interface GetBackgroundOutputPathPayload { + readonly taskId: string; +} +export interface GetBackgroundPayload { + /** + * When omitted, returns all tasks (including terminal/lost). Pass + * `true` to filter down to active-only — useful for model-facing + * surfaces. UI/TUI consumers should leave it undefined. + */ + readonly activeOnly?: boolean; + /** Caps the number of tasks returned. When omitted, returns all matching tasks. */ + readonly limit?: number; +} +export interface SkillSummary { + readonly name: string; + readonly description: string; + readonly path: string; + readonly source: 'builtin' | 'user' | 'extra' | 'project'; + readonly type?: string | undefined; + readonly disableModelInvocation?: boolean | undefined; +} + +export interface ActivateSkillPayload { + readonly name: string; + readonly args?: string | undefined; +} + +export interface McpServerInfo { + readonly name: string; + readonly transport: 'stdio' | 'http'; + readonly status: 'pending' | 'connected' | 'failed' | 'disabled' | 'needs-auth'; + readonly toolCount: number; + readonly error?: string; +} + +export interface McpStartupMetrics { + readonly durationMs: number; +} + +export interface ReconnectMcpServerPayload { + readonly name: string; +} + +export interface RenameSessionPayload { + readonly title: string; +} + +export interface UpdateSessionMetadataPayload { + readonly metadata: SessionMetadataPatch; +} + +export type SetKimiConfigPayload = KimiConfigPatch; + +export interface RemoveKimiProviderPayload { + readonly providerId: string; +} + +export interface AgentAPI { + prompt: (payload: PromptPayload) => void; + steer: (payload: SteerPayload) => void; + cancel: (payload: CancelPayload) => void; + setThinking: (payload: SetThinkingPayload) => void; + setPermission: (payload: SetPermissionPayload) => void; + setModel: (payload: SetModelPayload) => SetModelResult; + getModel: (payload: EmptyPayload) => string; + enterPlan: (payload: EmptyPayload) => void; + cancelPlan: (payload: CancelPlanPayload) => void; + clearPlan: (payload: EmptyPayload) => void; + beginCompaction: (payload: BeginCompactionPayload) => void; + cancelCompaction: (payload: EmptyPayload) => void; + registerTool: (payload: RegisterToolPayload) => void; + unregisterTool: (payload: UnregisterToolPayload) => void; + setActiveTools: (payload: SetActiveToolsPayload) => void; + stopBackground: (payload: StopBackgroundPayload) => void; + clearContext: (payload: EmptyPayload) => void; + activateSkill: (payload: ActivateSkillPayload) => void; + getBackgroundOutput: (payload: GetBackgroundOutputPayload) => string; + getBackgroundOutputPath: (payload: GetBackgroundOutputPathPayload) => string | undefined; + getContext: (payload: EmptyPayload) => AgentContextData; + getConfig: (payload: EmptyPayload) => AgentConfigData; + getPermission: (payload: EmptyPayload) => PermissionData; + getPlan: (payload: EmptyPayload) => PlanData; + getUsage: (payload: EmptyPayload) => UsageStatus; + getTools: (payload: EmptyPayload) => readonly ToolInfo[]; + getBackground: (payload: GetBackgroundPayload) => readonly BackgroundTaskInfo[]; +} + +type AgentAPIWithId = WithAgentId; + +export interface SessionAPI extends AgentAPIWithId { + renameSession: (payload: RenameSessionPayload) => void; + updateSessionMetadata: (payload: UpdateSessionMetadataPayload) => void; + getSessionMetadata: (payload: EmptyPayload) => SessionMeta; + listSkills: (payload: EmptyPayload) => readonly SkillSummary[]; + listMcpServers: (payload: EmptyPayload) => readonly McpServerInfo[]; + getMcpStartupMetrics: (payload: EmptyPayload) => McpStartupMetrics; + reconnectMcpServer: (payload: ReconnectMcpServerPayload) => void; + generateAgentsMd: (payload: EmptyPayload) => void; +} + +type SessionAPIWithId = WithSessionId; + +export interface CoreAPI extends SessionAPIWithId { + getCoreInfo: (payload: EmptyPayload) => CoreInfo; + getKimiConfig: (payload: EmptyPayload) => KimiConfig; + setKimiConfig: (payload: SetKimiConfigPayload) => KimiConfig; + removeKimiProvider: (payload: RemoveKimiProviderPayload) => KimiConfig; + createSession: (payload: CreateSessionPayload) => SessionSummary; + closeSession: (payload: CloseSessionPayload) => void; + resumeSession: (payload: ResumeSessionPayload) => ResumeSessionResult; + forkSession: (payload: ForkSessionPayload) => ResumeSessionResult; + listSessions: (payload: ListSessionsPayload) => readonly SessionSummary[]; + exportSession: (payload: ExportSessionPayload) => ExportSessionResult; +} diff --git a/packages/agent-core/src/rpc/core-impl.ts b/packages/agent-core/src/rpc/core-impl.ts new file mode 100644 index 000000000..189ae276d --- /dev/null +++ b/packages/agent-core/src/rpc/core-impl.ts @@ -0,0 +1,739 @@ +import { randomUUID } from 'node:crypto'; +import { homedir } from 'node:os'; + +import { localKaos } from '@moonshot-ai/kaos'; +import { ErrorCodes, KimiError } from '#/errors'; +import { getRootLogger, log } from '#/logging/logger'; +import { LocalFetchURLProvider } from '#/tools/providers/local-fetch-url'; +import { MoonshotFetchURLProvider } from '#/tools/providers/moonshot-fetch-url'; +import { MoonshotWebSearchProvider } from '#/tools/providers/moonshot-web-search'; +import { detectEnvironmentFromNode } from '#/utils/environment'; +import { getCoreVersion } from '#/version'; +import type { + ActivateSkillPayload, + BeginCompactionPayload, + CancelPayload, + CancelPlanPayload, + CloseSessionPayload, + CoreAPI, + CoreInfo, + CreateSessionPayload, + EmptyPayload, + ExportSessionPayload, + ExportSessionResult, + ForkSessionPayload, + GetBackgroundOutputPathPayload, + GetBackgroundOutputPayload, + GetBackgroundPayload, + ListSessionsPayload, + McpServerInfo, + McpStartupMetrics, + PromptPayload, + ReconnectMcpServerPayload, + RemoveKimiProviderPayload, + RenameSessionPayload, + ResumeSessionPayload, + RegisterToolPayload, + SetKimiConfigPayload, + SetActiveToolsPayload, + SetModelPayload, + SetModelResult, + SetPermissionPayload, + SetThinkingPayload, + SkillSummary, + SteerPayload, + StopBackgroundPayload, + SessionSummary, + UnregisterToolPayload, + UpdateSessionMetadataPayload, +} from './core-api'; +import type { CoreRPCClient } from './client'; +import type { ResumedAgentState, ResumeSessionResult } from './resumed'; +import type { SDKRPC } from './sdk-api'; +import { proxyWithExtraPayload } from './types'; +import type { PromisableMethods } from '#/utils/types'; + +import { resolveSessionMcpConfig } from '../mcp'; +import { Session, type SessionMeta, type SessionSkillConfig } from '../session'; +import { SessionAPIImpl } from '../session/rpc'; +import { + ensureKimiHome, + mergeConfigPatch, + readConfigFile, + resolveConfigPath, + resolveKimiHome, + writeConfigFile, + type KimiConfig, + type MoonshotServiceConfig, +} from '../config'; +import { exportSessionDirectory } from '../session/export'; +import { ProviderManager } from '../providers/provider-manager'; +import { + type BearerTokenProvider, + type OAuthTokenProviderResolver, +} from '../providers/runtime-provider'; +import type { Logger } from '../logging/types'; +import type { RuntimeConfig } from '../runtime-types'; +import { normalizeWorkDir, SessionStore } from '../session/store'; +import { noopTelemetryClient, withTelemetryContext, type TelemetryClient } from '../telemetry'; + +const KIMI_CODE_PROVIDER_NAME = 'managed:kimi-code'; + +type AgentScopedPayload = T & { readonly agentId: string }; +type SessionScopedPayload = T & { readonly sessionId: string }; +type SessionAgentPayload = SessionScopedPayload>; +type RenameSessionRequest = SessionScopedPayload; +type UpdateSessionMetadataRequest = SessionScopedPayload; + +export interface KimiCoreOptions { + readonly homeDir?: string | undefined; + readonly configPath?: string | undefined; + readonly runtime?: RuntimeConfig | undefined; + readonly kimiRequestHeaders?: Record | undefined; + readonly resolveOAuthTokenProvider?: OAuthTokenProviderResolver | undefined; + readonly skillDirs?: readonly string[]; + readonly telemetry?: TelemetryClient | undefined; +} + +export class KimiCore implements PromisableMethods { + readonly sdk: Promise; + readonly homeDir: string; + readonly configPath: string; + readonly sessions = new Map(); + readonly telemetry: TelemetryClient; + + private runtime: RuntimeConfig | undefined; + private readonly userHomeDir: string; + private readonly kimiRequestHeaders: Record | undefined; + private readonly resolveOAuthTokenProvider: OAuthTokenProviderResolver | undefined; + private readonly skillDirs: readonly string[]; + private readonly providerManager: ProviderManager; + private readonly sessionStore: SessionStore; + + constructor( + protected readonly rpcClient: CoreRPCClient, + options: KimiCoreOptions = {}, + ) { + const explicitHomeDir = options.homeDir ?? process.env['KIMI_CODE_HOME']; + this.homeDir = resolveKimiHome(options.homeDir); + this.userHomeDir = explicitHomeDir ?? homedir(); + this.configPath = resolveConfigPath({ + homeDir: this.homeDir, + configPath: options.configPath, + }); + this.runtime = options.runtime; + this.kimiRequestHeaders = options.kimiRequestHeaders; + this.resolveOAuthTokenProvider = options.resolveOAuthTokenProvider; + this.skillDirs = options.skillDirs ?? []; + this.telemetry = options.telemetry ?? noopTelemetryClient; + ensureKimiHome(this.homeDir); + this.providerManager = new ProviderManager({ + config: readConfigFile(this.configPath), + kimiRequestHeaders: this.kimiRequestHeaders, + resolveOAuthTokenProvider: this.resolveOAuthTokenProvider, + }); + this.sessionStore = new SessionStore(this.homeDir); + + this.sdk = rpcClient(this); + } + + async createSession(input: CreateSessionPayload): Promise { + const options = input; + const workDir = requiredWorkDir('createSession', options.workDir); + const config = this.reloadProviderManager(); + const id = options.id ?? createSessionId(); + const modelName = this.providerManager.resolveSelectedModel(options.model); + const thinkingLevel = this.providerManager.resolveThinkingLevel(options.thinking); + const permissionMode = options.permission ?? config.defaultPermissionMode; + const mcpConfig = await resolveSessionMcpConfig({ + cwd: workDir, + homeDir: this.homeDir, + }); + const summary = await this.sessionStore.create({ + id, + workDir, + }); + const result: SessionSummary = { + ...summary, + metadata: options.metadata, + }; + + // Session ctor attaches its own log sink. If anything in the setup-after- + // ctor block throws, `session.close()` releases the sink (and mcp). + const session = new Session({ + runtime: await this.resolveRuntime(config), + id, + homedir: summary.sessionDir, + kimiHomeDir: this.homeDir, + rpc: proxyWithExtraPayload(await this.sdk, { sessionId: summary.id }), + cwd: workDir, + providerManager: this.providerManager, + background: config.background, + hooks: config.hooks, + permissionRules: config.permission?.rules, + skills: this.resolveSessionSkillConfig(config), + mcpConfig, + telemetry: withTelemetryContext(this.telemetry, { sessionId: summary.id }), + }); + try { + session.metadata = { + ...session.metadata, + createdAt: new Date(summary.createdAt).toISOString(), + updatedAt: new Date(summary.updatedAt).toISOString(), + ...(summary.title !== undefined + ? { + title: summary.title, + isCustomTitle: true, + } + : {}), + custom: options.metadata === undefined ? {} : { ...options.metadata }, + }; + const mainAgent = await session.createMain(); + mainAgent.config.update({ + cwd: workDir, + modelAlias: modelName, + thinkingLevel, + }); + if (permissionMode !== undefined) { + mainAgent.permission.setMode(permissionMode); + } + // Honor config.defaultPlanMode for fresh sessions. Resumed sessions + // restore their own plan state from records and never re-apply this. + if (config.defaultPlanMode === true) { + await mainAgent.planMode.enter(); + } + await session.writeMetadata(); + await session.flushMetadata(); + } catch (error) { + await session.close().catch(() => {}); + throw error; + } + this.sessions.set(id, session); + return result; + } + + getCoreInfo(): CoreInfo { + return { version: getCoreVersion() }; + } + + async closeSession({ sessionId }: CloseSessionPayload): Promise { + const session = this.sessions.get(sessionId); + if (session) { + await session.close(); + this.sessions.delete(sessionId); + } + } + + async resumeSession(input: ResumeSessionPayload): Promise { + const summary = await this.sessionStore.get(input.sessionId); + const active = this.sessions.get(summary.id); + if (active !== undefined) { + return resumeSessionResult(summary, active); + } + + const config = this.reloadProviderManager(); + const mcpConfig = await resolveSessionMcpConfig({ + cwd: summary.workDir, + homeDir: this.homeDir, + }); + const session = new Session({ + runtime: await this.resolveRuntime(config), + id: summary.id, + homedir: summary.sessionDir, + kimiHomeDir: this.homeDir, + rpc: proxyWithExtraPayload(await this.sdk, { sessionId: summary.id }), + cwd: summary.workDir, + providerManager: this.providerManager, + background: config.background, + hooks: config.hooks, + permissionRules: config.permission?.rules, + skills: this.resolveSessionSkillConfig(config), + mcpConfig, + telemetry: withTelemetryContext(this.telemetry, { sessionId: summary.id }), + initializeMainAgent: false, + }); + try { + await session.resume(); + await this.refreshSessionRuntimeConfig(session, config); + } catch (error) { + await session.close().catch(() => {}); + withTelemetryContext(this.telemetry, { sessionId: summary.id }).track('session_load_failed', { + reason: telemetryErrorReason(error), + }); + throw error; + } + this.sessions.set(summary.id, session); + return resumeSessionResult(summary, session); + } + + async forkSession(input: ForkSessionPayload): Promise { + const source = await this.sessionStore.get(input.sessionId); + const active = this.sessions.get(source.id); + if (active !== undefined) { + await active.flushMetadata(); + } + + const id = input.id ?? createSessionId(); + await this.sessionStore.fork({ + sourceId: source.id, + targetId: id, + title: input.title, + metadata: input.metadata, + }); + return this.resumeSession({ sessionId: id }); + } + + async listSessions(input: ListSessionsPayload): Promise { + const options = input; + return this.sessionStore.list({ + ...options, + workDir: requiredWorkDir('listSessions', options.workDir), + }); + } + + async renameSession({ sessionId, ...payload }: RenameSessionRequest): Promise { + const session = this.sessions.get(sessionId); + if (session !== undefined) { + await new SessionAPIImpl(session).renameSession(payload); + return; + } + await this.sessionStore.rename(sessionId, payload.title); + } + + async exportSession(input: ExportSessionPayload): Promise { + const summary = await this.sessionStore.get(input.sessionId); + const active = this.sessions.get(input.sessionId); + // Closed sessions have no `Session.log`; create an ad-hoc child bound to + // their id so the entries still route to the session log file. + const exportLog = + active?.log ?? log.createChild({ sessionId: input.sessionId }); + if (active !== undefined) { + try { + await active.flushMetadata(); + } catch (error) { + exportLog.warn('flushMetadata failed before export', { error }); + } + } + await warnIfLogFlushFails(exportLog, 'export session log flush failed', () => + getRootLogger().flushSession(input.sessionId), + ); + if (input.includeGlobalLog === true) { + await warnIfLogFlushFails(exportLog, 'export global log flush failed', () => + getRootLogger().flushGlobal(), + ); + } + const result = await exportSessionDirectory({ + request: input, + summary, + homeDir: this.homeDir, + globalLogPath: getRootLogger().getConfig()?.globalLogPath, + }); + return result; + } + + async getKimiConfig(input: EmptyPayload = {}): Promise { + void input; + return readConfigFile(this.configPath); + } + + async setKimiConfig(input: SetKimiConfigPayload): Promise { + const config = mergeConfigPatch(readConfigFile(this.configPath), input); + await writeConfigFile(this.configPath, config); + const updated = readConfigFile(this.configPath); + this.providerManager.updateConfig(updated); + return updated; + } + + async removeKimiProvider(input: RemoveKimiProviderPayload): Promise { + const config = readConfigFile(this.configPath); + delete config.providers[input.providerId]; + + let removedDefault = false; + const existingModels = config.models ?? {}; + for (const [key, model] of Object.entries(existingModels)) { + if ( + typeof model === 'object' && + model !== null && + !Array.isArray(model) && + model['provider'] === input.providerId + ) { + delete existingModels[key]; + if (config.defaultModel === key) removedDefault = true; + } + } + config.models = existingModels; + + if (removedDefault) { + config.defaultModel = undefined; + } + + if (config.defaultProvider === input.providerId) { + config.defaultProvider = undefined; + } + + await writeConfigFile(this.configPath, config); + const updated = readConfigFile(this.configPath); + this.providerManager.updateConfig(updated); + return updated; + } + + prompt({ sessionId, ...payload }: SessionAgentPayload) { + return this.sessionApi(sessionId).prompt(payload); + } + + steer({ sessionId, ...payload }: SessionAgentPayload) { + return this.sessionApi(sessionId).steer(payload); + } + + cancel({ sessionId, ...payload }: SessionAgentPayload) { + return this.sessionApi(sessionId).cancel(payload); + } + + async setModel({ + sessionId, + ...payload + }: SessionAgentPayload): Promise { + this.reloadProviderManager(); + return this.sessionApi(sessionId).setModel(payload); + } + + setThinking({ sessionId, ...payload }: SessionAgentPayload) { + return this.sessionApi(sessionId).setThinking(payload); + } + + setPermission({ sessionId, ...payload }: SessionAgentPayload) { + return this.sessionApi(sessionId).setPermission(payload); + } + + getModel({ sessionId, ...payload }: SessionAgentPayload) { + return this.sessionApi(sessionId).getModel(payload); + } + + enterPlan({ sessionId, ...payload }: SessionAgentPayload) { + return this.sessionApi(sessionId).enterPlan(payload); + } + + cancelPlan({ sessionId, ...payload }: SessionAgentPayload) { + return this.sessionApi(sessionId).cancelPlan(payload); + } + + clearPlan({ sessionId, ...payload }: SessionAgentPayload) { + return this.sessionApi(sessionId).clearPlan(payload); + } + + beginCompaction({ sessionId, ...payload }: SessionAgentPayload) { + return this.sessionApi(sessionId).beginCompaction(payload); + } + + cancelCompaction({ sessionId, ...payload }: SessionAgentPayload) { + return this.sessionApi(sessionId).cancelCompaction(payload); + } + + registerTool({ sessionId, ...payload }: SessionAgentPayload) { + return this.sessionApi(sessionId).registerTool(payload); + } + + unregisterTool({ sessionId, ...payload }: SessionAgentPayload) { + return this.sessionApi(sessionId).unregisterTool(payload); + } + + setActiveTools({ sessionId, ...payload }: SessionAgentPayload) { + return this.sessionApi(sessionId).setActiveTools(payload); + } + + stopBackground({ sessionId, ...payload }: SessionAgentPayload) { + return this.sessionApi(sessionId).stopBackground(payload); + } + + clearContext({ sessionId, ...payload }: SessionAgentPayload) { + return this.sessionApi(sessionId).clearContext(payload); + } + + activateSkill({ + sessionId, + ...payload + }: SessionAgentPayload): Promise { + return this.sessionApi(sessionId).activateSkill(payload); + } + + getBackgroundOutput({ sessionId, ...payload }: SessionAgentPayload) { + return this.sessionApi(sessionId).getBackgroundOutput(payload); + } + + getBackgroundOutputPath({ + sessionId, + ...payload + }: SessionAgentPayload) { + return this.sessionApi(sessionId).getBackgroundOutputPath(payload); + } + + getContext({ sessionId, ...payload }: SessionAgentPayload) { + return this.sessionApi(sessionId).getContext(payload); + } + + getConfig({ sessionId, ...payload }: SessionAgentPayload) { + return this.sessionApi(sessionId).getConfig(payload); + } + + getPermission({ sessionId, ...payload }: SessionAgentPayload) { + return this.sessionApi(sessionId).getPermission(payload); + } + + getPlan({ sessionId, ...payload }: SessionAgentPayload) { + return this.sessionApi(sessionId).getPlan(payload); + } + + getUsage({ sessionId, ...payload }: SessionAgentPayload) { + return this.sessionApi(sessionId).getUsage(payload); + } + + getTools({ sessionId, ...payload }: SessionAgentPayload) { + return this.sessionApi(sessionId).getTools(payload); + } + + getBackground({ sessionId, ...payload }: SessionAgentPayload) { + return this.sessionApi(sessionId).getBackground(payload); + } + + updateSessionMetadata({ sessionId, ...payload }: UpdateSessionMetadataRequest): Promise { + return this.sessionApi(sessionId).updateSessionMetadata(payload); + } + + getSessionMetadata({ sessionId, ...payload }: SessionScopedPayload): SessionMeta { + return this.sessionApi(sessionId).getSessionMetadata(payload); + } + + listSkills({ + sessionId, + ...payload + }: SessionScopedPayload): Promise { + return this.sessionApi(sessionId).listSkills(payload); + } + + listMcpServers({ + sessionId, + ...payload + }: SessionScopedPayload): readonly McpServerInfo[] { + return this.sessionApi(sessionId).listMcpServers(payload); + } + + getMcpStartupMetrics({ + sessionId, + ...payload + }: SessionScopedPayload): Promise { + return this.sessionApi(sessionId).getMcpStartupMetrics(payload); + } + + reconnectMcpServer({ + sessionId, + ...payload + }: SessionScopedPayload): Promise { + return this.sessionApi(sessionId).reconnectMcpServer(payload); + } + + generateAgentsMd({ sessionId, ...payload }: SessionScopedPayload): Promise { + return this.sessionApi(sessionId).generateAgentsMd(payload); + } + + private async resolveRuntime(config: KimiConfig): Promise { + if (this.runtime !== undefined) return this.runtime; + const runtime = await createRuntimeConfig({ + config, + kimiRequestHeaders: this.kimiRequestHeaders, + resolveOAuthTokenProvider: this.resolveOAuthTokenProvider, + }); + this.runtime = runtime; + return runtime; + } + + private resolveSessionSkillConfig(config: KimiConfig): SessionSkillConfig { + const explicitDirs = this.skillDirs.length > 0 ? this.skillDirs : undefined; + return { + userHomeDir: this.userHomeDir, + explicitDirs, + extraDirs: config.extraSkillDirs, + mergeAllAvailableSkills: config.mergeAllAvailableSkills, + }; + } + + private sessionApi(sessionId: string): SessionAPIImpl { + const session = this.sessions.get(sessionId); + if (session === undefined) { + throw new KimiError(ErrorCodes.SESSION_NOT_FOUND, `Session "${sessionId}" was not found`, { + details: { sessionId }, + }); + } + return new SessionAPIImpl(session); + } + + private reloadProviderManager(): KimiConfig { + const config = readConfigFile(this.configPath); + this.providerManager.updateConfig(config); + return config; + } + + private async refreshSessionRuntimeConfig( + session: Session, + config: KimiConfig, + ): Promise { + const api = new SessionAPIImpl(session); + // A session migrated from an external tool carries no model, and any + // session may reference a model alias that no longer exists in config.toml. + // Try the session's own model first, then fall back to the configured + // default, so resume degrades gracefully instead of hard-failing. + const requested = (await api.getModel({ agentId: 'main' })).trim(); + const fallback = config.defaultModel?.trim() ?? ''; + const candidates = [...new Set([requested, fallback].filter((model) => model.length > 0))]; + for (const model of candidates) { + try { + await api.setModel({ agentId: 'main', model }); + await session.flushMetadata(); + return; + } catch (error) { + // Skip a candidate only when the alias is genuinely absent from + // config (a stale or migrated model) — that is the graceful-degrade + // case. A *configured* alias that fails to resolve (missing provider, + // no credentials, bad max_context_size) is an actionable config error + // the user must see; surface it instead of silently swapping models. + const aliasMissing = config.models?.[model] === undefined; + if ( + aliasMissing && + error instanceof KimiError && + error.code === ErrorCodes.CONFIG_INVALID + ) { + continue; + } + throw error; + } + } + // No candidate resolved (the replayed alias and the configured default are + // both invalid/unset). Clear the stale alias so the session is honestly + // model-less — the TUI then prompts for a model instead of showing a + // selection whose next prompt fails with a config error. Not persisted: + // `refreshSessionRuntimeConfig` re-derives this on every resume. + if (requested.length > 0) { + session.agents.get('main')?.config.update({ modelAlias: undefined }); + } + } +} + +async function createRuntimeConfig(input: { + readonly config: KimiConfig; + readonly kimiRequestHeaders?: Record | undefined; + readonly resolveOAuthTokenProvider?: OAuthTokenProviderResolver | undefined; +}): Promise { + const localFetcher = new LocalFetchURLProvider(); + const searchService = input.config.services?.moonshotSearch; + const fetchService = input.config.services?.moonshotFetch; + + return { + kaos: localKaos, + osEnv: await detectEnvironmentFromNode(), + urlFetcher: + fetchService?.baseUrl === undefined + ? localFetcher + : new MoonshotFetchURLProvider({ + baseUrl: fetchService.baseUrl, + localFallback: localFetcher, + defaultHeaders: input.kimiRequestHeaders, + ...serviceCredentials(fetchService, input.resolveOAuthTokenProvider), + }), + webSearcher: + searchService?.baseUrl === undefined + ? undefined + : new MoonshotWebSearchProvider({ + baseUrl: searchService.baseUrl, + defaultHeaders: input.kimiRequestHeaders, + ...serviceCredentials(searchService, input.resolveOAuthTokenProvider), + }), + }; +} + +function serviceCredentials( + service: MoonshotServiceConfig, + resolveOAuthTokenProvider: OAuthTokenProviderResolver | undefined, +): { + readonly apiKey?: string | undefined; + readonly tokenProvider?: BearerTokenProvider | undefined; + readonly customHeaders?: Record | undefined; +} { + const apiKey = nonEmptyString(service.apiKey); + return { + apiKey, + tokenProvider: + service.oauth !== undefined + ? resolveOAuthTokenProvider?.(KIMI_CODE_PROVIDER_NAME, service.oauth) + : undefined, + customHeaders: service.customHeaders, + }; +} + +function nonEmptyString(value: string | undefined): string | undefined { + const trimmed = value?.trim(); + return trimmed === undefined || trimmed.length === 0 ? undefined : trimmed; +} + +function requiredWorkDir(operation: string, value: string): string { + if (typeof value !== 'string' || value.trim() === '') { + throw new KimiError(ErrorCodes.REQUEST_WORK_DIR_REQUIRED, `${operation} requires workDir`); + } + return normalizeWorkDir(value); +} + +function createSessionId(): string { + return `session_${randomUUID()}`; +} + +function telemetryErrorReason(error: unknown): string { + if (error instanceof KimiError) return error.code; + if (error instanceof Error && error.name.length > 0) return error.name; + return typeof error; +} + +async function resumeSessionResult( + summary: SessionSummary, + session: Session, +): Promise { + const api = new SessionAPIImpl(session); + const agents: Record = {}; + for (const [agentId, agent] of session.agents) { + const config = await api.getConfig({ agentId }); + const context = await api.getContext({ agentId }); + const permission = await api.getPermission({ agentId }); + const plan = await api.getPlan({ agentId }); + const usage = await api.getUsage({ agentId }); + agents[agentId] = { + type: agent.type, + config, + context, + replay: agent.replayBuilder.buildResult(), + permission, + plan, + usage, + tools: await api.getTools({ agentId }), + toolStore: agent.tools.storeData(), + background: agent.background.list(false), + }; + } + return { + ...summary, + sessionMetadata: api.getSessionMetadata({}), + agents, + }; +} + +async function warnIfLogFlushFails( + exportLog: Logger, + message: string, + flush: () => Promise, +): Promise { + try { + if (await flush()) return; + exportLog.warn(message); + } catch (error) { + exportLog.warn(message, { error }); + } + try { + await flush(); + } catch {} +} diff --git a/packages/agent-core/src/rpc/events.ts b/packages/agent-core/src/rpc/events.ts new file mode 100644 index 000000000..d387c49ae --- /dev/null +++ b/packages/agent-core/src/rpc/events.ts @@ -0,0 +1,297 @@ +import type { FinishReason, TokenUsage } from '@moonshot-ai/kosong'; + +import type { PromptOrigin } from '../agent/context'; +import type { KimiErrorPayload } from '../errors'; +import type { PermissionMode } from '../agent/permission'; +import type { SkillSource } from '../skill'; +import type { BackgroundTaskInfo } from '../tools/background/manager'; +import type { ToolInputDisplay } from '../tools/display'; + +export type { ToolInputDisplay } from '../tools/display'; +export type { KimiErrorPayload } from '../errors'; + +export interface UsageStatus { + readonly byModel?: Record | undefined; + readonly currentTurn?: TokenUsage | undefined; + readonly total?: TokenUsage | undefined; +} + +export interface ToolUpdate { + readonly kind: 'stdout' | 'stderr' | 'progress' | 'status' | 'custom'; + readonly text?: string | undefined; + readonly percent?: number | undefined; + readonly customKind?: string | undefined; + readonly customData?: unknown; +} + +export const MCP_OAUTH_AUTHORIZATION_URL_TOOL_UPDATE = 'mcp.oauth.authorization_url'; + +export interface McpOAuthAuthorizationUrlUpdateData { + readonly serverName: string; + readonly authorizationUrl: string; +} + +export interface CompactionResult { + readonly summary: string; + readonly compactedCount: number; + readonly tokensBefore: number; + readonly tokensAfter: number; +} + +export type TurnEndReason = 'completed' | 'cancelled' | 'failed'; + +export interface AgentStatusUpdatedEvent { + readonly type: 'agent.status.updated'; + readonly model?: string | undefined; + readonly contextTokens?: number | undefined; + readonly maxContextTokens?: number | undefined; + readonly contextUsage?: number | undefined; + readonly planMode?: boolean | undefined; + readonly permission?: PermissionMode | undefined; + readonly usage?: UsageStatus | undefined; +} + +export interface SessionMetaUpdatedEvent { + readonly type: 'session.meta.updated'; + readonly title?: string | undefined; + readonly patch?: Record | undefined; +} + +export interface SkillActivatedEvent { + readonly type: 'skill.activated'; + readonly activationId: string; + readonly skillName: string; + readonly skillArgs?: string | undefined; + readonly trigger: 'user-slash' | 'model-tool' | 'nested-skill'; + readonly skillPath?: string | undefined; + readonly skillSource?: SkillSource | undefined; +} + +export interface ErrorEvent extends KimiErrorPayload { + readonly type: 'error'; + readonly sessionId?: string | undefined; + readonly agentId?: string | undefined; +} + +export interface TurnStartedEvent { + readonly type: 'turn.started'; + readonly turnId: number; + readonly origin: PromptOrigin; +} + +export interface TurnEndedEvent { + readonly type: 'turn.ended'; + readonly turnId: number; + readonly reason: TurnEndReason; + readonly error?: KimiErrorPayload | undefined; +} + +export interface TurnStepStartedEvent { + readonly type: 'turn.step.started'; + readonly turnId: number; + readonly step: number; + readonly stepId?: string | undefined; +} + +export interface TurnStepCompletedEvent { + readonly type: 'turn.step.completed'; + readonly turnId: number; + readonly step: number; + readonly stepId?: string | undefined; + readonly usage?: TokenUsage | undefined; + readonly finishReason?: string | undefined; + readonly providerFinishReason?: FinishReason | undefined; + readonly rawFinishReason?: string | undefined; +} + +export interface TurnStepRetryingEvent { + readonly type: 'turn.step.retrying'; + readonly turnId: number; + readonly step: number; + readonly stepId?: string; + readonly failedAttempt: number; + readonly nextAttempt: number; + readonly maxAttempts: number; + readonly delayMs: number; + readonly errorName: string; + readonly errorMessage: string; + readonly statusCode?: number; +} + +export interface TurnStepInterruptedEvent { + readonly type: 'turn.step.interrupted'; + readonly turnId: number; + readonly step: number; + readonly stepId?: string | undefined; + readonly reason: string; + readonly message?: string | undefined; +} + +export interface AssistantDeltaEvent { + readonly type: 'assistant.delta'; + readonly turnId: number; + readonly delta: string; +} + +export interface HookResultEvent { + readonly type: 'hook.result'; + readonly turnId: number; + readonly hookEvent: string; + readonly content: string; + readonly blocked?: boolean | undefined; +} + +export interface ThinkingDeltaEvent { + readonly type: 'thinking.delta'; + readonly turnId: number; + readonly delta: string; +} + +export interface ToolCallDeltaEvent { + readonly type: 'tool.call.delta'; + readonly turnId: number; + readonly toolCallId: string; + readonly name?: string | undefined; + readonly argumentsPart?: string | undefined; +} + +export interface ToolCallStartedEvent { + readonly type: 'tool.call.started'; + readonly turnId: number; + readonly toolCallId: string; + readonly name: string; + readonly args: unknown; + readonly description?: string | undefined; + readonly display?: ToolInputDisplay | undefined; +} + +export interface ToolProgressEvent { + readonly type: 'tool.progress'; + readonly turnId: number; + readonly toolCallId: string; + readonly update: ToolUpdate; +} + +export interface ToolResultEvent { + readonly type: 'tool.result'; + readonly turnId: number; + readonly toolCallId: string; + readonly output: unknown; + readonly isError?: boolean | undefined; + readonly synthetic?: boolean | undefined; +} + +export interface SubagentSpawnedEvent { + readonly type: 'subagent.spawned'; + readonly subagentId: string; + readonly subagentName: string; + readonly parentToolCallId: string; + readonly parentToolCallUuid?: string | undefined; + readonly parentAgentId?: string | undefined; + readonly description?: string | undefined; + readonly runInBackground: boolean; +} + +export interface SubagentCompletedEvent { + readonly type: 'subagent.completed'; + readonly subagentId: string; + readonly parentToolCallId: string; + readonly resultSummary: string; + readonly usage?: TokenUsage | undefined; +} + +export interface SubagentFailedEvent { + readonly type: 'subagent.failed'; + readonly subagentId: string; + readonly parentToolCallId: string; + readonly error: string; +} + +export interface CompactionStartedEvent { + readonly type: 'compaction.started'; + readonly trigger: 'manual' | 'auto'; + readonly instruction?: string | undefined; +} + +export interface CompactionBlockedEvent { + readonly type: 'compaction.blocked'; + readonly turnId?: number | undefined; +} + +export interface CompactionCancelledEvent { + readonly type: 'compaction.cancelled'; +} + +export interface CompactionCompletedEvent { + readonly type: 'compaction.completed'; + readonly result: CompactionResult; +} + +export interface BackgroundTaskStartedEvent { + readonly type: 'background.task.started'; + readonly info: BackgroundTaskInfo; +} + +export interface BackgroundTaskUpdatedEvent { + readonly type: 'background.task.updated'; + readonly info: BackgroundTaskInfo; +} + +export interface BackgroundTaskTerminatedEvent { + readonly type: 'background.task.terminated'; + readonly info: BackgroundTaskInfo; +} + +export type ToolListUpdatedReason = 'mcp.connected' | 'mcp.disconnected' | 'mcp.failed'; + +export interface ToolListUpdatedEvent { + readonly type: 'tool.list.updated'; + readonly reason: ToolListUpdatedReason; + readonly serverName: string; +} + +export interface McpServerStatusEvent { + readonly type: 'mcp.server.status'; + readonly server: McpServerStatusPayload; +} + +export interface McpServerStatusPayload { + readonly name: string; + readonly transport: 'stdio' | 'http'; + readonly status: 'pending' | 'connected' | 'failed' | 'disabled' | 'needs-auth'; + readonly toolCount: number; + readonly error?: string | undefined; +} + +export type AgentEvent = + | ErrorEvent + | AgentStatusUpdatedEvent + | SessionMetaUpdatedEvent + | SkillActivatedEvent + | TurnStartedEvent + | TurnEndedEvent + | TurnStepStartedEvent + | TurnStepCompletedEvent + | TurnStepRetryingEvent + | TurnStepInterruptedEvent + | AssistantDeltaEvent + | HookResultEvent + | ThinkingDeltaEvent + | ToolCallDeltaEvent + | ToolCallStartedEvent + | ToolProgressEvent + | ToolResultEvent + | ToolListUpdatedEvent + | McpServerStatusEvent + | SubagentSpawnedEvent + | SubagentCompletedEvent + | SubagentFailedEvent + | CompactionStartedEvent + | CompactionBlockedEvent + | CompactionCancelledEvent + | CompactionCompletedEvent + | BackgroundTaskStartedEvent + | BackgroundTaskUpdatedEvent + | BackgroundTaskTerminatedEvent; + +export type Event = AgentEvent & { agentId: string; sessionId: string }; diff --git a/packages/agent-core/src/rpc/index.ts b/packages/agent-core/src/rpc/index.ts new file mode 100644 index 000000000..1bc8f0b05 --- /dev/null +++ b/packages/agent-core/src/rpc/index.ts @@ -0,0 +1,7 @@ +export * from './client'; +export * from './core-api'; +export * from './core-impl'; +export * from './resumed'; +export * from './sdk-api'; +export * from './events'; +export * from './types'; diff --git a/packages/agent-core/src/rpc/resumed.ts b/packages/agent-core/src/rpc/resumed.ts new file mode 100644 index 000000000..61eb1dd67 --- /dev/null +++ b/packages/agent-core/src/rpc/resumed.ts @@ -0,0 +1,39 @@ +import type { AgentType } from '#/agent'; +import type { AgentConfigData, AgentConfigUpdateData } from '#/agent/config'; +import type { AgentContextData, ContextMessage } from '#/agent/context'; +import type { + PermissionApprovalResultRecord, + PermissionData, + PermissionMode, +} from '#/agent/permission'; +import type { PlanData } from '#/agent/plan'; +import type { ToolInfo } from '#/agent/tool'; +import type { SessionSummary } from '#/rpc/core-api'; +import type { UsageStatus } from '#/rpc/events'; +import type { SessionMeta } from '#/session'; +import type { BackgroundTaskInfo } from '#/tools/builtin'; + +export type AgentReplayRecord = + | { type: 'message'; message: ContextMessage } + | { type: 'plan_updated'; enabled: boolean } + | { type: 'config_updated'; config: AgentConfigUpdateData } + | { type: 'permission_updated'; mode: PermissionMode } + | { type: 'approval_result'; record: PermissionApprovalResultRecord }; + +export interface ResumedAgentState { + readonly type: AgentType; + readonly config: AgentConfigData; + readonly context: AgentContextData; + readonly replay: readonly AgentReplayRecord[]; + readonly permission: PermissionData; + readonly plan: PlanData; + readonly usage: UsageStatus; + readonly tools: readonly ToolInfo[]; + readonly toolStore?: Readonly>; + readonly background: readonly BackgroundTaskInfo[]; +} + +export interface ResumeSessionResult extends SessionSummary { + readonly sessionMetadata: SessionMeta; + readonly agents: Readonly>; +} diff --git a/packages/agent-core/src/rpc/sdk-api.ts b/packages/agent-core/src/rpc/sdk-api.ts new file mode 100644 index 000000000..3e58672be --- /dev/null +++ b/packages/agent-core/src/rpc/sdk-api.ts @@ -0,0 +1,79 @@ +import type { ContentPart } from '@moonshot-ai/kosong'; + +import type { RPCMethods } from './client'; +import type { AgentEvent, ToolInputDisplay } from './events'; +import type { WithAgentId, WithSessionId } from './types'; + +export type ApprovalDecision = 'approved' | 'rejected' | 'cancelled'; +export type ApprovalScope = 'session'; + +export interface ApprovalResponse { + readonly decision: ApprovalDecision; + readonly scope?: ApprovalScope | undefined; + readonly feedback?: string | undefined; + readonly selectedLabel?: string | undefined; +} + +export interface ApprovalRequest { + readonly turnId?: number | undefined; + readonly toolCallId: string; + readonly toolName: string; + readonly action: string; + readonly display: ToolInputDisplay; +} + +export interface QuestionOption { + readonly label: string; + readonly description?: string; +} + +export interface QuestionItem { + readonly question: string; + readonly header?: string; + readonly body?: string; + readonly options: readonly QuestionOption[]; + readonly multiSelect?: boolean; + readonly otherLabel?: string; + readonly otherDescription?: string; +} + +export type QuestionAnswerMethod = 'enter' | 'space' | 'number_key'; +export type QuestionAnswers = Record; + +export interface QuestionResponse { + readonly answers: QuestionAnswers; + readonly method?: QuestionAnswerMethod | undefined; +} + +export type QuestionResult = null | QuestionAnswers | QuestionResponse; + +export interface QuestionRequest { + readonly turnId?: number; + readonly toolCallId?: string; + readonly questions: readonly QuestionItem[]; +} + +export interface ToolCallRequest { + readonly turnId?: number | undefined; + readonly toolCallId: string; + readonly args: unknown; +} + +export interface ToolCallResponse { + readonly output: string | ContentPart[]; + readonly isError?: boolean | undefined; +} + +export interface SDKAgentAPI { + emitEvent: (event: AgentEvent) => void; + requestApproval: (request: ApprovalRequest) => Promise; + requestQuestion: (request: QuestionRequest) => Promise; + toolCall: (request: ToolCallRequest) => Promise; +} +export type SDKAgentRPC = RPCMethods; + +export type SDKSessionAPI = WithAgentId; +export type SDKSessionRPC = RPCMethods; + +export type SDKAPI = WithSessionId; +export type SDKRPC = RPCMethods; diff --git a/packages/agent-core/src/rpc/types.ts b/packages/agent-core/src/rpc/types.ts new file mode 100644 index 000000000..29a5bb609 --- /dev/null +++ b/packages/agent-core/src/rpc/types.ts @@ -0,0 +1,29 @@ +import type { RPCMethods } from './client'; + +type Prettify = { + [K in keyof T]: T[K]; +} & {}; + +type WithExtraPayload = { + [K in keyof T]: T[K] extends (payload: infer P) => infer R + ? (payload: Prettify

    ) => R + : never; +}; + +export type WithAgentId = WithExtraPayload; +export type WithSessionId = WithExtraPayload; + +export function proxyWithExtraPayload( + methods: RPCMethods>, + extraPayload: U, +): RPCMethods { + return new Proxy(methods as any, { + get(target, prop) { + const origMethod = target[prop as keyof typeof target]; + if (typeof origMethod !== 'function') { + return origMethod; + } + return (payload: any, ...args: any) => origMethod({ ...payload, ...extraPayload }, ...args); + }, + }); +} diff --git a/packages/agent-core/src/runtime-types.ts b/packages/agent-core/src/runtime-types.ts new file mode 100644 index 000000000..3ab4fcbb8 --- /dev/null +++ b/packages/agent-core/src/runtime-types.ts @@ -0,0 +1,11 @@ +import type { Kaos } from '@moonshot-ai/kaos'; + +import type { UrlFetcher, WebSearchProvider } from './tools/builtin'; +import type { Environment } from './utils/environment'; + +export interface RuntimeConfig { + readonly kaos: Kaos; + readonly osEnv: Environment; + readonly urlFetcher?: UrlFetcher | undefined; + readonly webSearcher?: WebSearchProvider | undefined; +} diff --git a/packages/agent-core/src/session/export/index.ts b/packages/agent-core/src/session/export/index.ts new file mode 100644 index 000000000..baaa03182 --- /dev/null +++ b/packages/agent-core/src/session/export/index.ts @@ -0,0 +1,4 @@ +export * from '#/session/export/manifest'; +export * from '#/session/export/session-export'; +export * from '#/session/export/wire-scan'; +export * from '#/session/export/zip'; diff --git a/packages/agent-core/src/session/export/manifest.ts b/packages/agent-core/src/session/export/manifest.ts new file mode 100644 index 000000000..d5334606b --- /dev/null +++ b/packages/agent-core/src/session/export/manifest.ts @@ -0,0 +1,36 @@ +import { AGENT_WIRE_PROTOCOL_VERSION } from '../../agent/records/types'; +import type { SessionWireScan } from '#/session/export/wire-scan'; +import type { ExportSessionManifest, SessionSummary } from '#/rpc/core-api'; + +export const WIRE_PROTOCOL_VERSION = AGENT_WIRE_PROTOCOL_VERSION; + +export function buildExportManifest(args: { + readonly summary: SessionSummary; + readonly now: Date; + readonly version: string; + readonly wireProtocolVersion?: string | undefined; + readonly sessionScan: SessionWireScan; + readonly sessionLogPath?: string | undefined; + readonly globalLogPath?: string | undefined; +}): ExportSessionManifest { + return { + sessionId: args.summary.id, + exportedAt: args.now.toISOString(), + kimiCodeVersion: args.version, + wireProtocolVersion: args.wireProtocolVersion ?? WIRE_PROTOCOL_VERSION, + os: `${process.platform} ${process.arch}`, + nodejsVersion: process.version.replace(/^v/, ''), + sessionFirstActivity: + args.sessionScan.firstActivityMs === undefined + ? undefined + : new Date(args.sessionScan.firstActivityMs).toISOString(), + sessionLastActivity: + args.sessionScan.lastActivityMs === undefined + ? undefined + : new Date(args.sessionScan.lastActivityMs).toISOString(), + title: args.summary.title, + workspaceDir: args.summary.workDir, + sessionLogPath: args.sessionLogPath, + globalLogPath: args.globalLogPath, + }; +} diff --git a/packages/agent-core/src/session/export/session-export.ts b/packages/agent-core/src/session/export/session-export.ts new file mode 100644 index 000000000..dc71b929a --- /dev/null +++ b/packages/agent-core/src/session/export/session-export.ts @@ -0,0 +1,86 @@ +import { readFile } from 'node:fs/promises'; +import { resolve } from 'node:path'; + +import { ErrorCodes, KimiError } from '#/errors'; +import { resolveGlobalLogPath } from '#/logging/logger'; +import { buildExportManifest } from '#/session/export/manifest'; +import { scanSessionWire } from '#/session/export/wire-scan'; +import { + type ExtraZipEntry, + collectFilesRecursive, + writeExportZip, +} from '#/session/export/zip'; +import type { ExportSessionPayload, ExportSessionResult, SessionSummary } from '#/rpc/core-api'; + +const SESSION_LOG_REL = 'logs/kimi-code.log'; +const GLOBAL_LOG_REL = 'logs/global/kimi-code.log'; + +export async function exportSessionDirectory(input: { + readonly request: ExportSessionPayload; + readonly summary: SessionSummary; + readonly homeDir?: string | undefined; + readonly globalLogPath?: string | undefined; +}): Promise { + const sessionDir = input.summary.sessionDir; + const sessionFiles = await collectFilesRecursive(sessionDir); + if (sessionFiles.length === 0) { + throw new KimiError(ErrorCodes.SESSION_EXPORT_NOT_FOUND, `Session "${input.summary.id}" has no exportable directory at "${sessionDir}"`, { + details: { sessionId: input.summary.id, sessionDir }, + }); + } + + const sessionScan = await scanSessionWire(sessionDir); + const hasSessionLog = sessionFiles.some((f) => + f.endsWith(`/${SESSION_LOG_REL}`) || f.endsWith(`\\${SESSION_LOG_REL.replaceAll('/', '\\')}`), + ); + + const extras: ExtraZipEntry[] = []; + let bundledGlobal = false; + const globalPath = + input.globalLogPath ?? + (input.homeDir === undefined ? undefined : resolveGlobalLogPath(input.homeDir)); + if (input.request.includeGlobalLog === true && globalPath !== undefined) { + const data = await readOptionalFile(globalPath); + if (data !== undefined) { + extras.push({ data, target: GLOBAL_LOG_REL }); + bundledGlobal = true; + } + } + + const manifest = buildExportManifest({ + summary: input.summary, + now: new Date(), + version: input.request.version, + sessionScan, + sessionLogPath: hasSessionLog ? SESSION_LOG_REL : undefined, + globalLogPath: bundledGlobal ? GLOBAL_LOG_REL : undefined, + }); + + const outputPath = + input.request.outputPath !== undefined + ? resolve(input.request.outputPath) + : resolve(`${input.summary.id}.zip`); + + const entries = await writeExportZip({ + outputPath, + manifest, + sessionDir, + sessionFiles, + extraEntries: extras, + }); + + return { + zipPath: outputPath, + entries, + sessionDir, + manifest, + }; +} + +async function readOptionalFile(path: string): Promise { + try { + return await readFile(path); + } catch { + return undefined; + } +} diff --git a/packages/agent-core/src/session/export/wire-scan.ts b/packages/agent-core/src/session/export/wire-scan.ts new file mode 100644 index 000000000..2d5aa2e2e --- /dev/null +++ b/packages/agent-core/src/session/export/wire-scan.ts @@ -0,0 +1,69 @@ +import { readFile } from 'node:fs/promises'; +import { join } from 'node:path'; + +export interface SessionWireScan { + readonly firstActivityMs?: number | undefined; + readonly lastActivityMs?: number | undefined; + readonly lastUserMessageMs?: number | undefined; + readonly firstUserInput?: string | undefined; +} + +export async function scanSessionWire(sessionDir: string): Promise { + let raw: string; + try { + raw = await readFile(join(sessionDir, 'wire.jsonl'), 'utf-8'); + } catch { + return {}; + } + + let firstActivityMs: number | undefined; + let lastActivityMs: number | undefined; + let lastUserMessageMs: number | undefined; + let firstUserInput: string | undefined; + + for (const line of raw.split('\n')) { + const trimmed = line.trim(); + if (trimmed.length === 0) continue; + let parsed: unknown; + try { + parsed = JSON.parse(trimmed) as unknown; + } catch { + continue; + } + if (typeof parsed !== 'object' || parsed === null) continue; + const record = parsed as { + type?: unknown; + time?: unknown; + userInput?: unknown; + }; + const timeMs = typeof record.time === 'number' ? normalizeTimestampMs(record.time) : undefined; + if (timeMs !== undefined) { + firstActivityMs ??= timeMs; + lastActivityMs = timeMs; + } + if (record.type === 'turn_begin') { + if (timeMs !== undefined) { + lastUserMessageMs = timeMs; + } + if ( + firstUserInput === undefined && + typeof record.userInput === 'string' && + record.userInput.trim().length > 0 + ) { + firstUserInput = record.userInput; + } + } + } + + return { + firstActivityMs, + lastActivityMs, + lastUserMessageMs, + firstUserInput, + }; +} + +export function normalizeTimestampMs(value: number): number | undefined { + if (!Number.isFinite(value) || value <= 0) return undefined; + return value > 1e12 ? Math.floor(value) : Math.floor(value * 1000); +} diff --git a/packages/agent-core/src/session/export/zip.ts b/packages/agent-core/src/session/export/zip.ts new file mode 100644 index 000000000..295ac4a31 --- /dev/null +++ b/packages/agent-core/src/session/export/zip.ts @@ -0,0 +1,69 @@ +import { createWriteStream } from 'node:fs'; +import { mkdir, readdir, readFile } from 'node:fs/promises'; +import { dirname, join, relative } from 'node:path'; +import type { Readable } from 'node:stream'; +import { pipeline } from 'node:stream/promises'; + +import type { ExportSessionManifest } from '#/rpc/core-api'; +import { ZipFile } from 'yazl'; + +export async function collectFilesRecursive(root: string): Promise { + try { + const entries = await readdir(root, { recursive: true, withFileTypes: true }); + return entries + .filter((entry) => entry.isFile()) + .map((entry) => join(entry.parentPath, entry.name)) + .toSorted((a, b) => a.localeCompare(b)); + } catch { + return []; + } +} + +export type ExtraZipEntry = + | { + /** Absolute path on disk. */ + readonly source: string; + /** zip-relative target path. */ + readonly target: string; + } + | { + readonly data: Buffer; + /** zip-relative target path. */ + readonly target: string; + }; + +export async function writeExportZip(args: { + readonly outputPath: string; + readonly manifest: ExportSessionManifest; + readonly sessionDir: string; + readonly sessionFiles: readonly string[]; + readonly extraEntries?: readonly ExtraZipEntry[]; +}): Promise { + await mkdir(dirname(args.outputPath), { recursive: true }); + + const entries: string[] = ['manifest.json']; + const zip = new ZipFile(); + zip.addBuffer(Buffer.from(JSON.stringify(args.manifest, null, 2), 'utf-8'), 'manifest.json'); + + for (const abs of args.sessionFiles) { + const rel = relative(args.sessionDir, abs).split(/[\\/]/).join('/'); + const data = await readFile(abs); + zip.addBuffer(data, rel); + entries.push(rel); + } + + for (const extra of args.extraEntries ?? []) { + try { + const data = 'data' in extra ? extra.data : await readFile(extra.source); + zip.addBuffer(data, extra.target); + entries.push(extra.target); + } catch { + // missing source is not fatal — caller decided it should be opt-in; + // do not abort the whole export. + } + } + + zip.end(); + await pipeline(zip.outputStream as unknown as Readable, createWriteStream(args.outputPath)); + return entries; +} diff --git a/packages/agent-core/src/session/git-context.ts b/packages/agent-core/src/session/git-context.ts new file mode 100644 index 000000000..f30edb479 --- /dev/null +++ b/packages/agent-core/src/session/git-context.ts @@ -0,0 +1,197 @@ +/** + * Git repository context for explore subagents. + * + * `collectGitContext` produces a `` block that is prepended to a + * fresh explore subagent's prompt so it can orient itself in the repository + * before searching. Every git command is individually guarded — a single + * failure never aborts the whole collection — and remote URLs are sanitized + * so internal infrastructure is not surfaced to the model. + */ + +import type { Readable } from 'node:stream'; + +import type { Kaos } from '@moonshot-ai/kaos'; + +const GIT_TIMEOUT_MS = 5_000; +const MAX_DIRTY_FILES = 20; +const MAX_COMMIT_LINE_LENGTH = 200; + +// Well-known public hosts whose remote URLs are safe to surface. Self-hosted +// or unrecognized hosts are excluded to avoid leaking internal infrastructure. +const ALLOWED_HOSTS = [ + 'github.com', + 'gitlab.com', + 'gitee.com', + 'bitbucket.org', + 'codeberg.org', + 'git.sr.ht', +] as const; + +/** + * Collect git context for the explore agent. + * + * Returns a formatted `` block, or an empty string if the + * directory is not a git repository or no useful information was collected. + */ +export async function collectGitContext(kaos: Kaos, cwd: string): Promise { + // Quick check: is this a git repo? + if ((await runGit(kaos, cwd, ['rev-parse', '--is-inside-work-tree'])) === null) { + return ''; + } + + const [remoteUrl, branch, dirtyRaw, logRaw] = await Promise.all([ + runGit(kaos, cwd, ['remote', 'get-url', 'origin']), + runGit(kaos, cwd, ['branch', '--show-current']), + runGit(kaos, cwd, ['status', '--porcelain']), + runGit(kaos, cwd, ['log', '-3', '--format=%h %s']), + ]); + + const sections: string[] = [`Working directory: ${cwd}`]; + + if (remoteUrl) { + const safeUrl = sanitizeRemoteUrl(remoteUrl); + if (safeUrl) { + sections.push(`Remote: ${safeUrl}`); + // Derive the project slug only from an allowed remote — deriving it from + // a rejected host would leak an internal owner/repo into the prompt. + const project = parseProjectName(safeUrl); + if (project) sections.push(`Project: ${project}`); + } + } + + if (branch) sections.push(`Branch: ${branch}`); + + if (dirtyRaw !== null) { + const dirtyLines = dirtyRaw.split('\n').filter((line) => line.trim().length > 0); + if (dirtyLines.length > 0) { + const total = dirtyLines.length; + const shown = dirtyLines.slice(0, MAX_DIRTY_FILES); + let body = shown.map((line) => ` ${line}`).join('\n'); + if (total > MAX_DIRTY_FILES) { + body += `\n ... and ${String(total - MAX_DIRTY_FILES)} more`; + } + sections.push(`Dirty files (${String(total)}):\n${body}`); + } + } + + if (logRaw) { + const logLines = logRaw.split('\n').filter((line) => line.trim().length > 0); + if (logLines.length > 0) { + const body = logLines.map((line) => ` ${line.slice(0, MAX_COMMIT_LINE_LENGTH)}`).join('\n'); + sections.push(`Recent commits:\n${body}`); + } + } + + if (sections.length <= 1) { + // Only the working directory line — nothing useful collected. + return ''; + } + + return `\n${sections.join('\n')}\n`; +} + +/** + * Return the remote URL if it points to a well-known public host, stripping + * credentials from HTTPS URLs. Returns `null` for unrecognized hosts. + */ +export function sanitizeRemoteUrl(remoteUrl: string): string | null { + // SSH format: git@host:owner/repo.git — no credentials possible. + for (const host of ALLOWED_HOSTS) { + if (remoteUrl.startsWith(`git@${host}:`)) return remoteUrl; + } + + // HTTPS format: parse the hostname exactly and drop any userinfo. + let parsed: URL; + try { + parsed = new URL(remoteUrl); + } catch { + return null; + } + if ((ALLOWED_HOSTS as readonly string[]).includes(parsed.hostname)) { + const port = parsed.port ? `:${parsed.port}` : ''; + return `https://${parsed.hostname}${port}${parsed.pathname}`; + } + + return null; +} + +/** + * Extract the project path from a git remote URL — `owner/repo`, or the full + * `group/subgroup/repo` for nested namespaces (e.g. GitLab subgroups). + * Supports scp-like SSH (`git@host:path`) and URL forms (`https://`, `ssh://`). + */ +export function parseProjectName(remoteUrl: string): string | null { + // scp-like SSH (`git@host:owner/.../repo.git`) is not a valid URL — match it + // directly; everything else goes through URL parsing. The whole path is kept + // so nested namespaces survive. + const scp = /^[^/]+@[^/:]+:(.+)$/.exec(remoteUrl); + const rawPath = scp?.[1] ?? tryUrlPath(remoteUrl); + if (rawPath === null) return null; + const project = rawPath.replace(/^\/+/, '').replace(/\/+$/, '').replace(/\.git$/, ''); + return project.length > 0 ? project : null; +} + +function tryUrlPath(remoteUrl: string): string | null { + try { + return new URL(remoteUrl).pathname; + } catch { + return null; + } +} + +/** + * Run a single `git -C ` command and return its trimmed stdout, + * or `null` on any failure (spawn error, non-zero exit, or timeout). The + * `git -C` form runs in the target directory regardless of the Kaos backend. + */ +async function runGit(kaos: Kaos, cwd: string, args: readonly string[]): Promise { + let proc; + try { + proc = await kaos.exec('git', '-C', cwd, ...args); + } catch { + return null; + } + + try { + proc.stdin.end(); + } catch { + /* stdin already closed */ + } + + const work = Promise.all([collectStream(proc.stdout), proc.wait()]); + // Attach a rejection handler up front: if `work` rejects during the + // timeout-handling window (before the catch block re-awaits it), Node must + // not flag it as an unhandled rejection. + work.catch(() => {}); + let timer: ReturnType | undefined; + try { + const timeout = new Promise((_resolve, reject) => { + timer = setTimeout(() => { + reject(new Error(`git ${args.join(' ')} timed out`)); + }, GIT_TIMEOUT_MS); + }); + const [stdout, exitCode] = await Promise.race([work, timeout]); + if (exitCode !== 0) return null; + return stdout.trim(); + } catch { + try { + await proc.kill('SIGKILL'); + } catch { + /* process already gone */ + } + // Let the stdout drain settle so the process resources are released, + // even though the timed-out output is discarded. + await work.catch(() => {}); + return null; + } finally { + if (timer !== undefined) clearTimeout(timer); + } +} + +async function collectStream(stream: Readable): Promise { + const chunks: Buffer[] = []; + for await (const chunk of stream) { + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk as string)); + } + return Buffer.concat(chunks).toString('utf-8'); +} diff --git a/packages/agent-core/src/session/index.ts b/packages/agent-core/src/session/index.ts new file mode 100644 index 000000000..b4c0adba5 --- /dev/null +++ b/packages/agent-core/src/session/index.ts @@ -0,0 +1,511 @@ +import { homedir } from 'node:os'; +import { join } from 'node:path'; + +import { ErrorCodes, KimiError } from '#/errors'; +import { getRootLogger, log } from '#/logging/logger'; +import type { Logger, SessionLogHandle } from '#/logging/types'; +import type { SDKSessionRPC } from '#/rpc'; +import { proxyWithExtraPayload } from '#/rpc/types'; + +import { Agent, type AgentConfig, type AgentType } from '../agent'; +import { HookEngine, type HookDef } from '../agent/hooks'; +import type { PermissionManagerOptions, PermissionRule } from '../agent/permission'; +import { parseBooleanEnv, resolveConfigValue, type BackgroundConfig } from '../config'; +import { makeErrorPayload } from '../errors'; +import { + McpConnectionManager, + McpOAuthService, + type McpServerEntry, + type SessionMcpConfig, +} from '../mcp'; +import { + DEFAULT_AGENT_PROFILES, + DEFAULT_INIT_PROMPT, + loadAgentsMd, + prepareSystemPromptContext, + type ResolvedAgentProfile, +} from '../profile'; +import type { ProviderManager } from '../providers/provider-manager'; +import type { RuntimeConfig } from '../runtime-types'; +import { + registerBuiltinSkills, + resolveSkillRoots, + SkillRegistry, + summarizeSkill, + type SkillSummary, +} from '../skill'; +import { noopTelemetryClient, type TelemetryClient } from '../telemetry'; +import { SessionSubagentHost } from './subagent-host'; + +export interface SessionConfig { + readonly runtime: RuntimeConfig; + readonly id?: string | undefined; + readonly homedir: string; + readonly kimiHomeDir?: string; + readonly rpc: SDKSessionRPC; + readonly cwd?: string; + readonly initializeMainAgent?: boolean | undefined; + readonly providerManager?: ProviderManager | undefined; + readonly background?: BackgroundConfig | undefined; + readonly hooks?: readonly HookDef[]; + readonly permissionRules?: readonly PermissionRule[]; + readonly skills?: SessionSkillConfig; + readonly mcpConfig?: SessionMcpConfig; + readonly telemetry?: TelemetryClient | undefined; +} + +export interface SessionSkillConfig { + readonly userHomeDir?: string; + readonly explicitDirs?: readonly string[]; + readonly extraDirs?: readonly string[]; + readonly mergeAllAvailableSkills?: boolean; + readonly builtinDir?: string; +} + +export interface AgentMeta { + readonly homedir: string; + readonly type: AgentType; + readonly parentAgentId: string | null; +} + +export interface SessionMeta { + createdAt: string; + updatedAt: string; + title: string; + isCustomTitle: boolean; + lastPrompt?: string; + forkedFrom?: string; + agents: Record; + custom: Record; +} + +const BACKGROUND_KEEP_ALIVE_ON_EXIT_ENV = 'KIMI_CODE_BACKGROUND_KEEP_ALIVE_ON_EXIT'; + +export class Session { + readonly rpc: SDKSessionRPC; + readonly telemetry: TelemetryClient; + readonly skills: SkillRegistry; + readonly agents: Map = new Map(); + readonly mcp: McpConnectionManager; + readonly log: Logger; + private readonly logHandle: SessionLogHandle | undefined; + readonly hookEngine: HookEngine; + private agentIdCounter = 0; + private readonly skillsReady: Promise; + metadata: SessionMeta = { + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + title: 'New Session', + isCustomTitle: false, + agents: {}, + custom: {}, + }; + private writeMetadataPromise = Promise.resolve(); + + constructor(public readonly config: SessionConfig) { + // Attach the per-session log sink up front so the constructor's + // fire-and-forget `loadSkills` / `loadMcpServers` failures (and + // anything else that races) land in the session log, not just global. + this.logHandle = + config.id === undefined + ? undefined + : getRootLogger().attachSession({ + sessionId: config.id, + sessionDir: config.homedir, + }); + this.log = + this.logHandle?.logger ?? + (config.id === undefined ? log : log.createChild({ sessionId: config.id })); + this.rpc = config.rpc; + this.hookEngine = new HookEngine(config.hooks, { + cwd: config.cwd, + sessionId: config.id, + }); + this.telemetry = config.telemetry ?? noopTelemetryClient; + this.skills = new SkillRegistry({ sessionId: config.id }); + this.mcp = new McpConnectionManager({ + oauthService: new McpOAuthService({ kimiHomeDir: config.kimiHomeDir }), + log: this.log, + }); + this.mcp.onStatusChange((entry) => { + this.onMcpServerStatusChange(entry); + }); + this.skillsReady = this.loadSkills() + .catch((error: unknown) => { + this.log.error('skills load failed', error); + }) + .then(() => { + this.refreshAgentBuiltinTools(); + }); + void this.loadMcpServers().catch((error: unknown) => { + this.emitInitialMcpLoadError(error); + }); + } + + async createMain() { + const { agent } = await this.createAgent({ type: 'main' }, DEFAULT_AGENT_PROFILES['agent']); + await this.triggerSessionStart('startup'); + return agent; + } + + async resume() { + await this.skillsReady; + const { agents } = await this.readMetadata(); + this.agents.clear(); + const resumeTasks = Object.keys(agents).map(async (id) => { + const agent = this.ensureResumeAgentInstantiated(id, agents); + await agent.resume(); + }); + await Promise.all(resumeTasks); + // A session migrated from an external tool ships a wire without the + // `config.update` bootstrap events a natively-created agent writes, so the + // main agent comes back with an empty system prompt and no tools. Apply the + // default profile so the resumed session is usable. Native sessions always + // replay a non-empty system prompt and never enter this branch. + const main = this.agents.get('main'); + const profile = DEFAULT_AGENT_PROFILES['agent']; + if (main !== undefined && profile !== undefined && main.config.systemPrompt === '') { + await this.bootstrapAgentProfile(main, profile); + } + await this.triggerSessionStart('resume'); + } + + async close(): Promise { + try { + await this.stopBackgroundTasksOnExit(); + await this.flushMetadata(); + await this.triggerSessionEnd('exit'); + } finally { + try { + await this.mcp.shutdown(); + } finally { + await this.logHandle?.close(); + } + } + } + + private async stopBackgroundTasksOnExit(): Promise { + const keepAliveOnExit = resolveConfigValue({ + env: process.env, + envKey: BACKGROUND_KEEP_ALIVE_ON_EXIT_ENV, + configValue: this.config.background?.keepAliveOnExit, + defaultValue: true, + parseEnv: parseBooleanEnv, + }); + if (keepAliveOnExit) return; + await Promise.all( + Array.from(this.agents.values(), (agent) => + agent.background.stopAll('Session closed'), + ), + ); + } + + async createAgent( + config: Partial, + profile?: ResolvedAgentProfile, + parentAgentId?: string | undefined, + ): Promise<{ readonly id: string; readonly agent: Agent }> { + await this.skillsReady; + const type = config.type ?? 'main'; + const id = type === 'main' ? 'main' : this.nextGeneratedAgentId(); + const homedir = config.homedir ?? join(this.config.homedir, 'agents', id); + const agent = this.instantiateAgent(id, homedir, type, config, parentAgentId ?? null); + if (profile) { + await this.bootstrapAgentProfile(agent, profile); + } else if (this.config.cwd !== undefined) { + agent.config.update({ cwd: this.config.cwd }); + } + + this.agents.set(id, agent); + this.metadata.agents[id] = { + homedir, + type, + parentAgentId: parentAgentId ?? null, + }; + void this.writeMetadata(); + + return { id, agent }; + } + + /** + * Applies a profile's derived config — cwd, system prompt, active tools — to + * an agent. Fresh creation and resume-of-an-incomplete-wire both route + * through here so the two paths cannot drift apart. + */ + private async bootstrapAgentProfile( + agent: Agent, + profile: ResolvedAgentProfile, + ): Promise { + if (this.config.cwd !== undefined) { + agent.config.update({ cwd: this.config.cwd }); + } + const context = await prepareSystemPromptContext(this.config.runtime.kaos, agent.config.cwd); + agent.useProfile(profile, context); + } + + async generateAgentsMd(): Promise { + await this.skillsReady; + const mainAgent = this.requireMainAgent(); + + try { + const handle = await mainAgent.subagentHost!.spawn('coder', { + parentToolCallId: 'generate-agents-md', + prompt: DEFAULT_INIT_PROMPT, + description: 'Initialize AGENTS.md', + runInBackground: false, + origin: { kind: 'system_trigger', name: 'init' }, + signal: new AbortController().signal, + }); + await handle.completion; + + const agentsMd = await loadAgentsMd(this.config.runtime.kaos, mainAgent.config.cwd); + mainAgent.context.appendSystemReminder(initCompletionReminder(agentsMd), { + kind: 'injection', + variant: 'init', + }); + await mainAgent.records.flush(); + } catch (error) { + throw new KimiError( + ErrorCodes.SESSION_INIT_FAILED, + error instanceof Error ? error.message : 'Init failed', + { cause: error }, + ); + } + } + + get hasActiveTurn(): boolean { + for (const agent of this.agents.values()) { + if (agent.turn.hasActiveTurn) return true; + } + return false; + } + + protected get metadataPath() { + return join(this.config.homedir, 'state.json'); + } + + writeMetadata() { + const text = JSON.stringify(this.metadata, null, 2); + const write = async () => { + await this.config.runtime.kaos.mkdir(this.config.homedir, { parents: true, existOk: true }); + await this.config.runtime.kaos.writeText(this.metadataPath, text); + }; + this.writeMetadataPromise = this.writeMetadataPromise.then(write, write); + return this.writeMetadataPromise; + } + + async readMetadata() { + const text = await this.config.runtime.kaos.readText(this.metadataPath); + this.metadata = JSON.parse(text); + return this.metadata; + } + + async flushMetadata() { + await this.skillsReady; + await this.writeMetadataPromise; + await Promise.all(Array.from(this.agents.values()).map((agent) => agent.records.flush())); + } + + async listSkills(): Promise { + await this.skillsReady; + return this.skills.listSkills().map(summarizeSkill); + } + + private async loadSkills(): Promise { + const roots = await resolveSkillRoots({ + paths: { + userHomeDir: this.config.skills?.userHomeDir ?? homedir(), + workDir: this.config.cwd ?? process.cwd(), + }, + explicitDirs: this.config.skills?.explicitDirs, + extraDirs: this.config.skills?.extraDirs, + mergeAllAvailableSkills: this.config.skills?.mergeAllAvailableSkills, + builtinDir: this.config.skills?.builtinDir, + }); + await this.skills.loadRoots(roots); + registerBuiltinSkills(this.skills); + } + + private async loadMcpServers(): Promise { + const servers = this.config.mcpConfig?.servers; + if (servers === undefined || Object.keys(servers).length === 0) return; + await this.mcp.connectAll(servers); + const entries = this.mcp.list().filter((entry) => entry.status !== 'disabled'); + const totalCount = entries.length; + if (totalCount === 0) return; + + const connectedCount = entries.filter((entry) => entry.status === 'connected').length; + if (connectedCount > 0) { + this.telemetry.track('mcp_connected', { + server_count: connectedCount, + total_count: totalCount, + }); + } + + const failedCount = entries.filter((entry) => entry.status === 'failed').length; + if (failedCount > 0) { + this.telemetry.track('mcp_failed', { + failed_count: failedCount, + total_count: totalCount, + }); + } + } + + private emitInitialMcpLoadError(error: unknown): void { + const message = error instanceof Error ? error.message : String(error); + this.log.error('mcp initial load failed', error); + void this.rpc.emitEvent({ + type: 'error', + agentId: 'main', + ...makeErrorPayload(ErrorCodes.MCP_STARTUP_FAILED, message), + }); + } + + private onMcpServerStatusChange(entry: McpServerEntry): void { + // Always surface server-level status changes to clients so the TUI/SDK + // can keep its dashboard in sync, even before the main agent exists. + void this.rpc.emitEvent({ + type: 'mcp.server.status', + agentId: 'main', + server: { + name: entry.name, + transport: entry.transport, + status: entry.status, + toolCount: entry.toolCount, + error: entry.error, + }, + }); + } + + private backgroundTaskTimeoutMs(): number | undefined { + const timeoutS = this.config.background?.agentTaskTimeoutS; + return timeoutS === undefined ? undefined : timeoutS * 1000; + } + + private refreshAgentBuiltinTools(): void { + for (const agent of this.agents.values()) { + if (!agent.config.hasProvider) continue; + agent.tools.initializeBuiltinTools(); + } + } + + private instantiateAgent( + id: string, + homedir: string, + type: AgentType, + config: Partial = {}, + parentAgentId: string | null = null, + ): Agent { + return new Agent({ + ...config, + type, + runtime: this.config.runtime, + homedir, + skills: this.skills, + rpc: proxyWithExtraPayload(this.rpc, { agentId: id }), + providerManager: this.config.providerManager, + sessionId: this.config.id, + hookEngine: config.hookEngine ?? this.hookEngine, + subagentHost: + config.subagentHost ?? new SessionSubagentHost(this, id, this.backgroundTaskTimeoutMs()), + mcp: this.mcp, + backgroundMaxRunningTasks: this.config.background?.maxRunningTasks, + backgroundSessionDir: homedir, + permission: this.permissionOptions(parentAgentId, config.permission), + telemetry: this.telemetry, + log: this.log.createChild({ agentId: id }), + }); + } + + private permissionOptions( + parentAgentId: string | null, + input?: PermissionManagerOptions | undefined, + ): PermissionManagerOptions { + if (parentAgentId === null) { + return { + ...input, + initialRules: input?.initialRules ?? this.config.permissionRules, + }; + } + return { + ...input, + parent: input?.parent ?? this.agents.get(parentAgentId)?.permission, + }; + } + + private ensureResumeAgentInstantiated( + id: string, + agents: Record, + stack: readonly string[] = [], + ): Agent { + const existing = this.agents.get(id); + if (existing !== undefined) return existing; + if (stack.includes(id)) { + throw new KimiError( + ErrorCodes.SESSION_STATE_INVALID, + `Session agent parent chain contains a cycle: ${[...stack, id].join(' -> ')}`, + ); + } + + const meta = agents[id]; + if (meta === undefined) { + throw new KimiError(ErrorCodes.SESSION_STATE_INVALID, `Session agent "${id}" is missing`); + } + + const parentAgentId = meta.parentAgentId ?? null; + if (parentAgentId !== null) { + this.ensureResumeAgentInstantiated(parentAgentId, agents, [...stack, id]); + } + + const agent = this.instantiateAgent(id, meta.homedir, meta.type, {}, parentAgentId); + this.agents.set(id, agent); + return agent; + } + + private nextGeneratedAgentId(): string { + while (true) { + const id = `agent-${this.agentIdCounter++}`; + if (this.agents.has(id)) continue; + if (this.metadata.agents[id] !== undefined) continue; + return id; + } + } + + private requireMainAgent(): Agent { + const agent = this.agents.get('main'); + if (agent === undefined) { + throw new KimiError(ErrorCodes.AGENT_NOT_FOUND, 'Main agent was not found'); + } + return agent; + } + + private async triggerSessionStart(source: 'startup' | 'resume'): Promise { + await this.hookEngine.trigger('SessionStart', { + matcherValue: source, + inputData: { source }, + }); + } + + private async triggerSessionEnd(reason: 'exit'): Promise { + await this.hookEngine.trigger('SessionEnd', { + matcherValue: reason, + inputData: { reason }, + }); + } +} + +export * from './subagent-host'; + +function initCompletionReminder(agentsMd: string): string { + const latest = + agentsMd.trim().length === 0 + ? 'No AGENTS.md content was found after `/init` completed.' + : agentsMd; + return [ + 'The user just ran `/init` slash command.', + 'The system has analyzed the codebase and generated an `AGENTS.md` file.', + '', + 'Latest AGENTS.md file content:', + latest, + ].join('\n'); +} diff --git a/packages/agent-core/src/session/prompt-metadata.ts b/packages/agent-core/src/session/prompt-metadata.ts new file mode 100644 index 000000000..94b417346 --- /dev/null +++ b/packages/agent-core/src/session/prompt-metadata.ts @@ -0,0 +1,62 @@ +import type { ActivateSkillPayload, PromptPayload } from '#/rpc'; +import type { ContentPart } from '@moonshot-ai/kosong'; + +const MAX_TITLE_LENGTH = 200; +const MAX_LAST_PROMPT_LENGTH = 4000; + +export function titleFromPromptMetadataText(text: string): string { + return text.slice(0, MAX_TITLE_LENGTH); +} + +export function promptMetadataTextFromPayload(payload: PromptPayload): string | undefined { + const parts: string[] = []; + for (const part of payload.input) { + const text = promptPartText(part); + if (text !== undefined) parts.push(text); + } + return sanitizeAndTruncatePromptText(parts.join('\n'), MAX_LAST_PROMPT_LENGTH); +} + +export function promptMetadataTextFromSkill(payload: ActivateSkillPayload): string | undefined { + const args = payload.args?.trim(); + return sanitizeAndTruncatePromptText( + args === undefined || args.length === 0 ? `/${payload.name}` : `/${payload.name} ${args}`, + MAX_LAST_PROMPT_LENGTH, + ); +} + +function promptPartText(part: ContentPart): string | undefined { + switch (part.type) { + case 'text': + return part.text; + case 'image_url': + return '[image]'; + case 'audio_url': + return '[audio]'; + case 'video_url': + return '[video]'; + case 'think': + return undefined; + } +} + +function sanitizeAndTruncatePromptText(text: string, maxLength: number): string | undefined { + const sanitized = text + .replaceAll( + /-----BEGIN [^-]*PRIVATE KEY-----[\s\S]*?-----END [^-]*PRIVATE KEY-----/gi, + '[redacted]', + ) + .replaceAll(/\b(authorization)\s*:\s*bearer\s+\S+/gi, '$1: Bearer [redacted]') + .replaceAll( + /\b(api[_-]?key|token|secret|password|passwd|pwd)\b\s*[:=]\s*(?:"[^"]*"|'[^']*'|\S+)/gi, + '$1=[redacted]', + ) + .replaceAll(/\bsk-[A-Za-z0-9_-]{12,}\b/g, '[redacted]') + .replaceAll(/\b[A-Za-z0-9][A-Za-z0-9+/=_-]{39,}\b/g, '[redacted]') + .replaceAll(/\p{Cc}+/gu, ' ') + .replaceAll(/\s+/g, ' ') + .trim(); + + if (sanitized.length === 0) return undefined; + return sanitized.slice(0, maxLength); +} diff --git a/packages/agent-core/src/session/rpc.ts b/packages/agent-core/src/session/rpc.ts new file mode 100644 index 000000000..be5eac82b --- /dev/null +++ b/packages/agent-core/src/session/rpc.ts @@ -0,0 +1,261 @@ +import { ErrorCodes, KimiError } from '#/errors'; +import type { + ActivateSkillPayload, + AgentAPI, + BeginCompactionPayload, + CancelPayload, + CancelPlanPayload, + EmptyPayload, + GetBackgroundOutputPathPayload, + GetBackgroundOutputPayload, + GetBackgroundPayload, + McpServerInfo, + McpStartupMetrics, + PromptPayload, + ReconnectMcpServerPayload, + RenameSessionPayload, + RegisterToolPayload, + SessionAPI, + SetActiveToolsPayload, + SetModelPayload, + SetPermissionPayload, + SetThinkingPayload, + SkillSummary, + SteerPayload, + StopBackgroundPayload, + UnregisterToolPayload, + UpdateSessionMetadataPayload, +} from '#/rpc'; +import type { PromisableMethods } from '#/utils/types'; + +import type { Session, SessionMeta } from '.'; +import { + promptMetadataTextFromPayload, + promptMetadataTextFromSkill, + titleFromPromptMetadataText, +} from './prompt-metadata'; + +type AgentScopedPayload = T & { agentId: string }; + +export class SessionAPIImpl implements PromisableMethods { + constructor(protected readonly session: Session) {} + + async renameSession(payload: RenameSessionPayload): Promise { + const title = payload.title.trim(); + if (title.length === 0) { + throw new KimiError(ErrorCodes.SESSION_TITLE_EMPTY, 'Session title cannot be empty'); + } + this.session.metadata = { + ...this.session.metadata, + title, + isCustomTitle: true, + updatedAt: new Date().toISOString(), + }; + await this.session.writeMetadata(); + } + + async updateSessionMetadata(payload: UpdateSessionMetadataPayload): Promise { + this.session.metadata = { + ...this.session.metadata, + ...payload.metadata, + agents: this.session.metadata.agents, + }; + await this.session.writeMetadata(); + } + + getSessionMetadata(_payload: EmptyPayload): SessionMeta { + return this.session.metadata; + } + + listSkills(_payload: EmptyPayload): Promise { + return this.session.listSkills(); + } + + listMcpServers(_payload: EmptyPayload): readonly McpServerInfo[] { + return this.session.mcp.list(); + } + + async getMcpStartupMetrics(_payload: EmptyPayload): Promise { + await this.session.mcp.waitForInitialLoad(); + return { durationMs: this.session.mcp.initialLoadDurationMs() }; + } + + async reconnectMcpServer(payload: ReconnectMcpServerPayload): Promise { + await this.session.mcp.reconnect(payload.name); + } + + generateAgentsMd(_payload: EmptyPayload): Promise { + return this.session.generateAgentsMd(); + } + + async prompt({ agentId, ...payload }: AgentScopedPayload) { + if (agentId === 'main') { + await this.updatePromptMetadata(promptMetadataTextFromPayload(payload)); + } + return this.getAgent(agentId).prompt(payload); + } + + steer({ agentId, ...payload }: AgentScopedPayload) { + return this.getAgent(agentId).steer(payload); + } + + cancel({ agentId, ...payload }: AgentScopedPayload) { + return this.getAgent(agentId).cancel(payload); + } + + setModel({ agentId, ...payload }: AgentScopedPayload) { + return this.getAgent(agentId).setModel(payload); + } + + setThinking({ agentId, ...payload }: AgentScopedPayload) { + return this.getAgent(agentId).setThinking(payload); + } + + setPermission({ agentId, ...payload }: AgentScopedPayload) { + return this.getAgent(agentId).setPermission(payload); + } + + getModel({ agentId, ...payload }: AgentScopedPayload) { + return this.getAgent(agentId).getModel(payload); + } + + enterPlan({ agentId, ...payload }: AgentScopedPayload) { + return this.getAgent(agentId).enterPlan(payload); + } + + cancelPlan({ agentId, ...payload }: AgentScopedPayload) { + return this.getAgent(agentId).cancelPlan(payload); + } + + clearPlan({ agentId, ...payload }: AgentScopedPayload) { + return this.getAgent(agentId).clearPlan(payload); + } + + beginCompaction({ agentId, ...payload }: AgentScopedPayload) { + return this.getAgent(agentId).beginCompaction(payload); + } + + cancelCompaction({ agentId, ...payload }: AgentScopedPayload) { + return this.getAgent(agentId).cancelCompaction(payload); + } + + registerTool({ agentId, ...payload }: AgentScopedPayload) { + return this.getAgent(agentId).registerTool(payload); + } + + unregisterTool({ agentId, ...payload }: AgentScopedPayload) { + return this.getAgent(agentId).unregisterTool(payload); + } + + setActiveTools({ agentId, ...payload }: AgentScopedPayload) { + return this.getAgent(agentId).setActiveTools(payload); + } + + stopBackground({ agentId, ...payload }: AgentScopedPayload) { + return this.getAgent(agentId).stopBackground(payload); + } + + clearContext({ agentId, ...payload }: AgentScopedPayload) { + return this.getAgent(agentId).clearContext(payload); + } + + async activateSkill({ agentId, ...payload }: AgentScopedPayload) { + await this.getAgent(agentId).activateSkill(payload); + if (agentId === 'main') { + await this.updatePromptMetadata(promptMetadataTextFromSkill(payload)); + } + } + + getBackgroundOutput({ agentId, ...payload }: AgentScopedPayload) { + return this.getAgent(agentId).getBackgroundOutput(payload); + } + + getBackgroundOutputPath({ + agentId, + ...payload + }: AgentScopedPayload) { + return this.getAgent(agentId).getBackgroundOutputPath(payload); + } + + getContext({ agentId, ...payload }: AgentScopedPayload) { + return this.getAgent(agentId).getContext(payload); + } + + getConfig({ agentId, ...payload }: AgentScopedPayload) { + return this.getAgent(agentId).getConfig(payload); + } + + getPermission({ agentId, ...payload }: AgentScopedPayload) { + return this.getAgent(agentId).getPermission(payload); + } + + getPlan({ agentId, ...payload }: AgentScopedPayload) { + return this.getAgent(agentId).getPlan(payload); + } + + getUsage({ agentId, ...payload }: AgentScopedPayload) { + return this.getAgent(agentId).getUsage(payload); + } + + getTools({ agentId, ...payload }: AgentScopedPayload) { + return this.getAgent(agentId).getTools(payload); + } + + getBackground({ agentId, ...payload }: AgentScopedPayload) { + return this.getAgent(agentId).getBackground(payload); + } + + private getAgent(agentId: string): PromisableMethods { + const agent = this.session.agents.get(agentId); + if (agent === undefined) { + throw new KimiError(ErrorCodes.AGENT_NOT_FOUND, `Agent "${agentId}" was not found`); + } + return agent.rpcMethods; + } + + private needUpdateEasyTitle(metadata: SessionMeta): boolean { + if (hasCustomTitle(metadata)) return false; + if (!isUntitled(metadata.title)) return false; + return true; + } + + private async updatePromptMetadata(lastPrompt: string | undefined): Promise { + if (lastPrompt === undefined) return; + + const title = this.needUpdateEasyTitle(this.session.metadata) + ? titleFromPromptMetadataText(lastPrompt) + : undefined; + const now = new Date().toISOString(); + const nextMetadata = { + ...this.session.metadata, + lastPrompt, + updatedAt: now, + }; + if (title !== undefined) { + nextMetadata.title = title; + nextMetadata.isCustomTitle = false; + } + + this.session.metadata = nextMetadata; + await this.session.writeMetadata(); + await this.session.rpc.emitEvent({ + type: 'session.meta.updated', + agentId: 'main', + title, + patch: { + title, + isCustomTitle: title === undefined ? undefined : false, + lastPrompt, + }, + }); + } +} + +function isUntitled(title: unknown): boolean { + return typeof title !== 'string' || title.trim().length === 0 || title === 'New Session'; +} + +function hasCustomTitle(metadata: SessionMeta): boolean { + if (metadata.isCustomTitle) return true; + return typeof (metadata as SessionMeta & { customTitle?: unknown }).customTitle === 'string'; +} diff --git a/packages/agent-core/src/session/store/index.ts b/packages/agent-core/src/session/store/index.ts new file mode 100644 index 000000000..54bba0465 --- /dev/null +++ b/packages/agent-core/src/session/store/index.ts @@ -0,0 +1,8 @@ +export { SessionStore } from '#/session/store/session-store'; +export type { + CreateSessionRecordInput, + ForkSessionRecordInput, + SessionStoreOptions, +} from '#/session/store/session-store'; +export { sessionIndexPath } from '#/session/store/session-index'; +export { encodeWorkDirKey, normalizeWorkDir } from '#/session/store/workdir-key'; diff --git a/packages/agent-core/src/session/store/session-index.ts b/packages/agent-core/src/session/store/session-index.ts new file mode 100644 index 000000000..6b77ed007 --- /dev/null +++ b/packages/agent-core/src/session/store/session-index.ts @@ -0,0 +1,79 @@ +import { appendFile, mkdir, readFile } from 'node:fs/promises'; +import { basename, dirname, isAbsolute, join, relative, resolve } from 'node:path'; + +export interface SessionIndexEntry { + readonly sessionId: string; + readonly sessionDir: string; + readonly workDir: string; +} + +export function sessionIndexPath(homeDir: string): string { + return join(homeDir, 'session_index.jsonl'); +} + +export async function appendSessionIndexEntry( + homeDir: string, + entry: SessionIndexEntry, +): Promise { + const indexPath = sessionIndexPath(homeDir); + await mkdir(dirname(indexPath), { recursive: true, mode: 0o700 }); + await appendFile(indexPath, `${JSON.stringify(entry)}\n`, 'utf-8'); +} + +export async function readSessionIndex( + homeDir: string, + sessionsDir: string, +): Promise> { + let raw: string; + try { + raw = await readFile(sessionIndexPath(homeDir), 'utf-8'); + } catch { + return new Map(); + } + + const result = new Map(); + for (const line of raw.split(/\r?\n/)) { + const trimmed = line.trim(); + if (trimmed === '') continue; + const entry = parseIndexLine(trimmed); + if (entry === undefined) continue; + const sessionDir = resolve(entry.sessionDir); + if (!isAbsolute(entry.sessionDir)) continue; + if (!isAbsolute(entry.workDir)) continue; + if (!isPathInside(sessionsDir, sessionDir)) continue; + if (basename(sessionDir) !== entry.sessionId) continue; + result.set(entry.sessionId, { + sessionId: entry.sessionId, + sessionDir, + workDir: resolve(entry.workDir), + }); + } + return result; +} + +function parseIndexLine(line: string): SessionIndexEntry | undefined { + try { + const parsed = JSON.parse(line) as unknown; + if (typeof parsed !== 'object' || parsed === null) return undefined; + const entry = parsed as Partial; + if ( + typeof entry.sessionId !== 'string' || + typeof entry.sessionDir !== 'string' || + typeof entry.workDir !== 'string' + ) { + return undefined; + } + return { + sessionId: entry.sessionId, + sessionDir: entry.sessionDir, + workDir: entry.workDir, + }; + } catch { + return undefined; + } +} + +function isPathInside(parent: string, child: string): boolean { + const rel = relative(resolve(parent), resolve(child)); + return rel !== '' && !rel.startsWith('..') && !isAbsolute(rel); +} diff --git a/packages/agent-core/src/session/store/session-store.ts b/packages/agent-core/src/session/store/session-store.ts new file mode 100644 index 000000000..88a1bfcd0 --- /dev/null +++ b/packages/agent-core/src/session/store/session-store.ts @@ -0,0 +1,366 @@ +import { cp, mkdir, readdir, readFile, rm, stat, writeFile } from 'node:fs/promises'; +import { dirname, isAbsolute, join, relative } from 'node:path'; + +import { z } from 'zod'; + +import { ErrorCodes, KimiError } from '#/errors'; +import type { SessionIndexEntry } from '#/session/store/session-index'; +import { appendSessionIndexEntry, readSessionIndex } from '#/session/store/session-index'; +import { encodeWorkDirKey, normalizeWorkDir } from '#/session/store/workdir-key'; +import type { JsonObject, ListSessionsPayload, SessionSummary } from '#/rpc/core-api'; + +const SessionSummaryStateSchema = z.object({ + customTitle: z.string().optional(), + isCustomTitle: z.boolean().optional(), + lastPrompt: z.string().optional(), + title: z.string().optional(), + custom: z.record(z.string(), z.unknown()).optional(), +}); + +type SessionSummaryState = z.infer; + +export interface CreateSessionRecordInput { + readonly id: string; + readonly workDir: string; +} + +export interface ForkSessionRecordInput { + readonly sourceId: string; + readonly targetId: string; + readonly title?: string; + readonly metadata?: JsonObject; +} + +export type SessionStoreOptions = Record; + +export class SessionStore { + readonly sessionsDir: string; + + constructor( + readonly homeDir: string, + _options: SessionStoreOptions = {}, + ) { + this.sessionsDir = join(homeDir, 'sessions'); + } + + sessionDirFor(input: { readonly id: string; readonly workDir: string }): string { + assertSafeSessionId(input.id); + return join(this.sessionsDir, encodeWorkDirKey(normalizeWorkDir(input.workDir)), input.id); + } + + async create(input: CreateSessionRecordInput): Promise { + assertSafeSessionId(input.id); + const workDir = normalizeWorkDir(input.workDir); + const indexed = await this.findSessionEntry(input.id); + if (indexed !== undefined) { + throw new KimiError(ErrorCodes.SESSION_ALREADY_EXISTS, `Session "${input.id}" already exists`); + } + + const dir = this.sessionDirFor({ id: input.id, workDir }); + if (await isDirectory(dir)) { + throw new KimiError(ErrorCodes.SESSION_ALREADY_EXISTS, `Session "${input.id}" already exists`); + } + + await mkdir(dir, { recursive: true, mode: 0o700 }); + await appendSessionIndexEntry(this.homeDir, { + sessionId: input.id, + sessionDir: dir, + workDir, + }); + return this.summaryFromDir(input.id, dir, workDir); + } + + async fork(input: ForkSessionRecordInput): Promise { + const source = await this.findExistingSessionEntry(input.sourceId); + assertSafeSessionId(input.targetId); + const indexed = await this.findSessionEntry(input.targetId); + if (indexed !== undefined) { + throw new KimiError(ErrorCodes.SESSION_ALREADY_EXISTS, `Session "${input.targetId}" already exists`); + } + + const targetDir = this.sessionDirFor({ id: input.targetId, workDir: source.workDir }); + if (await isDirectory(targetDir)) { + throw new KimiError(ErrorCodes.SESSION_ALREADY_EXISTS, `Session "${input.targetId}" already exists`); + } + + await mkdir(dirname(targetDir), { recursive: true, mode: 0o700 }); + try { + await cp(source.sessionDir, targetDir, { + recursive: true, + force: false, + errorOnExist: true, + }); + await this.writeForkedState(input, source.sessionDir, targetDir); + const summary = await this.summaryFromDir(input.targetId, targetDir, source.workDir); + await appendSessionIndexEntry(this.homeDir, { + sessionId: input.targetId, + sessionDir: targetDir, + workDir: source.workDir, + }); + return summary; + } catch (error) { + await rm(targetDir, { recursive: true, force: true }).catch(() => {}); + throw error; + } + } + + async get(id: string): Promise { + const entry = await this.findExistingSessionEntry(id); + return this.summaryFromDir(id, entry.sessionDir, entry.workDir); + } + + async rename(id: string, title: string): Promise { + const normalized = title.trim(); + if (normalized.length === 0) { + throw new KimiError(ErrorCodes.SESSION_TITLE_EMPTY, 'Session title cannot be empty'); + } + const entry = await this.findExistingSessionEntry(id); + const statePath = join(entry.sessionDir, 'state.json'); + let parsed: unknown; + try { + parsed = JSON.parse(await readFile(statePath, 'utf-8')) as unknown; + } catch (error) { + throw new KimiError(ErrorCodes.SESSION_STATE_NOT_FOUND, `Session "${id}" state.json was not found`, { + cause: error, + }); + } + if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) { + throw new KimiError(ErrorCodes.SESSION_STATE_INVALID, `Session "${id}" state.json is invalid`); + } + const next: Record = { + ...(parsed as Record), + title: normalized, + isCustomTitle: true, + }; + await writeFile(statePath, `${JSON.stringify(next, null, 2)}\n`, 'utf-8'); + } + + async list(options: ListSessionsPayload): Promise { + const workDir = normalizeWorkDir(options.workDir); + const bucketDir = join(this.sessionsDir, encodeWorkDirKey(workDir)); + let entries; + try { + entries = await readdir(bucketDir, { withFileTypes: true }); + } catch { + return []; + } + + const sessions: SessionSummary[] = []; + for (const entry of entries) { + if (!entry.isDirectory()) continue; + const id = entry.name; + if (!isSafeSessionId(id)) continue; + const dir = join(bucketDir, id); + sessions.push(await this.summaryFromDir(id, dir, workDir)); + } + sessions.sort(compareSessionSummary); + return sessions; + } + + async assertDirectory(id: string): Promise { + return (await this.findExistingSessionEntry(id)).sessionDir; + } + + private async findSessionEntry(id: string): Promise { + if (!isSafeSessionId(id)) return undefined; + const index = await readSessionIndex(this.homeDir, this.sessionsDir); + return index.get(id); + } + + private async findExistingSessionEntry(id: string): Promise { + const entry = await this.findSessionEntry(id); + if (entry !== undefined && (await isDirectory(entry.sessionDir))) return entry; + throw new KimiError(ErrorCodes.SESSION_NOT_FOUND, `Session "${id}" was not found`, { + details: { sessionId: id }, + }); + } + + private async writeForkedState( + input: ForkSessionRecordInput, + sourceDir: string, + targetDir: string, + ): Promise { + const statePath = join(targetDir, 'state.json'); + let parsed: unknown; + try { + parsed = JSON.parse(await readFile(statePath, 'utf-8')) as unknown; + } catch (error) { + throw new KimiError( + ErrorCodes.SESSION_STATE_NOT_FOUND, + `Session "${input.sourceId}" state.json was not found`, + { + cause: error, + }, + ); + } + if (!isRecord(parsed)) { + throw new KimiError( + ErrorCodes.SESSION_STATE_INVALID, + `Session "${input.sourceId}" state.json is invalid`, + ); + } + + const title = normalizeForkTitle(input.title, parsed['title']); + const now = new Date().toISOString(); + const next: Record = { + ...parsed, + createdAt: now, + updatedAt: now, + title, + isCustomTitle: input.title === undefined ? parsed['isCustomTitle'] === true : true, + forkedFrom: input.sourceId, + agents: rewriteAgentHomedirs(parsed['agents'], sourceDir, targetDir), + custom: Object.assign({}, isRecord(parsed['custom']) ? parsed['custom'] : {}, input.metadata), + }; + await writeFile(statePath, `${JSON.stringify(next, null, 2)}\n`, 'utf-8'); + } + + private async summaryFromDir( + id: string, + sessionDir: string, + workDir: string, + ): Promise { + const dirStat = await stat(sessionDir); + const state = await readOptionalState(sessionDir); + const [stateInfo, wireInfo, agentsWireMtime] = await Promise.all([ + statIfExists(join(sessionDir, 'state.json')), + statIfExists(join(sessionDir, 'wire.jsonl')), + latestAgentWireMtime(sessionDir), + ]); + return { + id, + workDir, + sessionDir, + createdAt: timestampOrFallback(dirStat.birthtimeMs, dirStat.ctimeMs), + updatedAt: Math.max( + dirStat.mtimeMs, + stateInfo?.mtimeMs ?? 0, + wireInfo?.mtimeMs ?? 0, + agentsWireMtime ?? 0, + ), + title: titleFromState(state), + lastPrompt: state?.lastPrompt, + metadata: metadataFromState(state), + }; + } +} + +function metadataFromState(state: SessionSummaryState | undefined): JsonObject | undefined { + if (state === undefined || state.custom === undefined) return undefined; + return state.custom as JsonObject; +} + +async function latestAgentWireMtime(sessionDir: string): Promise { + const agentsDir = join(sessionDir, 'agents'); + let entries; + try { + entries = await readdir(agentsDir, { withFileTypes: true }); + } catch { + return undefined; + } + + let latest = 0; + for (const entry of entries) { + if (!entry.isDirectory()) continue; + const wireInfo = await statIfExists(join(agentsDir, entry.name, 'wire.jsonl')); + latest = Math.max(latest, wireInfo?.mtimeMs ?? 0); + } + return latest > 0 ? latest : undefined; +} + +function titleFromState(state: SessionSummaryState | undefined): string | undefined { + if (state === undefined) return undefined; + if (typeof state.isCustomTitle === 'boolean' && typeof state.title === 'string') { + return state.title; + } + if (typeof state.customTitle === 'string') return state.customTitle; + return typeof state.title === 'string' ? state.title : undefined; +} + +async function readOptionalState(sessionDir: string): Promise { + try { + const parsed = JSON.parse(await readFile(join(sessionDir, 'state.json'), 'utf-8')) as unknown; + const result = SessionSummaryStateSchema.safeParse(parsed); + return result.success ? result.data : undefined; + } catch { + return undefined; + } +} + +function normalizeForkTitle(title: string | undefined, fallback: unknown): string { + if (title !== undefined) { + const normalized = title.trim(); + if (normalized.length === 0) { + throw new KimiError(ErrorCodes.SESSION_TITLE_EMPTY, 'Session title cannot be empty'); + } + return normalized; + } + return typeof fallback === 'string' && fallback.trim().length > 0 ? fallback : 'New Session'; +} + +function rewriteAgentHomedirs(value: unknown, sourceDir: string, targetDir: string): unknown { + if (!isRecord(value)) return {}; + + const agents: Record = {}; + for (const [agentId, agentMeta] of Object.entries(value)) { + if (!isRecord(agentMeta)) { + agents[agentId] = agentMeta; + continue; + } + const homedir = agentMeta['homedir']; + agents[agentId] = { + ...agentMeta, + homedir: + typeof homedir === 'string' ? remapSessionPath(homedir, sourceDir, targetDir) : homedir, + }; + } + return agents; +} + +function remapSessionPath(value: string, sourceDir: string, targetDir: string): string { + const rel = relative(sourceDir, value); + if (rel === '') return targetDir; + if (rel.startsWith('..') || isAbsolute(rel)) return value; + return join(targetDir, rel); +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +async function statIfExists(path: string): Promise<{ readonly mtimeMs: number } | undefined> { + try { + return await stat(path); + } catch { + return undefined; + } +} + +async function isDirectory(path: string): Promise { + try { + return (await stat(path)).isDirectory(); + } catch { + return false; + } +} + +function timestampOrFallback(value: number, fallback: number): number { + return Number.isFinite(value) && value > 0 ? value : fallback; +} + +function assertSafeSessionId(id: string): void { + if (isSafeSessionId(id)) return; + throw new KimiError(ErrorCodes.SESSION_ID_INVALID, 'Session id contains unsupported path characters'); +} + +function isSafeSessionId(id: string): boolean { + return /^[A-Za-z0-9._-]+$/.test(id) && id !== '.' && id !== '..'; +} + +function compareSessionSummary(a: SessionSummary, b: SessionSummary): number { + if (a.updatedAt !== b.updatedAt) return b.updatedAt - a.updatedAt; + if (a.createdAt !== b.createdAt) return b.createdAt - a.createdAt; + if (a.id < b.id) return -1; + if (a.id > b.id) return 1; + return 0; +} diff --git a/packages/agent-core/src/session/store/workdir-key.ts b/packages/agent-core/src/session/store/workdir-key.ts new file mode 100644 index 000000000..2b0aaafbd --- /dev/null +++ b/packages/agent-core/src/session/store/workdir-key.ts @@ -0,0 +1,18 @@ +import { createHash } from 'node:crypto'; +import { basename, resolve } from 'node:path'; + +import { slugifyWorkDirName } from '#/utils/workdir-slug'; + +const WORKDIR_KEY_PREFIX = 'wd_'; +const HASH_LENGTH = 12; + +export function normalizeWorkDir(workDir: string): string { + return resolve(workDir); +} + +export function encodeWorkDirKey(workDir: string): string { + const normalized = normalizeWorkDir(workDir); + const slug = slugifyWorkDirName(basename(normalized)); + const hash = createHash('sha256').update(normalized).digest('hex').slice(0, HASH_LENGTH); + return `${WORKDIR_KEY_PREFIX}${slug}_${hash}`; +} diff --git a/packages/agent-core/src/session/subagent-host.ts b/packages/agent-core/src/session/subagent-host.ts new file mode 100644 index 000000000..20b744ec2 --- /dev/null +++ b/packages/agent-core/src/session/subagent-host.ts @@ -0,0 +1,345 @@ +import type { TokenUsage } from '@moonshot-ai/kosong'; + +import type { Agent } from '../agent'; +import type { PromptOrigin } from '../agent/context'; +import type { LoopTurnStopReason } from '../loop'; +import { + DEFAULT_AGENT_PROFILES, + prepareSystemPromptContext, + type ResolvedAgentProfile, +} from '../profile'; +import { linkAbortSignal } from '../utils/abort'; +import { collectGitContext } from './git-context'; +import type { Session } from './index'; +import SUMMARY_CONTINUATION_PROMPT from './summary-continuation.md'; + +/** + * A subagent summary shorter than this many characters triggers one + * follow-up turn that asks the subagent to expand it, so the parent + * agent receives a technically complete handoff. + */ +const SUMMARY_MIN_LENGTH = 200; +const SUMMARY_CONTINUATION_ATTEMPTS = 1; +const HOOK_TEXT_PREVIEW_LENGTH = 500; +const SUBAGENT_MAX_TOKENS_ERROR = + 'Subagent turn failed before completing its final summary: reason=max_tokens'; + +type RunSubagentOptions = { + readonly parentToolCallId: string; + readonly parentToolCallUuid?: string | undefined; + readonly prompt: string; + readonly description: string; + readonly runInBackground: boolean; + readonly origin?: PromptOrigin | undefined; + readonly signal: AbortSignal; +}; + +type SubagentCompletion = { + readonly result: string; + readonly usage?: TokenUsage; +}; + +type ActiveChild = { + readonly controller: AbortController; + readonly runInBackground: boolean; +}; + +export type SubagentHandle = { + readonly agentId: string; + readonly profileName: string; + readonly resumed: boolean; + readonly completion: Promise; +}; + +export class SessionSubagentHost { + private readonly activeChildren = new Map(); + + constructor( + private readonly session: Session, + private readonly ownerAgentId: string, + readonly backgroundTaskTimeoutMs?: number | undefined, + ) {} + + async spawn(profileName: string, options: RunSubagentOptions): Promise { + options.signal.throwIfAborted(); + + const parent = this.session.agents.get(this.ownerAgentId); + if (parent === undefined) { + throw new Error(`Parent agent "${this.ownerAgentId}" was not found`); + } + + const profile = this.resolveProfile(parent, profileName); + const { id, agent } = await this.session.createAgent( + { type: 'sub', generate: parent.rawGenerate }, + undefined, + this.ownerAgentId, + ); + const controller = new AbortController(); + const unlinkAbortSignal = linkAbortSignal(options.signal, controller); + this.activeChildren.set(id, { + controller, + runInBackground: options.runInBackground, + }); + + const completion = this.runChild( + parent, + id, + agent, + profile.name, + { + ...options, + signal: controller.signal, + }, + () => this.configureChild(parent, agent, profile), + ).finally(() => { + unlinkAbortSignal(); + this.activeChildren.delete(id); + }); + + return { + agentId: id, + profileName: profile.name, + resumed: false, + completion, + }; + } + + async resume(agentId: string, options: RunSubagentOptions): Promise { + options.signal.throwIfAborted(); + + const parent = this.session.agents.get(this.ownerAgentId); + if (parent === undefined) { + throw new Error(`Parent agent "${this.ownerAgentId}" was not found`); + } + + const child = this.session.agents.get(agentId); + if (child === undefined) { + throw new Error(`Agent instance "${agentId}" was not found`); + } + const metadata = this.session.metadata.agents[agentId]; + if (metadata?.type !== 'sub') { + throw new Error(`Agent instance "${agentId}" is not a subagent`); + } + if (metadata.parentAgentId !== this.ownerAgentId) { + throw new Error(`Agent instance "${agentId}" does not belong to this parent agent`); + } + if (this.activeChildren.has(agentId) || child.turn.hasActiveTurn) { + throw new Error( + `Agent instance "${agentId}" is already running and cannot be resumed concurrently`, + ); + } + + const profileName = child.config.profileName ?? 'subagent'; + + const controller = new AbortController(); + const unlinkAbortSignal = linkAbortSignal(options.signal, controller); + this.activeChildren.set(agentId, { + controller, + runInBackground: options.runInBackground, + }); + + const completion = this.runChild( + parent, + agentId, + child, + profileName, + { + ...options, + signal: controller.signal, + }, + // A resumed subagent is realigned to the parent agent's current model, + // so a parent setModel between the initial spawn and the resume is + // reflected — a subagent always uses the parent agent's model. + () => { + child.config.update({ modelAlias: parent.config.modelAlias }); + return Promise.resolve(); + }, + ).finally(() => { + unlinkAbortSignal(); + this.activeChildren.delete(agentId); + }); + + return { + agentId, + profileName, + resumed: true, + completion, + }; + } + + cancelAll(): void { + const foregroundChildren = Array.from(this.activeChildren).filter( + ([, child]) => !child.runInBackground, + ); + for (const [childId, child] of foregroundChildren) { + this.session.agents.get(childId)?.subagentHost?.cancelAll(); + child.controller.abort(); + } + } + + getProfileName(agentId: string): string | undefined { + const metadata = this.session.metadata.agents[agentId]; + if (metadata?.type !== 'sub' || metadata.parentAgentId !== this.ownerAgentId) { + return undefined; + } + return this.session.agents.get(agentId)?.config.profileName; + } + + private resolveProfile(parent: Agent, profileName: string): ResolvedAgentProfile { + const profile = + DEFAULT_AGENT_PROFILES[parent.config.profileName ?? 'agent']?.subagents?.[profileName] ?? + DEFAULT_AGENT_PROFILES['agent']?.subagents?.[profileName]; + if (profile === undefined) { + throw new Error(`Subagent profile "${profileName}" was not found`); + } + return profile; + } + + private async runChild( + parent: Agent, + childId: string, + child: Agent, + profileName: string, + options: RunSubagentOptions, + prepareChild: () => Promise, + ): Promise { + parent.emitEvent({ + type: 'subagent.spawned', + subagentId: childId, + subagentName: profileName, + parentToolCallId: options.parentToolCallId, + parentToolCallUuid: options.parentToolCallUuid, + parentAgentId: this.ownerAgentId, + description: options.description, + runInBackground: options.runInBackground, + }); + parent.telemetry.track('subagent_created', { + subagent_name: profileName, + run_in_background: options.runInBackground, + }); + + try { + await prepareChild(); + options.signal.throwIfAborted(); + await this.triggerSubagentStart(parent, profileName, options.prompt, options.signal); + options.signal.throwIfAborted(); + + // Explore subagents start cold; a git-context block helps them orient + // in the repository before searching. + let childPrompt = options.prompt; + if (profileName === 'explore') { + const gitContext = await collectGitContext(child.runtime.kaos, child.config.cwd); + if (gitContext) childPrompt = `${gitContext}\n\n${childPrompt}`; + } + const origin: PromptOrigin = options.origin ?? { kind: 'system_trigger', name: 'subagent' }; + child.turn.prompt([{ type: 'text', text: childPrompt }], origin); + await runChildTurnToCompletion(child, options.signal); + + // A subagent that returns an overly terse summary leaves the parent + // agent under-informed. Give it a bounded number of chances to expand + // the handoff; if it is still short after that, accept it as-is rather + // than retrying indefinitely. + let result = lastAssistantText(child); + let remainingContinuations = SUMMARY_CONTINUATION_ATTEMPTS; + while (remainingContinuations > 0 && result.length < SUMMARY_MIN_LENGTH) { + remainingContinuations -= 1; + options.signal.throwIfAborted(); + child.turn.prompt([{ type: 'text', text: SUMMARY_CONTINUATION_PROMPT }], origin); + await runChildTurnToCompletion(child, options.signal); + result = lastAssistantText(child); + } + const usage = child.usage.data().total; + parent.emitEvent({ + type: 'subagent.completed', + subagentId: childId, + parentToolCallId: options.parentToolCallId, + resultSummary: result, + usage, + }); + this.triggerSubagentStop(parent, profileName, result); + return { result, usage }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + parent.emitEvent({ + type: 'subagent.failed', + subagentId: childId, + parentToolCallId: options.parentToolCallId, + error: message, + }); + throw error; + } + } + + private async configureChild( + parent: Agent, + child: Agent, + profile: ResolvedAgentProfile, + ): Promise { + // A subagent always inherits the parent agent's model. + child.config.update({ + cwd: parent.config.cwd, + modelAlias: parent.config.modelAlias, + thinkingLevel: parent.config.thinkingLevel, + }); + + const context = await prepareSystemPromptContext(child.runtime.kaos, child.config.cwd); + child.useProfile(profile, context); + } + + private async triggerSubagentStart( + parent: Agent, + profileName: string, + prompt: string, + signal: AbortSignal, + ): Promise { + await parent.hooks?.trigger('SubagentStart', { + matcherValue: profileName, + signal, + inputData: { + agentName: profileName, + prompt: prompt.slice(0, HOOK_TEXT_PREVIEW_LENGTH), + }, + }); + } + + private triggerSubagentStop(parent: Agent, profileName: string, result: string): void { + void parent.hooks?.fireAndForgetTrigger('SubagentStop', { + matcherValue: profileName, + inputData: { + agentName: profileName, + response: result.slice(0, HOOK_TEXT_PREVIEW_LENGTH), + }, + }); + } +} + +async function runChildTurnToCompletion(child: Agent, signal: AbortSignal): Promise { + const completion = await child.turn.waitForCurrentTurn(signal); + const turnEnded = completion.event; + if (turnEnded.reason !== 'completed') { + throw new Error( + turnEnded.error === undefined + ? `Subagent turn ${turnEnded.reason}` + : `[${turnEnded.error.code}] ${turnEnded.error.message}`, + ); + } + throwIfSubagentStoppedAtMaxTokens(completion.stopReason); +} + +function throwIfSubagentStoppedAtMaxTokens(stopReason: LoopTurnStopReason | undefined): void { + if (stopReason === 'max_tokens') { + throw new Error(`${SUBAGENT_MAX_TOKENS_ERROR}.`); + } +} + +function lastAssistantText(agent: Agent): string { + for (const message of [...agent.context.history].toReversed()) { + if (message.role !== 'assistant') continue; + const text = message.content + .filter((part) => part.type === 'text') + .map((part) => part.text) + .join(''); + if (text.trim().length > 0) return text.trim(); + } + return ''; +} diff --git a/packages/agent-core/src/session/summary-continuation.md b/packages/agent-core/src/session/summary-continuation.md new file mode 100644 index 000000000..8efb589a5 --- /dev/null +++ b/packages/agent-core/src/session/summary-continuation.md @@ -0,0 +1,5 @@ +Your previous response was too brief. Please provide a more comprehensive summary that includes: + +1. Specific technical details and implementations +2. Detailed findings and analysis +3. All important information that the parent agent should know \ No newline at end of file diff --git a/packages/agent-core/src/skill/builtin/index.ts b/packages/agent-core/src/skill/builtin/index.ts new file mode 100644 index 000000000..e921ec1bf --- /dev/null +++ b/packages/agent-core/src/skill/builtin/index.ts @@ -0,0 +1,8 @@ +import type { SkillRegistry } from '../registry'; +import { MCP_CONFIG_SKILL } from './mcp-config'; + +export function registerBuiltinSkills(registry: SkillRegistry): void { + registry.registerBuiltinSkill(MCP_CONFIG_SKILL); +} + +export { MCP_CONFIG_SKILL }; diff --git a/packages/agent-core/src/skill/builtin/mcp-config.md b/packages/agent-core/src/skill/builtin/mcp-config.md new file mode 100644 index 000000000..bf20d847a --- /dev/null +++ b/packages/agent-core/src/skill/builtin/mcp-config.md @@ -0,0 +1,96 @@ +--- +name: mcp-config +description: Configure MCP servers and handle MCP OAuth login. +--- + +# Interactive MCP server configuration + +The user invoked this skill through `/mcp-config` or `/skill:mcp-config`. +Either they want to log into an MCP server that asked for OAuth, or they +want to edit the `mcp.json` that lists MCP servers. The work is small and +local — handle it on this turn yourself, no agents or planning todos. + +Pick the flow from the user's message and your tool list: + +- An `mcp____authenticate` tool is in your list, the user says + "log in" / "auth" / "sign in", they invoke `/mcp-config login + `, or they quote a `needs-auth` status → **Login**. +- Add / edit / remove / list of an `mcp.json` entry → **Config edit**. +- Bare `/mcp-config` with no `authenticate` tool in your list → + **Config edit**. If there were a pending login, the authenticate tool + would be in your list. + +## Login + +Each MCP server in `needs-auth` exposes one `mcp____authenticate` +tool. Call it for the server the user means — its own description owns +the OAuth UX (printing the URL, blocking on the callback, reconnecting on +success). Surface its output verbatim, including the authorization URL +unchanged; the URL contains state and PKCE parameters that break if +edited. + +If the user named a server that has no authenticate tool, say so in one +sentence and stop — do **not** fall into config edit. They're trying to +log in to a server that isn't currently waiting for login; quietly +rewriting `mcp.json` would be the wrong fix. If multiple authenticate +tools exist and the user didn't name one, ask which. + +## Config edit + +Config lives in two files; on key collision the project file overrides +the user-global one: + +- User-global: `~/.kimi-code/mcp.json` (or `$KIMI_CODE_HOME/mcp.json` if + set). Use for servers you want everywhere. +- Project-local: `/.kimi-code/mcp.json`. Mention once that stdio + entries spawn commands at session start, so this should only live in + trusted repos. + +Both files wrap their entries the same way: + +```json +{ "mcpServers": { "": { /* entry */ } } } +``` + +A minimal stdio entry needs `command` (+ optional `args`, `env`, `cwd`). +A minimal http entry needs `url`; add `bearerTokenEnvVar: "ENV_NAME"` for +servers that authenticate with a static bearer token from the +environment. Servers that use OAuth take no token field — the login flow +above handles them. `transport` is inferred from `command` vs `url`, so +omit it. For less common fields (`enabled`, `startupTimeoutMs`, +`toolTimeoutMs`, `enabledTools`, `disabledTools`, `headers`) the source of +truth is `McpServerStdioConfigSchema` / `McpServerHttpConfigSchema` in +`packages/agent-core/src/config/schema.ts`. + +If the user only wants to **see** what's configured, read both files, +show a merged view, and stop — no scope prompt, no write. + +For changes, the flow is: + +1. **Pick a scope.** Infer it from the user's words when you can + (project / repo / this checkout / cwd → project; global / everywhere / + all projects → user-global). When the request is genuinely scope-less, + use one `AskUserQuestion` to ask user-global vs project-local, defaulting + to user-global. Use plain text for every other question — `AskUserQuestion` + is a poor fit for free-form input. If the user dismisses the scope + question, stop; you can't safely guess where they wanted the change. +2. **Read and announce.** Read the target file (a missing or empty file + is fine; you'll create `{ "mcpServers": {} }`). If JSON parsing fails, + surface the error verbatim and stop — silently overwriting a broken + file could destroy work. Then show the user the target path, what's + currently in it, and the entry you're about to write or delete. This + is for transparency, not a confirmation gate — the Edit/Write + permission prompt is the real gate, and your message is what gives + the user context when that prompt appears. In yolo / afk modes there + is no prompt, which is those modes' explicit contract. +3. **Write and tell them how to reload MCP servers.** Preserve unrelated + entries and the `mcpServers` wrapper. MCP servers load at session + start, so tell the user to start a new session (for example `/new`) or + restart `kimi-code` for the change to take effect. + +## Secrets + +Don't store secrets (tokens, keys, passwords) as literals in +`mcp.json` — it's a plain config file on disk. http servers should use +`bearerTokenEnvVar` to reference an env var instead; if a stdio entry +must inline one in `env`, warn the user before writing. diff --git a/packages/agent-core/src/skill/builtin/mcp-config.ts b/packages/agent-core/src/skill/builtin/mcp-config.ts new file mode 100644 index 000000000..36e60c1c7 --- /dev/null +++ b/packages/agent-core/src/skill/builtin/mcp-config.ts @@ -0,0 +1,23 @@ +import { parseSkillText } from '../parser'; +import type { SkillDefinition } from '../types'; +import MCP_CONFIG_BODY from './mcp-config.md'; + +const PSEUDO_PATH = 'builtin://mcp-config'; + +const parsed = parseSkillText({ + skillMdPath: '/builtin/skills/mcp-config.md', + skillDirName: 'mcp-config', + source: 'builtin', + text: MCP_CONFIG_BODY, +}); + +export const MCP_CONFIG_SKILL: SkillDefinition = { + ...parsed, + path: PSEUDO_PATH, + dir: PSEUDO_PATH, + metadata: { + ...parsed.metadata, + type: parsed.metadata.type ?? 'inline', + disableModelInvocation: true, + }, +}; diff --git a/packages/agent-core/src/skill/index.ts b/packages/agent-core/src/skill/index.ts new file mode 100644 index 000000000..924027bfa --- /dev/null +++ b/packages/agent-core/src/skill/index.ts @@ -0,0 +1,5 @@ +export * from './builtin'; +export * from './parser'; +export * from './registry'; +export * from './scanner'; +export * from './types'; diff --git a/packages/agent-core/src/skill/parser.ts b/packages/agent-core/src/skill/parser.ts new file mode 100644 index 000000000..f308112b2 --- /dev/null +++ b/packages/agent-core/src/skill/parser.ts @@ -0,0 +1,295 @@ +import { readFile } from 'node:fs/promises'; +import path from 'node:path'; + +import { load as loadYaml } from 'js-yaml'; +import regexpEscape from 'regexp.escape'; + +import type { SkillDefinition, SkillMetadata, SkillSource } from './types'; +import { isSupportedSkillType } from './types'; + +export class FrontmatterError extends Error { + constructor(message: string, cause?: unknown) { + super(message); + this.name = 'FrontmatterError'; + if (cause !== undefined) { + Object.defineProperty(this, 'cause', { value: cause, configurable: true }); + } + } +} + +export class SkillParseError extends Error { + readonly reason?: unknown; + + constructor(message: string, cause?: unknown) { + super(message); + this.name = 'SkillParseError'; + if (cause !== undefined) this.reason = cause; + } +} + +export class UnsupportedSkillTypeError extends Error { + readonly skillType: string; + + constructor(skillType: string) { + super( + `Skill type "${skillType}" is not supported; only "prompt", "inline", and "flow" are supported.`, + ); + this.name = 'UnsupportedSkillTypeError'; + this.skillType = skillType; + } +} + +export interface ParseSkillOptions { + readonly skillMdPath: string; + readonly skillDirName: string; + readonly source: SkillSource; +} + +export interface ParseSkillTextOptions extends ParseSkillOptions { + readonly text: string; +} + +export interface SkillExpandContext { + readonly skillDir: string; + readonly sessionId?: string; + readonly argumentNames?: readonly string[]; +} + +export interface ParsedFrontmatter { + readonly data: unknown; + readonly body: string; +} + +const FENCE = '---'; +const METADATA_ALIASES: Readonly> = { + 'when-to-use': 'whenToUse', + when_to_use: 'whenToUse', + 'disable-model-invocation': 'disableModelInvocation', + disable_model_invocation: 'disableModelInvocation', +}; + +export async function parseSkillFromFile(options: ParseSkillOptions): Promise { + let text: string; + try { + text = await readFile(options.skillMdPath, 'utf8'); + } catch (error) { + throw new SkillParseError(`Failed to read ${options.skillMdPath}`, error); + } + return parseSkillText({ ...options, text }); +} + +export function parseFrontmatter(text: string): ParsedFrontmatter { + const lines = text.split(/\r?\n/); + if (lines[0]?.trim() !== FENCE) { + return { data: null, body: text }; + } + + const close = lines.findIndex((line, index) => index > 0 && line.trim() === FENCE); + if (close === -1) { + throw new FrontmatterError('Missing closing frontmatter fence'); + } + + const yamlText = lines.slice(1, close).join('\n').trim(); + const body = lines.slice(close + 1).join('\n'); + if (yamlText === '') { + return { data: {}, body }; + } + + try { + return { data: loadYaml(yamlText) ?? {}, body }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + throw new FrontmatterError(message, error); + } +} + +export function parseSkillText(options: ParseSkillTextOptions): SkillDefinition { + const isDirectorySkill = path.basename(options.skillMdPath) === 'SKILL.md'; + if (isDirectorySkill && options.text.split(/\r?\n/, 1)[0]?.trim() !== FENCE) { + throw new SkillParseError(`Missing frontmatter in ${options.skillMdPath}`); + } + + let parsed; + try { + parsed = parseFrontmatter(options.text); + } catch (error) { + if (error instanceof FrontmatterError) { + throw new SkillParseError( + `Invalid frontmatter in ${options.skillMdPath}: ${error.message}`, + error, + ); + } + throw error; + } + + const frontmatter = parsed.data ?? {}; + if (!isRecord(frontmatter)) { + throw new SkillParseError( + `Frontmatter in ${options.skillMdPath} must be a mapping at the top level`, + ); + } + + const metadata = normalizeMetadata(frontmatter); + if (!isSupportedSkillType(metadata.type)) { + throw new UnsupportedSkillTypeError(metadata.type ?? String(frontmatter['type'])); + } + + const name = nonEmptyString(metadata.name); + const description = nonEmptyString(metadata.description); + if (isDirectorySkill && (name === undefined || description === undefined)) { + const field = name === undefined ? '"name"' : '"description"'; + throw new SkillParseError( + `Missing required frontmatter field ${field} in ${options.skillMdPath}`, + ); + } + + const skillPath = path.resolve(options.skillMdPath); + const content = parsed.body.trim(); + return { + name: name ?? options.skillDirName, + description: description ?? descriptionFromBody(content), + path: skillPath, + dir: path.dirname(skillPath), + content, + metadata, + source: options.source, + mermaid: parseMermaidFlowchart(content), + d2: parseD2Flowchart(content), + }; +} + +export function parseMermaidFlowchart(markdown: string): string | undefined { + return /```mermaid\r?\n([\s\S]*?)\r?\n```/.exec(markdown)?.[1]; +} + +export function parseD2Flowchart(markdown: string): string | undefined { + return /```d2\r?\n([\s\S]*?)\r?\n```/.exec(markdown)?.[1]; +} + +/** + * Expand argument placeholders in a skill body. + * + * Placeholder syntax ($ARGUMENTS, $0, $name, etc.) is modelled after common + * shell/CLI conventions rather than any specific product. + */ +export function expandSkillParameters( + body: string, + rawArgs: string, + context: SkillExpandContext, +): string { + const tokens = tokenizeArgs(rawArgs); + let content = body; + + for (let index = 0; index < (context.argumentNames?.length ?? 0); index++) { + const name = context.argumentNames?.[index]; + if (name === undefined) continue; + const escaped = regexpEscape(name); + content = content.replaceAll( + new RegExp(`\\$${escaped}(?![\\[\\w])`, 'g'), + tokens[index] ?? '', + ); + } + + content = content + .replaceAll(/\$ARGUMENTS\[(\d+)\]/g, (_match, indexText: string) => { + const index = Number.parseInt(indexText, 10); + return tokens[index] ?? ''; + }) + .replaceAll(/\$(\d+)(?!\w)/g, (_match, indexText: string) => { + const index = Number.parseInt(indexText, 10); + return tokens[index] ?? ''; + }) + .replaceAll('$ARGUMENTS', rawArgs); + + const hasArgumentPlaceholder = content !== body; + content = content + .replaceAll('${KIMI_SKILL_DIR}', context.skillDir) + .replaceAll('${KIMI_SESSION_ID}', context.sessionId ?? ''); + + if (!hasArgumentPlaceholder && rawArgs.length > 0) { + return `${content}\n\nARGUMENTS: ${rawArgs}`; + } + return content; +} + +export function skillArgumentNames(metadata: SkillMetadata): readonly string[] { + const value = metadata.arguments; + const isValidName = (name: string): boolean => + name.trim() !== '' && !/^\d+$/.test(name); + if (typeof value === 'string') return value.split(/\s+/).filter(isValidName); + if (!Array.isArray(value)) return []; + return value.filter((item): item is string => typeof item === 'string' && isValidName(item)); +} + +function normalizeMetadata(raw: Record): SkillMetadata { + const out: Record = {}; + for (const [rawKey, value] of Object.entries(raw)) { + const key = METADATA_ALIASES[rawKey] ?? rawKey; + out[key] = value; + } + + const type = nonEmptyString(out['type']); + if (type !== undefined) out['type'] = type; + + const name = nonEmptyString(out['name']); + if (name !== undefined) out['name'] = name; + + const description = nonEmptyString(out['description']); + if (description !== undefined) out['description'] = description; + + return out as SkillMetadata; +} + +function descriptionFromBody(body: string): string { + const firstLine = body + .split(/\r?\n/) + .map((line) => line.trim()) + .find((line) => line.length > 0); + if (firstLine === undefined) return 'No description provided.'; + return firstLine.length > 240 ? `${firstLine.slice(0, 239)}…` : firstLine; +} + +function tokenizeArgs(raw: string): string[] { + const out: string[] = []; + let current = ''; + let quote: '"' | "'" | undefined; + let hasContent = false; + + for (const char of raw) { + if (quote !== undefined) { + if (char === quote) { + quote = undefined; + } else { + current += char; + hasContent = true; + } + continue; + } + if (char === '"' || char === "'") { + quote = char; + hasContent = true; + continue; + } + if (/\s/.test(char)) { + if (hasContent) { + out.push(current); + current = ''; + hasContent = false; + } + continue; + } + current += char; + hasContent = true; + } + + if (hasContent) out.push(current); + return out; +} + +function nonEmptyString(value: unknown): string | undefined { + return typeof value === 'string' && value.trim() !== '' ? value.trim() : undefined; +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} diff --git a/packages/agent-core/src/skill/registry.ts b/packages/agent-core/src/skill/registry.ts new file mode 100644 index 000000000..551b5bfc7 --- /dev/null +++ b/packages/agent-core/src/skill/registry.ts @@ -0,0 +1,150 @@ +import { expandSkillParameters, skillArgumentNames } from './parser'; +import { discoverSkills, type DiscoverSkillsOptions } from './scanner'; +import type { SkillDefinition, SkillRoot, SkillSource, SkippedSkill } from './types'; +import { isInlineSkillType, normalizeSkillName } from './types'; + +const LISTING_DESC_MAX = 250; + +export class SkillNotFoundError extends Error { + readonly skillName: string; + + constructor(skillName: string) { + super(`Skill "${skillName}" is not registered`); + this.name = 'SkillNotFoundError'; + this.skillName = skillName; + } +} + +export interface SkillRegistryOptions { + readonly discover?: typeof discoverSkills; + readonly onWarning?: (message: string, cause?: unknown) => void; + readonly sessionId?: string; +} + +export class SkillRegistry { + private readonly byName = new Map(); + private readonly roots: string[] = []; + private readonly skipped: SkippedSkill[] = []; + private readonly discoverImpl: typeof discoverSkills; + private readonly onWarning: (message: string, cause?: unknown) => void; + readonly sessionId?: string; + + constructor(options: SkillRegistryOptions = {}) { + this.discoverImpl = options.discover ?? discoverSkills; + this.onWarning = options.onWarning ?? (() => {}); + this.sessionId = options.sessionId; + } + + async loadRoots(roots: readonly SkillRoot[]): Promise { + for (const root of roots) { + if (!this.roots.includes(root.path)) this.roots.push(root.path); + } + + const skills = await this.discoverImpl({ + roots, + onWarning: this.onWarning, + onSkippedByPolicy: (skill) => this.skipped.push(skill), + } satisfies DiscoverSkillsOptions); + + for (const skill of skills) { + this.byName.set(normalizeSkillName(skill.name), skill); + } + } + + registerBuiltinSkill(skill: SkillDefinition): void { + this.register(skill.source === 'builtin' ? skill : { ...skill, source: 'builtin' }); + } + + register(skill: SkillDefinition, options: { readonly replace?: boolean } = {}): void { + const key = normalizeSkillName(skill.name); + if (options.replace === true || !this.byName.has(key)) { + this.byName.set(key, skill); + } + } + + getSkill(name: string): SkillDefinition | undefined { + return this.byName.get(normalizeSkillName(name)); + } + + renderSkillPrompt(skill: SkillDefinition, rawArgs: string): string { + const argumentNames = skillArgumentNames(skill.metadata); + return expandSkillParameters(skill.content, rawArgs, { + skillDir: skill.dir, + sessionId: this.sessionId, + argumentNames, + }); + } + + listSkills(): readonly SkillDefinition[] { + return [...this.byName.values()].toSorted((a, b) => a.name.localeCompare(b.name)); + } + + listInvocableSkills(): readonly SkillDefinition[] { + return this.listSkills().filter( + (skill) => + skill.metadata.disableModelInvocation !== true && isInlineSkillType(skill.metadata.type), + ); + } + + getSkillRoots(): readonly string[] { + return [...this.roots]; + } + + getSkippedByPolicy(): readonly SkippedSkill[] { + return [...this.skipped]; + } + + getKimiSkillsDescription(): string { + const rendered = renderGroupedSkills(this.listSkills(), formatFullSkill); + return rendered.length === 0 ? 'No skills' : rendered; + } + + getModelSkillListing(): string { + const lines = ['DISREGARD any earlier skill listings. Current available skills:']; + const listing = renderGroupedSkills(this.listInvocableSkills(), formatModelSkill); + if (listing.length > 0) { + lines.push(listing); + } + return lines.length === 1 ? '' : lines.join('\n'); + } +} + +const SOURCE_GROUPS: ReadonlyArray<{ readonly source: SkillSource; readonly label: string }> = [ + { source: 'project', label: 'Project' }, + { source: 'user', label: 'User' }, + { source: 'extra', label: 'Extra' }, + { source: 'builtin', label: 'Built-in' }, +]; + +function renderGroupedSkills( + skills: readonly SkillDefinition[], + format: (skill: SkillDefinition) => readonly string[], +): string { + const lines: string[] = []; + for (const group of SOURCE_GROUPS) { + const groupSkills = skills.filter((skill) => skill.source === group.source); + if (groupSkills.length === 0) continue; + lines.push(`### ${group.label}`); + for (const skill of groupSkills) { + lines.push(...format(skill)); + } + } + return lines.join('\n'); +} + +function formatFullSkill(skill: SkillDefinition): readonly string[] { + return [`- ${skill.name}`, ` - Path: ${skill.path}`, ` - Description: ${skill.description}`]; +} + +function formatModelSkill(skill: SkillDefinition): readonly string[] { + const lines = [`- ${skill.name}: ${truncate(skill.description, LISTING_DESC_MAX)}`]; + if (typeof skill.metadata.whenToUse === 'string' && skill.metadata.whenToUse.length > 0) { + lines.push(` When to use: ${skill.metadata.whenToUse}`); + } + lines.push(` Path: ${skill.path}`); + return lines; +} + +function truncate(value: string, max: number): string { + return value.length > max ? value.slice(0, max) : value; +} diff --git a/packages/agent-core/src/skill/scanner.ts b/packages/agent-core/src/skill/scanner.ts new file mode 100644 index 000000000..ecbe11b6e --- /dev/null +++ b/packages/agent-core/src/skill/scanner.ts @@ -0,0 +1,366 @@ +import { promises as fs } from 'node:fs'; +import path from 'node:path'; + +import { SkillParseError, UnsupportedSkillTypeError, parseSkillFromFile } from './parser'; +import type { SkillDefinition, SkillRoot, SkillSource, SkippedSkill } from './types'; +import { normalizeSkillName } from './types'; + +const USER_BRAND_DIRS = ['.kimi-code/skills'] as const; +const USER_GENERIC_DIRS = ['.agents/skills'] as const; +const PROJECT_BRAND_DIRS = ['.kimi-code/skills'] as const; +const PROJECT_GENERIC_DIRS = ['.agents/skills'] as const; + +// Bounds recursion so a directory symlink cycle inside a skill root cannot +// loop forever. Real skill trees are 1-3 levels deep. +const MAX_SKILL_SCAN_DEPTH = 8; + +export interface SkillPathContext { + readonly userHomeDir: string; + readonly workDir: string; +} + +export interface ResolveSkillRootsOptions { + readonly paths: SkillPathContext; + readonly builtinDir?: string; + readonly explicitDirs?: readonly string[]; + readonly extraDirs?: readonly string[]; + readonly mergeAllAvailableSkills?: boolean; + readonly realpath?: (p: string) => Promise; + readonly isDir?: (p: string) => Promise; +} + +export interface DiscoverSkillsOptions { + readonly roots: readonly SkillRoot[]; + readonly onWarning?: (message: string, cause?: unknown) => void; + readonly onSkippedByPolicy?: (skill: SkippedSkill) => void; + readonly readdir?: (p: string) => Promise; + readonly isFile?: (p: string) => Promise; + readonly isDir?: (p: string) => Promise; + readonly parse?: (input: { + readonly skillMdPath: string; + readonly skillDirName: string; + readonly source: SkillSource; + }) => Promise; +} + +export interface WorkspaceWithAdditionalDirs { + readonly workspaceDir: string; + readonly additionalDirs: readonly string[]; +} + +export async function resolveSkillRoots( + options: ResolveSkillRootsOptions, +): Promise { + const isDir = options.isDir ?? defaultIsDir; + const realpath = options.realpath ?? ((p: string) => fs.realpath(p)); + const roots: SkillRoot[] = []; + const mergeAllAvailableSkills = options.mergeAllAvailableSkills ?? true; + const { userHomeDir, workDir } = options.paths; + const projectRoot = await findProjectRoot(workDir); + + if (options.explicitDirs !== undefined && options.explicitDirs.length > 0) { + await pushConfiguredDirs( + roots, + options.explicitDirs, + projectRoot, + userHomeDir, + 'user', + isDir, + realpath, + ); + } else { + await pushBrandGroup( + roots, + PROJECT_BRAND_DIRS, + projectRoot, + 'project', + mergeAllAvailableSkills, + isDir, + realpath, + ); + await pushFirstExisting(roots, PROJECT_GENERIC_DIRS, projectRoot, 'project', isDir, realpath); + await pushBrandGroup( + roots, + USER_BRAND_DIRS, + userHomeDir, + 'user', + mergeAllAvailableSkills, + isDir, + realpath, + ); + await pushFirstExisting(roots, USER_GENERIC_DIRS, userHomeDir, 'user', isDir, realpath); + } + + if (options.extraDirs !== undefined) { + await pushConfiguredDirs( + roots, + options.extraDirs, + projectRoot, + userHomeDir, + 'extra', + isDir, + realpath, + ); + } + + if (options.builtinDir !== undefined) { + await pushExistingRoot(roots, options.builtinDir, 'builtin', isDir, realpath); + } + + return roots; +} + +export async function discoverSkills( + options: DiscoverSkillsOptions, +): Promise { + const readdir = options.readdir ?? ((p: string) => fs.readdir(p)); + const isFile = options.isFile ?? defaultIsFile; + const isDir = options.isDir ?? defaultIsDir; + const parse = options.parse ?? parseSkillFromFile; + const warn = options.onWarning ?? (() => {}); + const skip = options.onSkippedByPolicy ?? (() => {}); + const byName = new Map(); + + async function walkSkillDir( + dirPath: string, + source: SkillSource, + isTopLevel: boolean, + depth: number, + ): Promise { + if (depth > MAX_SKILL_SCAN_DEPTH) return; + + let entries: readonly string[]; + try { + // Sorted so first-wins collision resolution across sibling directories + // is deterministic rather than dependent on filesystem readdir order. + entries = [...(await readdir(dirPath))].toSorted(); + } catch (error) { + warn(`Failed to read skill directory ${dirPath}`, error); + return; + } + + const directorySkills = new Set(); + const subdirs: string[] = []; + for (const entry of entries) { + // A directory holding SKILL.md is a skill bundle: register it and do not + // descend, so its bundled references/scripts are never scanned. + if (await isFile(path.join(dirPath, entry, 'SKILL.md'))) { + directorySkills.add(entry); + continue; + } + if (entry === 'node_modules' || entry.startsWith('.')) continue; + if (await isDir(path.join(dirPath, entry))) subdirs.push(entry); + } + + for (const entry of directorySkills) { + await parseAndRegister({ + parse, + byName, + skillMdPath: path.join(dirPath, entry, 'SKILL.md'), + skillDirName: entry, + source, + warn, + skip, + }); + } + + // Flat .md skills count only at a root's top level; deeper .md files are + // skill payload (e.g. references/foo.md), not skills. + if (isTopLevel) { + for (const entry of entries) { + if (!entry.endsWith('.md')) continue; + if (entry === 'SKILL.md') continue; + const skillName = entry.slice(0, -'.md'.length); + if (directorySkills.has(skillName)) { + warn( + `Ignoring flat skill ${path.join(dirPath, entry)} because ${path.join(dirPath, skillName, 'SKILL.md')} exists with the same name`, + ); + continue; + } + const skillMdPath = path.join(dirPath, entry); + if (!(await isFile(skillMdPath))) continue; + await parseAndRegister({ + parse, + byName, + skillMdPath, + skillDirName: skillName, + source, + warn, + skip, + }); + } + } + + for (const entry of subdirs) { + await walkSkillDir(path.join(dirPath, entry), source, false, depth + 1); + } + } + + for (const root of options.roots) { + await walkSkillDir(root.path, root.source, true, 0); + } + + return sortSkills([...byName.values()]); +} + +export function extendWorkspaceWithSkillRoots( + workspace: T, + skillRoots: readonly string[], +): T { + const additionalDirs = [...workspace.additionalDirs]; + for (const root of skillRoots) { + if (isWithin(root, workspace.workspaceDir)) continue; + if (additionalDirs.some((dir) => root === dir || isWithin(root, dir))) continue; + additionalDirs.push(root); + } + if (additionalDirs.length === workspace.additionalDirs.length) return workspace; + return { ...workspace, additionalDirs }; +} + +function sortSkills(skills: readonly SkillDefinition[]): readonly SkillDefinition[] { + return [...skills].toSorted((a, b) => a.name.localeCompare(b.name)); +} + +async function pushFirstExisting( + out: SkillRoot[], + dirs: readonly string[], + base: string, + source: SkillSource, + isDir: (p: string) => Promise, + realpath: (p: string) => Promise, +): Promise { + for (const dir of dirs) { + if (await pushExistingRoot(out, path.join(base, dir), source, isDir, realpath)) return; + } +} + +async function pushBrandGroup( + out: SkillRoot[], + dirs: readonly string[], + base: string, + source: SkillSource, + mergeAllAvailableSkills: boolean, + isDir: (p: string) => Promise, + realpath: (p: string) => Promise, +): Promise { + if (!mergeAllAvailableSkills) { + await pushFirstExisting(out, dirs, base, source, isDir, realpath); + return; + } + for (const dir of dirs) { + await pushExistingRoot(out, path.join(base, dir), source, isDir, realpath); + } +} + +async function pushConfiguredDirs( + out: SkillRoot[], + dirs: readonly string[], + projectRoot: string, + userHomeDir: string, + source: SkillSource, + isDir: (p: string) => Promise, + realpath: (p: string) => Promise, +): Promise { + for (const dir of dirs) { + await pushExistingRoot( + out, + resolveConfiguredDir(dir, projectRoot, userHomeDir), + source, + isDir, + realpath, + ); + } +} + +async function pushExistingRoot( + out: SkillRoot[], + dir: string, + source: SkillSource, + isDir: (p: string) => Promise, + realpath: (p: string) => Promise, +): Promise { + if (!(await isDir(dir))) return false; + const resolved = await realpath(dir); + if (!out.some((root) => root.path === resolved)) out.push({ path: resolved, source }); + return true; +} + +async function parseAndRegister(input: { + readonly parse: NonNullable; + readonly byName: Map; + readonly skillMdPath: string; + readonly skillDirName: string; + readonly source: SkillSource; + readonly warn: (message: string, cause?: unknown) => void; + readonly skip: (skill: SkippedSkill) => void; +}): Promise { + try { + const skill = await input.parse({ + skillMdPath: input.skillMdPath, + skillDirName: input.skillDirName, + source: input.source, + }); + const key = normalizeSkillName(skill.name); + if (!input.byName.has(key)) input.byName.set(key, skill); + } catch (error) { + if (error instanceof UnsupportedSkillTypeError) { + input.skip({ + path: input.skillMdPath, + type: error.skillType, + reason: `unsupported skill type "${error.skillType}"`, + }); + } else if (error instanceof SkillParseError) { + input.warn(`Skipping invalid skill at ${input.skillMdPath}: ${error.message}`, error); + } else { + input.warn(`Skipping skill at ${input.skillMdPath} due to unexpected error`, error); + } + } +} + +async function defaultIsDir(p: string): Promise { + try { + return (await fs.stat(p)).isDirectory(); + } catch { + return false; + } +} + +async function defaultIsFile(p: string): Promise { + try { + return (await fs.stat(p)).isFile(); + } catch { + return false; + } +} + +async function findProjectRoot(workDir: string): Promise { + const start = path.resolve(workDir); + let current = start; + while (true) { + if (await exists(path.join(current, '.git'))) return current; + const parent = path.dirname(current); + if (parent === current) return start; + current = parent; + } +} + +async function exists(p: string): Promise { + try { + await fs.stat(p); + return true; + } catch { + return false; + } +} + +function resolveConfiguredDir(dir: string, projectRoot: string, userHomeDir: string): string { + if (dir === '~') return userHomeDir; + if (dir.startsWith('~/')) return path.join(userHomeDir, dir.slice(2)); + if (path.isAbsolute(dir)) return dir; + return path.resolve(projectRoot, dir); +} + +function isWithin(child: string, parent: string): boolean { + const relative = path.relative(parent, child); + return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative)); +} diff --git a/packages/agent-core/src/skill/types.ts b/packages/agent-core/src/skill/types.ts new file mode 100644 index 000000000..8ea89b1dd --- /dev/null +++ b/packages/agent-core/src/skill/types.ts @@ -0,0 +1,77 @@ +export type SkillSource = 'project' | 'user' | 'extra' | 'builtin'; + +export interface SkillMetadata { + readonly name?: string | undefined; + readonly description?: string | undefined; + readonly type?: string | undefined; + readonly whenToUse?: string | undefined; + readonly disableModelInvocation?: boolean | undefined; + readonly safe?: boolean | undefined; + readonly arguments?: readonly unknown[] | string | undefined; + readonly [key: string]: unknown; +} + +export interface SkillDefinition { + readonly name: string; + readonly description: string; + readonly path: string; + readonly dir: string; + readonly content: string; + readonly metadata: SkillMetadata; + readonly source: SkillSource; + readonly mermaid?: string | undefined; + readonly d2?: string; +} + +export interface SkillSummary { + readonly name: string; + readonly description: string; + readonly path: string; + readonly source: SkillSource; + readonly type?: string | undefined; + readonly disableModelInvocation?: boolean | undefined; +} + +export interface SkillRoot { + readonly path: string; + readonly source: SkillSource; +} + +export interface SkippedSkill { + readonly path: string; + readonly type: string; + readonly reason: string; +} + +export interface SkillCatalog { + getSkill(name: string): SkillDefinition | undefined; + listSkills(): readonly SkillDefinition[]; + listInvocableSkills(): readonly SkillDefinition[]; +} + +export function normalizeSkillName(name: string): string { + return name.toLowerCase(); +} + +export function isInlineSkillType(type: string | undefined): boolean { + return type === undefined || type === 'prompt' || type === 'inline'; +} + +export function isUserActivatableSkillType(type: string | undefined): boolean { + return isInlineSkillType(type) || type === 'flow'; +} + +export function isSupportedSkillType(type: string | undefined): boolean { + return isUserActivatableSkillType(type); +} + +export function summarizeSkill(skill: SkillDefinition): SkillSummary { + return { + name: skill.name, + description: skill.description, + path: skill.path, + source: skill.source, + type: skill.metadata.type, + disableModelInvocation: skill.metadata.disableModelInvocation, + }; +} diff --git a/packages/agent-core/src/telemetry.ts b/packages/agent-core/src/telemetry.ts new file mode 100644 index 000000000..e1c9c3ad6 --- /dev/null +++ b/packages/agent-core/src/telemetry.ts @@ -0,0 +1,26 @@ +export type TelemetryPropertyValue = boolean | number | string | null; + +export type TelemetryProperties = Readonly>; + +export interface TelemetryContextPatch { + readonly sessionId?: string | null; +} + +export interface TelemetryClient { + track(event: string, properties?: TelemetryProperties): void; + withContext?(patch: TelemetryContextPatch): TelemetryClient; + setContext?(patch: TelemetryContextPatch): void; +} + +export const noopTelemetryClient: TelemetryClient = { + track: () => {}, + withContext: () => noopTelemetryClient, + setContext: () => {}, +}; + +export function withTelemetryContext( + telemetry: TelemetryClient, + patch: TelemetryContextPatch, +): TelemetryClient { + return telemetry.withContext?.(patch) ?? telemetry; +} diff --git a/packages/agent-core/src/tools/args-validator.ts b/packages/agent-core/src/tools/args-validator.ts new file mode 100644 index 000000000..ce967ccb0 --- /dev/null +++ b/packages/agent-core/src/tools/args-validator.ts @@ -0,0 +1,93 @@ +import Ajv, { type ErrorObject, type ValidateFunction } from 'ajv'; +import Ajv2019 from 'ajv/dist/2019'; +import Ajv2020 from 'ajv/dist/2020'; +import addFormats from 'ajv-formats'; + +const DRAFT_07_AJV = new Ajv({ strict: false, allErrors: true }); +addFormats(DRAFT_07_AJV); + +const DRAFT_2019_AJV = new Ajv2019({ strict: false, allErrors: true }); +addFormats(DRAFT_2019_AJV); + +const DRAFT_2020_AJV = new Ajv2020({ strict: false, allErrors: true }); +addFormats(DRAFT_2020_AJV); + +const DRAFT_2019_KEYWORDS = new Set([ + 'dependentRequired', + 'dependentSchemas', + 'maxContains', + 'minContains', + 'unevaluatedItems', + 'unevaluatedProperties', + '$recursiveAnchor', + '$recursiveRef', +]); + +const DRAFT_2020_KEYWORDS = new Set(['prefixItems', '$dynamicAnchor', '$dynamicRef']); + +// Mixing JSON Schema dialects in a single Ajv instance is unsafe because +// keyword semantics differ, e.g. draft-07 tuple `items` vs 2020-12 `prefixItems`. +function ajvFor(schema: Record): Ajv | Ajv2019 | Ajv2020 { + const $schema = schema['$schema']; + if (typeof $schema === 'string') { + if ($schema.includes('2020-12')) return DRAFT_2020_AJV; + if ($schema.includes('2019-09')) return DRAFT_2019_AJV; + return DRAFT_07_AJV; + } + if (containsSchemaKeyword(schema, DRAFT_2020_KEYWORDS)) return DRAFT_2020_AJV; + if (containsSchemaKeyword(schema, DRAFT_2019_KEYWORDS)) return DRAFT_2019_AJV; + return DRAFT_07_AJV; +} + +function containsSchemaKeyword(value: unknown, keywords: ReadonlySet): boolean { + if (Array.isArray(value)) { + return value.some((item) => containsSchemaKeyword(item, keywords)); + } + if (typeof value !== 'object' || value === null) return false; + for (const [key, child] of Object.entries(value)) { + if (keywords.has(key)) return true; + if (containsSchemaKeyword(child, keywords)) return true; + } + return false; +} + +export type JsonType = null | number | string | boolean | JsonArray | JsonObject; + +/** @internal */ +export interface JsonArray extends Array {} + +/** @internal */ +export interface JsonObject extends Record {} + +export type ToolArgsValidator = ValidateFunction; + +function formatValidationError(error: ErrorObject): string { + if (error.keyword === 'required' && 'missingProperty' in error.params) { + return `must have required property '${String(error.params['missingProperty'])}'`; + } + + if (error.keyword === 'additionalProperties' && 'additionalProperty' in error.params) { + return `must NOT have additional property '${String(error.params['additionalProperty'])}'`; + } + + const path = error.instancePath ? `${error.instancePath} ` : ''; + return `${path}${error.message ?? 'is invalid'}`; +} + +export function compileToolArgsValidator(schema: Record): ToolArgsValidator { + return ajvFor(schema).compile(schema) as ToolArgsValidator; +} + +export function validateToolArgs(validator: ToolArgsValidator, args: JsonType): string | null { + const valid = validator(args); + if (valid) { + return null; + } + + const errors = validator.errors ?? []; + if (errors.length === 0) { + return 'Tool parameter validation failed'; + } + + return errors.map((error) => formatValidationError(error)).join('; '); +} diff --git a/packages/agent-core/src/tools/background/index.ts b/packages/agent-core/src/tools/background/index.ts new file mode 100644 index 000000000..1f924b359 --- /dev/null +++ b/packages/agent-core/src/tools/background/index.ts @@ -0,0 +1,19 @@ +/** + * Background task management tools barrel. + */ + +export { BackgroundProcessManager, generateTaskId } from './manager'; +export type { + BackgroundTaskInfo, + BackgroundTaskKind, + BackgroundTaskOutputSnapshot, + BackgroundTaskStatus, + ReconcileResult, +} from './manager'; +export { VALID_TASK_ID } from './persist'; +export { TaskListTool, TaskListInputSchema } from './task-list'; +export type { TaskListInput } from './task-list'; +export { TaskOutputTool, TaskOutputInputSchema } from './task-output'; +export type { TaskOutputInput } from './task-output'; +export { TaskStopTool, TaskStopInputSchema } from './task-stop'; +export type { TaskStopInput } from './task-stop'; diff --git a/packages/agent-core/src/tools/background/manager.ts b/packages/agent-core/src/tools/background/manager.ts new file mode 100644 index 000000000..97f77fc9e --- /dev/null +++ b/packages/agent-core/src/tools/background/manager.ts @@ -0,0 +1,1205 @@ +/** + * BackgroundProcessManager — manages background shell processes. + * + * Tracks background bash tasks spawned by `BashTool` when + * `run_in_background=true`. + * + * Each task gets a unique ID, captures stdout+stderr to a ring buffer, + * and supports status query / output retrieval / stop operations. + * + * Accepts `KaosProcess` (not `ChildProcess`) so there is no unsafe cast + * at the BashTool call site. Lifecycle detection uses `wait()` instead + * of EventEmitter `on('exit')`. + */ + +import { randomBytes } from 'node:crypto'; + +import type { KaosProcess } from '@moonshot-ai/kaos'; + +import { isAbortError } from '../../loop/errors'; +import { + appendTaskOutput, + listTasks, + readTaskOutput, + readTaskOutputBytes, + removeTask, + taskOutputExists, + taskOutputExistsSync, + taskOutputFile, + taskOutputSizeBytes, + writeTask, + type PersistedTask, +} from './persist'; + +// ── Types ──────────────────────────────────────────────────────────── + +/** + * `'lost'` is a reconcile-only terminal state. Tasks loaded from disk + * that were marked `running` at startup but have no live KaosProcess + * (the previous CLI process died) are reclassified as lost. + * + * `'awaiting_approval'` is a non-terminal state entered when a background + * agent task is paused waiting for tool-call approval from the root + * agent. The BPM state machine is the single source of truth for "is + * this task actively running vs. gated on approval" — UI reads from BPM + * instead of reverse-querying the ApprovalRuntime. The loop boundary is + * preserved because `awaiting_approval` in BPM does not leak permission + * vocabulary into the loop. + */ +export type BackgroundTaskStatus = + | 'running' + | 'awaiting_approval' + | 'completed' + | 'failed' + | 'killed' + | 'lost'; + +/** Terminal states tasks never leave once reached. */ +const TERMINAL_STATUSES: ReadonlySet = new Set([ + 'completed', + 'failed', + 'killed', + 'lost', +]); + +export function isBackgroundTaskTerminal(status: BackgroundTaskStatus): boolean { + return TERMINAL_STATUSES.has(status); +} + +/** Task kinds with distinct id prefixes. */ +export type BackgroundTaskKind = 'bash' | 'agent'; + +/** Lifecycle phases observed by `onLifecycle` subscribers. */ +export type BackgroundLifecycleEvent = 'started' | 'updated' | 'terminated'; + +export interface BackgroundTaskInfo { + readonly taskId: string; + readonly command: string; + readonly description: string; + readonly status: BackgroundTaskStatus; + readonly pid: number; + readonly exitCode: number | null; + readonly startedAt: number; + readonly endedAt: number | null; + /** Populated only while `status === 'awaiting_approval'`. */ + readonly approvalReason?: string | undefined; + /** True when an agent task was aborted by its deadline. */ + readonly timedOut?: boolean | undefined; + /** Reason recorded when a task is explicitly stopped. */ + readonly stopReason?: string | undefined; + /** + * Deadline (ms) supplied to `registerAgentTask`. Surfaced so shutdown + * wait-caps and UI can read the originally-requested timeout without + * round-tripping the call site. `undefined` means no deadline. + */ + readonly timeoutMs?: number | undefined; + /** Identifier of the spawned subagent (agent tasks only). */ + readonly agentId?: string | undefined; + /** Profile name of the spawned subagent (agent tasks only). */ + readonly subagentType?: string | undefined; + /** + * Human-readable reason recorded when a non-terminal task is reclassified + * via reconcile (e.g. a stale heartbeat → lost). + */ + readonly failureReason?: string | undefined; +} + +interface ManagedProcess { + readonly taskId: string; + readonly command: string; + readonly description: string; + readonly proc: KaosProcess; + readonly outputChunks: string[]; + /** Total UTF-8 bytes observed, including chunks dropped from the live ring buffer. */ + outputSizeBytes: number; + status: BackgroundTaskStatus; + exitCode: number | null; + readonly startedAt: number; + endedAt: number | null; + /** Listeners awaiting task completion. */ + readonly waiters: Array<() => void>; + /** True once `fireTerminalCallbacks` has already run. */ + terminalFired: boolean; + /** Reason carried while awaiting approval. */ + approvalReason?: string | undefined; + /** Set when a deadline fires before natural completion. */ + timedOut?: boolean | undefined; + /** Reason recorded when a task is explicitly stopped. */ + stopReason?: string | undefined; + /** Deadline supplied at registration; surfaced via task info. */ + timeoutMs?: number | undefined; + /** Subagent identifier (agent tasks only). */ + agentId?: string | undefined; + /** Subagent profile name (agent tasks only). */ + subagentType?: string | undefined; + /** Non-terminal-reclassification reason (e.g. stale heartbeat). */ + failureReason?: string | undefined; + /** True after stop() has requested cancellation but before terminal status is chosen. */ + stopRequested: boolean; + /** Session dir captured at registration for output.log writes. */ + readonly outputSessionDir?: string | undefined; + lifecyclePromise: Promise; + persistWriteQueue: Promise; + outputWriteQueue: Promise; +} + +/** + * Maximum bytes of combined output kept in the in-memory ring buffer per + * task. When exceeded, the oldest chunks are dropped. + * + * The ring buffer is a lightweight tail intended for the `/tasks` UI and + * terminal notifications only — it deliberately discards old output to + * cap memory. It is NOT the authoritative full output: the complete, + * never-truncated log lives on disk at `/tasks//output.log`. + * Callers that need the full output (e.g. `TaskOutput`) must read the + * disk log via `getOutputSizeBytes` / `readOutputBytesFromDisk`. + */ +const MAX_OUTPUT_BYTES = 1024 * 1024; // 1 MiB + +const SIGTERM_GRACE_MS = 5_000; +const EXIT_SETTLE_GRACE_MS = 10; + +const _ALPHABET = '0123456789abcdefghijklmnopqrstuvwxyz'; + +/** + * Generate `{prefix}-{8 base36 chars}`. + * + * `randomBytes(8) % 36` has a modest modulo bias (256 % 36 = 4) but + * over an 8-char suffix yields ~36^8 ≈ 2.8e12 distinct ids which is + * more than enough uniqueness for per-session task ids. + */ +export function generateTaskId(kind: BackgroundTaskKind): string { + const bytes = randomBytes(8); + let suffix = ''; + for (let i = 0; i < 8; i++) { + suffix += _ALPHABET[bytes[i]! % 36]; + } + return `${kind}-${suffix}`; +} + +/** + * Terminal-state info for tasks reconciled as lost on resume. They + * have no live KaosProcess and no captured output (the buffer died + * with the previous process), so list/get returns this minimal record. + */ +export interface ReconcileResult { + /** Task IDs that were marked `lost` because their process is gone. */ + readonly lost: readonly string[]; + /** Snapshot of each lost task's persisted info for terminal notifications. */ + readonly lostInfo: readonly BackgroundTaskInfo[]; +} + +export interface BackgroundProcessManagerOptions { + readonly maxRunningTasks?: number; + readonly sessionDir?: string; +} + +export interface BackgroundTaskReservation { + release(): void; +} + +export interface BackgroundTaskOutputSnapshot { + readonly outputPath?: string; + readonly outputSizeBytes: number; + readonly previewBytes: number; + readonly truncated: boolean; + readonly fullOutputAvailable: boolean; + readonly preview: string; +} + +function emptyOutputSnapshot(): BackgroundTaskOutputSnapshot { + return { + outputSizeBytes: 0, + previewBytes: 0, + truncated: false, + fullOutputAvailable: false, + preview: '', + }; +} + +// ── Manager ────────────────────────────────────────────────────────── + +export class BackgroundProcessManager { + private readonly processes = new Map(); + private reservedTaskSlots = 0; + /** + * Ghosts: tasks loaded from disk during reconcile that have no live + * KaosProcess. They appear in `list()` / `getTask()` with status + * `lost` so users see what was running before the crash/restart. + */ + private readonly ghosts = new Map(); + /** When set, register/lifecycle changes persist to disk. */ + private sessionDir: string | undefined; + + /** + * Registered terminal-state callbacks. Fired once per task when the + * task reaches a terminal state (completed / failed / killed). + */ + private readonly terminalCallbacks: Array<(info: BackgroundTaskInfo) => void | Promise> = + []; + + /** + * Registered lifecycle callbacks. Fired for every observable + * transition (started / updated / terminated). Errors thrown by + * callbacks are silently swallowed so the BPM main flow never breaks + * because of a buggy subscriber. + */ + private readonly lifecycleCallbacks: Array< + (event: BackgroundLifecycleEvent, info: BackgroundTaskInfo) => void + > = []; + + constructor(private readonly options: BackgroundProcessManagerOptions = {}) { + this.sessionDir = options.sessionDir; + } + + /** + * Register a callback that fires when any task reaches a terminal + * state. The callback receives the task's `BackgroundTaskInfo` + * snapshot. Multiple callbacks may be registered; they are invoked in + * registration order. Errors thrown by callbacks are silently swallowed. + */ + onTerminal(callback: (info: BackgroundTaskInfo) => void | Promise): void { + this.terminalCallbacks.push(callback); + } + + /** + * Register a callback that fires on every lifecycle transition: + * - 'started': task just registered (either bash or agent) + * - 'updated': awaiting_approval entered / cleared + * - 'terminated': task reached a terminal state (also triggers + * onTerminal); fires exactly once per task. + * + * Synchronous callback. Errors are swallowed so the BPM lifecycle + * machinery (status updates, persistence, waiters) cannot be blocked + * by a buggy subscriber. Use it for fan-out to RPC events; do not put + * heavy work in it (defer to microtask if needed). + */ + onLifecycle(callback: (event: BackgroundLifecycleEvent, info: BackgroundTaskInfo) => void): void { + this.lifecycleCallbacks.push(callback); + } + + /** Fan out a lifecycle event to subscribers. */ + private fireLifecycle(event: BackgroundLifecycleEvent, info: BackgroundTaskInfo): void { + for (const cb of this.lifecycleCallbacks) { + try { + cb(event, info); + } catch { + /* swallow callback errors */ + } + } + } + + /** + * Subclasses can react to live task completion here. Restored disk + * tasks reconciled as lost do not call this hook. + */ + protected onLiveTaskTerminal(_info: BackgroundTaskInfo): void | Promise {} + + /** + * Fire all registered terminal callbacks for a task. Idempotent: the + * second invocation for the same task is a no-op so `reconcile()` / + * a lagging `wait()` resolver / a race between `stop()` and natural + * exit cannot yield duplicate notifications. This is the manager-side + * half of the dedupe pact with `NotificationManager.dedupe_key`. + */ + private fireTerminalCallbacks(entry: ManagedProcess): void { + if (entry.terminalFired) return; + entry.terminalFired = true; + const info = this.toInfo(entry); + try { + const result = this.onLiveTaskTerminal(info); + if (result && typeof result.catch === 'function') { + result.catch(() => {}); + } + } catch { + /* swallow */ + } + this.fireTerminalSubscribers(info); + } + + private fireTerminalSubscribers(info: BackgroundTaskInfo): void { + for (const cb of this.terminalCallbacks) { + try { + const result = cb(info); + if (result && typeof result.catch === 'function') { + result.catch(() => {}); + } + } catch { + /* swallow callback errors */ + } + } + this.fireLifecycle('terminated', info); + } + + private resolveWaiters(entry: ManagedProcess): void { + const waiters = entry.waiters.splice(0); + for (const resolve of waiters) resolve(); + } + + assertCanRegister(): void { + const maxRunningTasks = this.options.maxRunningTasks; + if (maxRunningTasks === undefined) return; + if (this.activeTaskCount() + this.reservedTaskSlots < maxRunningTasks) return; + throw new Error('Too many background tasks are already running.'); + } + + reserveSlot(): BackgroundTaskReservation { + const maxRunningTasks = this.options.maxRunningTasks; + if (maxRunningTasks === undefined) { + return { release: () => {} }; + } + this.assertCanRegister(); + this.reservedTaskSlots++; + let released = false; + return { + release: () => { + if (released) return; + released = true; + this.reservedTaskSlots--; + }, + }; + } + + private activeTaskCount(): number { + let count = 0; + for (const entry of this.processes.values()) { + if (!TERMINAL_STATUSES.has(entry.status)) count++; + } + return count; + } + + /** + * Register a KaosProcess as a background task. + * Starts capturing stdout/stderr and monitors lifecycle via `wait()`. + * Returns the assigned task ID. + * + * `opts.kind` picks the id prefix. Defaults to `'bash'` because bash + * subprocess registration is the only caller on the process path + * today; agent tasks go through `registerAgentTask` which forces + * `'agent'`. + */ + register( + proc: KaosProcess, + command: string, + description: string, + opts: + | { + kind?: BackgroundTaskKind; + /** + * Optional shell metadata. Carried so the `/task` UI and the + * background persist snapshot can surface which dialect a + * task was launched under. Legacy callers omitting this + * field keep the implicit 'bash' default. + */ + shellInfo?: { + shellName: string; + shellPath: string; + cwd: string; + }; + reservation?: BackgroundTaskReservation; + } + | undefined = undefined, + ): string { + if (opts?.reservation) { + opts.reservation.release(); + } else { + this.assertCanRegister(); + } + const kind = opts?.kind; + const taskId = generateTaskId(kind ?? 'bash'); + const entry: ManagedProcess = { + taskId, + command, + description, + proc, + outputChunks: [], + outputSizeBytes: 0, + status: 'running', + exitCode: null, + startedAt: Date.now(), + endedAt: null, + waiters: [], + terminalFired: false, + stopRequested: false, + outputSessionDir: this.sessionDir, + lifecyclePromise: Promise.resolve(), + persistWriteQueue: Promise.resolve(), + outputWriteQueue: Promise.resolve(), + }; + this.processes.set(taskId, entry); + + // Capture stdout + stderr into the ring buffer. + for (const stream of [proc.stdout, proc.stderr]) { + stream.setEncoding('utf8'); + stream.on('data', (chunk: string) => { + this.appendOutput(entry, chunk); + }); + } + + // Initial persistence (snapshot at start). + void this.persistLive(entry); + this.fireLifecycle('started', this.toInfo(entry)); + + // Monitor lifecycle via wait() — no EventEmitter dependency. + entry.lifecyclePromise = proc + .wait() + .then((exitCode) => this.settleProcessExit(entry, exitCode)) + .catch(async (_err: unknown) => { + // When `proc.wait()` rejects (launch failed / stream error), + // still drive the task through the same terminal finalizer. + await this.finalizeTerminal(entry, entry.stopRequested ? 'killed' : 'failed', null); + }); + void entry.lifecyclePromise; + + return taskId; + } + + /** Get info about a specific task. Falls back to reconcile ghosts. */ + getTask(taskId: string): BackgroundTaskInfo | undefined { + const entry = this.processes.get(taskId); + if (entry !== undefined) { + return this.toInfo(entry); + } + return this.ghosts.get(taskId); + } + + /** + * Give just-ended processes a short grace period to settle their `wait()` + * promise, then return with whatever lifecycle state has been finalized. + */ + async settlePendingExits(): Promise { + const pendingCompletions = this.observedExitCompletions(); + if (pendingCompletions.length === 0) return; + await Promise.race([ + Promise.allSettled(pendingCompletions).then(() => {}), + new Promise((resolve) => { + setTimeout(resolve, EXIT_SETTLE_GRACE_MS); + }), + ]); + } + + /** + * List tasks, optionally filtering to active-only. + * + * When `activeOnly=false`, includes reconcile ghosts (lost tasks + * from a prior CLI process) so the user sees what survived the + * restart. Active-only mode never shows ghosts (they're terminal). + */ + list(activeOnly = true, limit?: number): BackgroundTaskInfo[] { + const result: BackgroundTaskInfo[] = []; + for (const entry of this.processes.values()) { + // An awaiting_approval task is non-terminal and therefore counts + // as active in listings (UI needs to show it alongside plain + // running tasks). + if (activeOnly && TERMINAL_STATUSES.has(entry.status)) continue; + result.push(this.toInfo(entry)); + if (limit !== undefined && result.length >= limit) return result; + } + if (!activeOnly) { + for (const ghost of this.ghosts.values()) { + result.push(ghost); + if (limit !== undefined && result.length >= limit) return result; + } + } + return result; + } + + /** + * Await all pending `output.log` appends for a task to settle. + * + * Output chunks are persisted to disk on an async queue, so a task can + * reach a terminal state before its final chunks have landed on disk. + * Callers that read the on-disk log (`getOutputSizeBytes` / + * `readOutputBytesFromDisk`) should `await flushOutput()` first so they + * observe the complete log. No-op for unknown/ghost tasks. + */ + async flushOutput(taskId: string): Promise { + const entry = this.processes.get(taskId); + if (entry === undefined) return; + await entry.outputWriteQueue; + } + + /** + * Total byte size of a task's full output as stored on disk. + * + * Reads `/tasks//output.log`, which is the complete, + * never-truncated log — unlike the in-memory ring buffer it never drops + * old chunks. Returns 0 when the manager is detached, the task is + * unknown, or the task has produced no output yet. + */ + async getOutputSizeBytes(taskId: string): Promise { + const outputSessionDir = this.outputSessionDirFor(taskId); + if (outputSessionDir === undefined) return 0; + return taskOutputSizeBytes(outputSessionDir, taskId); + } + + /** + * Read a byte range of a task's full output from the on-disk log. + * + * Reads up to `maxBytes` bytes starting at `offset` of `output.log`, + * straight from disk so it never loses the head of a large task the way + * the in-memory ring buffer would. Callers derive `offset` and `maxBytes` + * from a single `getOutputSizeBytes` snapshot, so the bytes returned stay + * consistent with the size used for metadata even when a still-running + * task keeps growing its log. Returns an empty string when the manager + * is detached, the task is unknown, or the log is absent. + */ + async readOutputBytesFromDisk( + taskId: string, + offset: number, + maxBytes: number, + ): Promise { + const outputSessionDir = this.outputSessionDirFor(taskId); + if (outputSessionDir === undefined) return ''; + return readTaskOutputBytes(outputSessionDir, taskId, offset, maxBytes); + } + + /** + * Return the output snapshot used by TaskOutput. + * + * Persisted logs are preferred when the task was registered with an + * output session directory and `output.log` has actually been created, + * because they are the complete, never-truncated source. Detached managers, + * tasks registered before a session dir was attached, and silent tasks with + * no persisted log fall back to the live ring buffer. + */ + async getOutputSnapshot( + taskId: string, + maxPreviewBytes: number, + ): Promise { + if (this.getTask(taskId) === undefined) return emptyOutputSnapshot(); + + await this.flushOutput(taskId); + + const previewLimit = Math.max(0, Math.trunc(maxPreviewBytes)); + const outputSessionDir = this.outputSessionDirFor(taskId); + if (outputSessionDir !== undefined && (await taskOutputExists(outputSessionDir, taskId))) { + const outputSizeBytes = await taskOutputSizeBytes(outputSessionDir, taskId); + const previewOffset = Math.max(0, outputSizeBytes - previewLimit); + const previewBytes = outputSizeBytes - previewOffset; + const preview = await readTaskOutputBytes( + outputSessionDir, + taskId, + previewOffset, + previewBytes, + ); + return { + outputPath: taskOutputFile(outputSessionDir, taskId), + outputSizeBytes, + previewBytes, + truncated: previewOffset > 0, + fullOutputAvailable: true, + preview, + }; + } + + const entry = this.processes.get(taskId); + if (entry === undefined) return emptyOutputSnapshot(); + + const available = Buffer.from(entry.outputChunks.join(''), 'utf-8'); + const previewBytes = Math.min(previewLimit, available.byteLength, entry.outputSizeBytes); + const previewOffset = available.byteLength - previewBytes; + return { + outputSizeBytes: entry.outputSizeBytes, + previewBytes, + truncated: entry.outputSizeBytes > previewBytes, + fullOutputAvailable: false, + preview: available.subarray(previewOffset).toString('utf-8'), + }; + } + + /** Get the combined output of a task (tail of the ring buffer). */ + getOutput(taskId: string, tail?: number): string { + const entry = this.processes.get(taskId); + if (!entry) return ''; + const full = entry.outputChunks.join(''); + if (tail !== undefined && tail < full.length) { + return full.slice(-tail); + } + return full; + } + + async readOutput(taskId: string, tail?: number): Promise { + const entry = this.processes.get(taskId); + const outputSessionDir = this.outputSessionDirFor(taskId); + if (outputSessionDir !== undefined) { + await entry?.outputWriteQueue; + const persisted = await readTaskOutput(outputSessionDir, taskId); + if (persisted.length > 0) { + if (tail !== undefined && tail < persisted.length) { + return persisted.slice(-tail); + } + return persisted; + } + } + return this.getOutput(taskId, tail); + } + + getOutputPath(taskId: string): string | undefined { + const outputSessionDir = this.outputSessionDirFor(taskId); + if (outputSessionDir === undefined) return undefined; + if (!taskOutputExistsSync(outputSessionDir, taskId)) return undefined; + return taskOutputFile(outputSessionDir, taskId); + } + + /** Stop a running task. SIGTERM → 5s grace → SIGKILL. */ + async stop(taskId: string, reason?: string): Promise { + const entry = this.processes.get(taskId); + if (!entry) return undefined; + // Normalize at this shared boundary: every public stop path (the TaskStop + // tool, SDK/RPC) funnels through here, so a blank or whitespace-only + // reason must never be recorded as an empty stopReason. + const trimmedReason = reason?.trim(); + const stopReason = + trimmedReason === undefined || trimmedReason.length === 0 ? undefined : trimmedReason; + // Terminal tasks short-circuit. awaiting_approval tasks can still + // be stopped (the approval gate is lifted when we transition to + // 'killed'). + if (TERMINAL_STATUSES.has(entry.status)) { + await entry.persistWriteQueue; + return this.toInfo(entry); + } + + entry.approvalReason = undefined; + entry.stopRequested = true; + entry.stopReason = stopReason; + + try { + await entry.proc.kill('SIGTERM'); + } catch { + /* process already gone */ + } + + // Wait up to 5s for the lifecycle path to settle, then SIGKILL. + // Waiting on lifecyclePromise, rather than proc.wait() directly, lets a + // natural completion win the race instead of being overwritten here. + let graceTimer: ReturnType | undefined; + const graceful = await Promise.race([ + entry.lifecyclePromise.then( + () => true, + () => true, + ), + new Promise((resolve) => { + graceTimer = setTimeout(() => { + resolve(false); + }, SIGTERM_GRACE_MS); + }), + ]); + if (graceTimer !== undefined) clearTimeout(graceTimer); + + if (TERMINAL_STATUSES.has(entry.status)) { + await entry.persistWriteQueue; + return this.toInfo(entry); + } + + if (!graceful && entry.proc.exitCode === null) { + try { + await entry.proc.kill('SIGKILL'); + } catch { + /* ignore */ + } + } + + if (TERMINAL_STATUSES.has(entry.status)) { + await entry.persistWriteQueue; + return this.toInfo(entry); + } + + // Agent tasks whose completion promise never settles (no timeoutMs, + // or a truly hung coroutine) need an explicit terminal finalize here. + await this.finalizeTerminal(entry, 'killed', null, { stopReason }); + + return this.toInfo(entry); + } + + async stopAll(reason?: string): Promise { + const taskIds = Array.from(this.processes.values()) + .filter((entry) => !TERMINAL_STATUSES.has(entry.status)) + .map((entry) => entry.taskId); + const results = await Promise.all(taskIds.map((taskId) => this.stop(taskId, reason))); + return results.filter((info): info is BackgroundTaskInfo => info !== undefined); + } + + /** + * Wait for a task to reach a terminal state. + * Returns immediately if already terminal. Times out after `timeoutMs`. + */ + async wait(taskId: string, timeoutMs = 30_000): Promise { + const entry = this.processes.get(taskId); + if (!entry) return undefined; + if (TERMINAL_STATUSES.has(entry.status)) { + await entry.persistWriteQueue; + return this.toInfo(entry); + } + + let terminalWaiter: (() => void) | undefined; + let timeout: ReturnType | undefined; + try { + await Promise.race([ + new Promise((resolve) => { + terminalWaiter = resolve; + entry.waiters.push(resolve); + }), + new Promise((resolve) => { + timeout = setTimeout(resolve, timeoutMs); + }), + ]); + } finally { + if (timeout !== undefined) clearTimeout(timeout); + if (terminalWaiter !== undefined) { + const index = entry.waiters.indexOf(terminalWaiter); + if (index !== -1) entry.waiters.splice(index, 1); + } + } + + if (TERMINAL_STATUSES.has(entry.status)) { + await entry.persistWriteQueue; + } + return this.toInfo(entry); + } + + /** + * Register a Promise-based agent task (no KaosProcess). Used by + * AgentTool for background subagent dispatch. Agent tasks appear in + * `list()` / `getTask()` but have pid=0 and empty output. + * + * `opts.timeoutMs` wraps the completion in an external deadline. On + * deadline fire, the task is marked `failed` with `timedOut=true` + * (distinct from a caller-driven `stop()` which uses `killed`, and + * distinct from an internal `TimeoutError` rejection which is a + * generic `failed` with `timedOut` left unset). + */ + registerAgentTask( + completion: Promise<{ result: string }>, + description: string, + opts: { + timeoutMs?: number; + abort?: () => void; + reservation?: BackgroundTaskReservation; + /** Subagent identifier; surfaced on task info. */ + agentId?: string; + /** Subagent profile name; surfaced on task info. */ + subagentType?: string; + } = {}, + ): string { + if (opts.reservation) { + opts.reservation.release(); + } else { + this.assertCanRegister(); + } + const taskId = generateTaskId('agent'); + const entry: ManagedProcess = { + taskId, + command: `[agent] ${description}`, + description, + timeoutMs: opts.timeoutMs, + // Fall back to defaults that satisfy callers reading these fields + // without forcing every call site to supply them. The dedicated + // dispatch path in AgentTool passes the real handle.agentId / + // handle.profileName. + agentId: opts.agentId ?? taskId, + subagentType: opts.subagentType ?? 'agent', + // Dummy KaosProcess — agent tasks are Promise-based, not process-based + proc: { + stdin: { write: () => false, end: () => {} } as never, + stdout: { setEncoding: () => {}, on: () => {} } as never, + stderr: { setEncoding: () => {}, on: () => {} } as never, + pid: 0, + exitCode: null, + wait: () => completion.then(() => 0), + kill: async () => { + opts.abort?.(); + }, + } as unknown as KaosProcess, + outputChunks: [], + outputSizeBytes: 0, + status: 'running', + exitCode: null, + startedAt: Date.now(), + endedAt: null, + waiters: [], + terminalFired: false, + stopRequested: false, + outputSessionDir: this.sessionDir, + lifecyclePromise: Promise.resolve(), + persistWriteQueue: Promise.resolve(), + outputWriteQueue: Promise.resolve(), + }; + this.processes.set(taskId, entry); + void this.persistLive(entry); + this.fireLifecycle('started', this.toInfo(entry)); + + // Deadline symbol distinguishes "external timeout fired" from "the + // agent promise itself rejected with TimeoutError" (which must + // remain a generic failure, not a deadline timeout). + const deadlineTimeout = Symbol('deadline-timeout'); + let deadlineTimer: ReturnType | undefined; + + const raceInputs: Array> = [completion]; + if (opts.timeoutMs !== undefined && opts.timeoutMs > 0) { + raceInputs.push( + new Promise((resolve) => { + deadlineTimer = setTimeout(() => { + resolve(deadlineTimeout); + }, opts.timeoutMs); + }), + ); + } + + const settleLifecycle = Promise.race(raceInputs) + .then(async (outcome) => { + if (outcome === deadlineTimeout) { + // External deadline fired before the agent resolved. + if (TERMINAL_STATUSES.has(entry.status)) return; + opts.abort?.(); + await this.finalizeTerminal(entry, 'failed', 1, { timedOut: true }); + return; + } + // `completion` resolved before deadline. + const r = outcome as { result: string }; + if (TERMINAL_STATUSES.has(entry.status)) return; + this.appendOutput(entry, r.result); + await this.finalizeTerminal(entry, 'completed', 0); + }) + .catch(async (error: unknown) => { + // Caller-driven stop() that ran to completion through our own + // abort callback: the rejection is an AbortError-shaped object. + // Treat as `killed` so user-initiated cancellation is recorded + // as a cancellation, not a failure. The shape check is + // load-bearing — if a non-AbortError rejection arrives while + // `stopRequested` is set, it means a real failure (e.g. a + // model error) won the race against the in-flight stop, and we + // must record that failure rather than hide it behind the + // user's cancellation. + if (entry.stopRequested && isAbortError(error)) { + await this.finalizeTerminal(entry, 'killed', null); + return; + } + // Runner-initiated cancellation: the background agent runner + // raises `RunCancelled` to signal "abort this run" (e.g. on a + // Ctrl+C path with no BPM-side stop()). Map to `killed` + // because cancellation is not a failure. + if (error instanceof Error && error.name === 'RunCancelled') { + await this.finalizeTerminal(entry, 'killed', null); + return; + } + // Internal rejection (including TimeoutError, model errors, + // and stopRequested cases where a non-abort failure won the + // race): generic failure. `timedOut` stays unset so consumers + // can distinguish this from a true external deadline. + await this.finalizeTerminal(entry, 'failed', 1); + }) + .finally(() => { + if (deadlineTimer !== undefined) clearTimeout(deadlineTimer); + }); + + entry.lifecyclePromise = settleLifecycle; + void entry.lifecyclePromise; + + return taskId; + } + + // ── awaiting_approval state transitions ──────────────────────────── + + /** + * Mark a running task as paused pending approval. The approval reason + * (tool call description) is retained until the task either returns + * to `'running'` via `clearAwaitingApproval()` or reaches a terminal + * state. Calls on terminal or unknown tasks are silently ignored so + * the ApprovalRuntime callback path is race-safe. + */ + markAwaitingApproval(taskId: string, reason: string): void { + const entry = this.processes.get(taskId); + if (!entry) return; + if (TERMINAL_STATUSES.has(entry.status)) return; + entry.status = 'awaiting_approval'; + entry.approvalReason = reason; + void this.persistLive(entry); + this.fireLifecycle('updated', this.toInfo(entry)); + } + + /** + * Drop the approval gate and return to `'running'`. Clears the stored + * reason so stale text cannot leak into a future `awaiting_approval` + * cycle. No-op unless the task is currently in the awaiting_approval + * state. + */ + clearAwaitingApproval(taskId: string): void { + const entry = this.processes.get(taskId); + if (!entry) return; + if (entry.status !== 'awaiting_approval') return; + entry.status = 'running'; + entry.approvalReason = undefined; + void this.persistLive(entry); + this.fireLifecycle('updated', this.toInfo(entry)); + } + + // ── completion event (await lifecycle end) ──────────────────────── + + /** + * Resolve when the task reaches a terminal state. If the task is + * already terminal, resolves synchronously on the next microtask. + * Intended for integration code that wants to `await` a specific + * task's exit without installing a full `onTerminal` subscriber. + * Returns `undefined` for unknown ids (matching `getTask`). Ghost + * (reconciled-lost) entries are considered terminal from the + * manager's perspective. + */ + async waitForTerminal(taskId: string): Promise { + const entry = this.processes.get(taskId); + if (entry === undefined) return this.ghosts.get(taskId); + if (TERMINAL_STATUSES.has(entry.status)) { + await entry.persistWriteQueue; + return this.toInfo(entry); + } + await new Promise((resolve) => { + entry.waiters.push(resolve); + }); + await entry.persistWriteQueue; + return this.toInfo(entry); + } + + /** Reset internal state (for testing). */ + _reset(): void { + this.processes.clear(); + this.ghosts.clear(); + this.sessionDir = undefined; + } + + // ── persistence + reconcile ──────────────────────────────────────── + + /** + * Attach the manager to a session directory for persistence. Tasks + * created via `register()` after this call are written to + * `/tasks/.json` and updated on lifecycle change. + * Tasks created before attach are NOT retroactively persisted. + */ + attachSessionDir(sessionDir: string): void { + this.sessionDir = sessionDir; + } + + /** + * Load persisted task records into the ghost map. Does NOT reconcile + * (call `reconcile()` after `loadFromDisk()`). Idempotent; subsequent + * calls overwrite the ghost map. + * + * Requires `attachSessionDir()` first; no-op otherwise. + */ + async loadFromDisk(): Promise { + if (this.sessionDir === undefined) return; + this.ghosts.clear(); + const persisted = await listTasks(this.sessionDir); + for (const t of persisted) { + // Skip ids that already exist as live processes — live wins. + if (this.processes.has(t.task_id)) continue; + this.ghosts.set(t.task_id, persistedToInfo(t)); + } + } + + /** + * Reconcile loaded ghost tasks. Any ghost with status `running` is + * reclassified as `lost` (its previous CLI process died without + * writing a terminal state). Updates the on-disk record and returns + * the lost task ids so the caller can emit user-facing notifications. + */ + protected async markLoadedTasksLost(): Promise { + const lost: string[] = []; + const lostInfo: BackgroundTaskInfo[] = []; + for (const [id, info] of this.ghosts) { + // Any non-terminal ghost is lost. Includes `awaiting_approval` + // (the approval context died with the previous process so it + // cannot be resumed). + if (TERMINAL_STATUSES.has(info.status)) continue; + const updated: BackgroundTaskInfo = { + ...info, + status: 'lost', + endedAt: info.endedAt ?? Date.now(), + approvalReason: undefined, + failureReason: 'Background worker heartbeat expired', + }; + this.ghosts.set(id, updated); + if (this.sessionDir !== undefined) { + await writeTask(this.sessionDir, infoToPersisted(updated)); + } + lost.push(id); + lostInfo.push(updated); + } + return { lost, lostInfo }; + } + + async reconcile(): Promise { + const result = await this.markLoadedTasksLost(); + // Fire onTerminal for newly-lost ghosts so NotificationManager + // receives a `task.lost` notification. Dedupe on the consumer side + // is by `dedupe_key`; a second reconcile() on the same ghost is a + // no-op because the status flips to `lost` above and we guard on + // TERMINAL_STATUSES on the next pass. + for (const info of result.lostInfo) { + this.fireTerminalSubscribers(info); + } + return result; + } + + /** Drop a persisted task from disk and ghost map. */ + async forgetTask(taskId: string): Promise { + this.ghosts.delete(taskId); + if (this.sessionDir !== undefined) { + await removeTask(this.sessionDir, taskId); + } + } + + /** + * Persist the current state of a live ManagedProcess. Called from + * `register()` and the lifecycle finally block. No-op unless attached. + */ + private persistLive(entry: ManagedProcess): Promise { + if (this.sessionDir === undefined) return Promise.resolve(); + const sessionDir = this.sessionDir; + const task: PersistedTask = { + task_id: entry.taskId, + command: entry.command, + description: entry.description, + pid: entry.proc.pid, + started_at: entry.startedAt, + ended_at: entry.endedAt, + exit_code: entry.exitCode, + status: entry.status, + approval_reason: entry.approvalReason, + timed_out: entry.timedOut, + stop_reason: entry.stopReason, + }; + entry.persistWriteQueue = entry.persistWriteQueue + .then(() => writeTask(sessionDir, task)) + .catch(() => {}); + return entry.persistWriteQueue; + } + + private appendOutput(entry: ManagedProcess, chunk: string): void { + entry.outputSizeBytes += Buffer.byteLength(chunk, 'utf-8'); + entry.outputChunks.push(chunk); + // Enforce output cap: drop oldest chunks when over budget. + let total = entry.outputChunks.reduce((s, c) => s + c.length, 0); + while (total > MAX_OUTPUT_BYTES && entry.outputChunks.length > 1) { + const removed = entry.outputChunks.shift(); + if (removed === undefined) break; + total -= removed.length; + } + + const outputSessionDir = entry.outputSessionDir; + if (outputSessionDir === undefined) return; + entry.outputWriteQueue = entry.outputWriteQueue + .then(() => appendTaskOutput(outputSessionDir, entry.taskId, chunk)) + .catch(() => {}); + } + + private outputSessionDirFor(taskId: string): string | undefined { + const entry = this.processes.get(taskId); + if (entry !== undefined) return entry.outputSessionDir; + if (this.ghosts.has(taskId)) return this.sessionDir; + return undefined; + } + + private async settleProcessExit(entry: ManagedProcess, exitCode: number): Promise { + if (TERMINAL_STATUSES.has(entry.status)) { + if (entry.status === 'killed' && entry.exitCode === null) { + entry.exitCode = exitCode; + entry.endedAt = Date.now(); + await this.persistLive(entry); + this.fireTerminalCallbacks(entry); + this.resolveWaiters(entry); + } + return; + } + const status = entry.stopRequested ? 'killed' : exitCode === 0 ? 'completed' : 'failed'; + await this.finalizeTerminal(entry, status, exitCode); + } + + private observedExitCompletions(): Promise[] { + const completions: Promise[] = []; + for (const entry of this.processes.values()) { + if (!TERMINAL_STATUSES.has(entry.status) && entry.proc.exitCode !== null) { + completions.push(entry.lifecyclePromise); + } + } + return completions; + } + + private toInfo(entry: ManagedProcess): BackgroundTaskInfo { + return { + taskId: entry.taskId, + command: entry.command, + description: entry.description, + status: entry.status, + pid: entry.proc.pid, + exitCode: entry.exitCode, + startedAt: entry.startedAt, + endedAt: entry.endedAt, + approvalReason: entry.approvalReason, + timedOut: entry.timedOut, + stopReason: entry.stopReason, + timeoutMs: entry.timeoutMs, + agentId: entry.agentId, + subagentType: entry.subagentType, + failureReason: entry.failureReason, + }; + } + + private async finalizeTerminal( + entry: ManagedProcess, + status: BackgroundTaskStatus, + exitCode: number | null, + options: { readonly timedOut?: boolean; readonly stopReason?: string } = {}, + ): Promise { + if (TERMINAL_STATUSES.has(entry.status)) return false; + entry.status = status; + entry.exitCode = exitCode; + entry.endedAt = Date.now(); + entry.timedOut = options.timedOut; + entry.stopReason = status === 'killed' ? (options.stopReason ?? entry.stopReason) : undefined; + // A task that ended while still in awaiting_approval (e.g. crashed + // mid-prompt, deadline fired, or got killed) must not leak the + // stale approvalReason onto the terminal record. The awaiting → + // running path (clearAwaitingApproval) already clears it; mirror + // that here for the awaiting → terminal path. + entry.approvalReason = undefined; + entry.stopRequested = false; + await this.persistLive(entry); + this.fireTerminalCallbacks(entry); + this.resolveWaiters(entry); + return true; + } +} + +// ── persistence shape <-> in-memory shape ────────────────────────────── + +function persistedToInfo(t: PersistedTask): BackgroundTaskInfo { + return { + taskId: t.task_id, + command: t.command, + description: t.description, + status: t.status, + pid: t.pid, + exitCode: t.exit_code, + startedAt: t.started_at, + endedAt: t.ended_at, + approvalReason: t.approval_reason, + timedOut: t.timed_out, + stopReason: t.stop_reason, + }; +} + +function infoToPersisted(info: BackgroundTaskInfo): PersistedTask { + return { + task_id: info.taskId, + command: info.command, + description: info.description, + pid: info.pid, + started_at: info.startedAt, + ended_at: info.endedAt, + exit_code: info.exitCode, + status: info.status, + approval_reason: info.approvalReason, + timed_out: info.timedOut, + stop_reason: info.stopReason, + }; +} diff --git a/packages/agent-core/src/tools/background/persist.ts b/packages/agent-core/src/tools/background/persist.ts new file mode 100644 index 000000000..bc4364e29 --- /dev/null +++ b/packages/agent-core/src/tools/background/persist.ts @@ -0,0 +1,269 @@ +/** + * Background task persistence helpers. + * + * Each task lives at `/tasks/.json` so a CLI + * restart can list previously-running tasks (now lost) and emit terminal + * notifications. + * + * Writes use `atomicWrite` (write-tmp-fsync-rename) so a crash mid-write + * never leaves a half-truncated file. + */ + +import { statSync } from 'node:fs'; +import { appendFile, mkdir, open, readFile, readdir, rm, stat, unlink } from 'node:fs/promises'; +import { dirname, join } from 'node:path'; + +import { atomicWrite } from '../../utils/fs'; +import type { BackgroundTaskStatus } from './manager'; + +/** + * Task id format: `{bash|agent}-{8 chars of [0-9a-z]}`. + * + * Strictly enforced by `taskFile()` so neither path-traversal (`../`) + * nor a legacy `bg_` format can escape through the persistence + * layer. + */ +export const VALID_TASK_ID: RegExp = /^(bash|agent)-[0-9a-z]{8}$/; + +/** On-disk task representation (snake_case, Python-friendly). */ +export interface PersistedTask { + readonly task_id: string; + readonly command: string; + readonly description: string; + readonly pid: number; + readonly started_at: number; + readonly ended_at: number | null; + readonly exit_code: number | null; + readonly status: BackgroundTaskStatus; + /** + * Reason supplied when the task is marked `awaiting_approval`. + * Cleared (omitted) when the task leaves that state. + */ + readonly approval_reason?: string | undefined; + /** + * True when an agent task was forcibly terminated by its external + * deadline (`registerAgentTask(..., { timeoutMs })`). An internal + * `TimeoutError` raised by the agent promise itself is a generic + * failure and does NOT set this flag. + */ + readonly timed_out?: boolean | undefined; + /** Reason recorded when a task is explicitly stopped. */ + readonly stop_reason?: string | undefined; + /** + * Shell origin metadata (name / path / cwd) captured when + * `BackgroundProcessManager.register` attached a `shellInfo` option. + * Persisted so restart can reconstruct the spawn environment. + */ + readonly shell_info?: + | { + readonly name: string; + readonly path?: string | undefined; + readonly cwd?: string | undefined; + } + | undefined; +} + +function tasksDirOf(sessionDir: string): string { + return join(sessionDir, 'tasks'); +} + +function taskFile(sessionDir: string, taskId: string): string { + if (!VALID_TASK_ID.test(taskId)) { + throw new Error(`Invalid task id: "${taskId}"`); + } + return join(tasksDirOf(sessionDir), `${taskId}.json`); +} + +function taskOutputDir(sessionDir: string, taskId: string): string { + if (!VALID_TASK_ID.test(taskId)) { + throw new Error(`Invalid task id: "${taskId}"`); + } + return join(tasksDirOf(sessionDir), taskId); +} + +export function taskOutputFile(sessionDir: string, taskId: string): string { + return join(taskOutputDir(sessionDir, taskId), 'output.log'); +} + +/** Atomically write a task's persisted state. Creates dirs as needed. */ +export async function writeTask(sessionDir: string, task: PersistedTask): Promise { + await mkdir(tasksDirOf(sessionDir), { recursive: true, mode: 0o700 }); + const target = taskFile(sessionDir, task.task_id); + await atomicWrite(target, JSON.stringify(task, null, 2)); +} + +/** Read a single task file. Returns undefined when missing/corrupt. */ +export async function readTask( + sessionDir: string, + taskId: string, +): Promise { + // Path-traversal validation runs before the try/catch so callers see + // an explicit error instead of a misleading "missing" return. + const path = taskFile(sessionDir, taskId); + let raw: string; + try { + raw = await readFile(path, 'utf-8'); + } catch { + return undefined; + } + try { + const parsed = JSON.parse(raw) as Record; + if (typeof parsed !== 'object' || parsed === null) return undefined; + return parsed as unknown as PersistedTask; + } catch { + return undefined; + } +} + +export async function appendTaskOutput( + sessionDir: string, + taskId: string, + chunk: string, +): Promise { + const path = taskOutputFile(sessionDir, taskId); + await mkdir(dirname(path), { recursive: true, mode: 0o700 }); + await appendFile(path, chunk, 'utf-8'); +} + +export async function readTaskOutput(sessionDir: string, taskId: string): Promise { + try { + return await readFile(taskOutputFile(sessionDir, taskId), 'utf-8'); + } catch { + return ''; + } +} + +/** + * Total byte size of a task's `output.log`. Returns 0 when the log does + * not exist yet (the task has produced no output, or is unknown). + * + * This is the authoritative full-output size — unlike the in-memory ring + * buffer it is never truncated, so callers can report how much output a + * task has actually produced. + */ +export async function taskOutputSizeBytes(sessionDir: string, taskId: string): Promise { + try { + const st = await stat(taskOutputFile(sessionDir, taskId)); + return st.size; + } catch { + return 0; + } +} + +export async function taskOutputExists(sessionDir: string, taskId: string): Promise { + try { + return (await stat(taskOutputFile(sessionDir, taskId))).isFile(); + } catch { + return false; + } +} + +export function taskOutputExistsSync(sessionDir: string, taskId: string): boolean { + try { + return statSync(taskOutputFile(sessionDir, taskId)).isFile(); + } catch { + return false; + } +} + +/** + * Read a byte window of a task's `output.log`. + * + * Reads at most `maxBytes` bytes starting at byte `offset`. A window that + * runs past EOF is clamped to whatever remains; an `offset` at/after EOF + * yields an empty string. Returns an empty string when the log is absent. + * + * Byte-level (not line-level) paging mirrors how the full log is stored + * on disk, so callers can page arbitrarily large logs without loading the + * whole file into memory. + */ +export async function readTaskOutputBytes( + sessionDir: string, + taskId: string, + offset: number, + maxBytes: number, +): Promise { + const start = Math.max(0, Math.trunc(offset)); + const limit = Math.max(0, Math.trunc(maxBytes)); + if (limit === 0) return ''; + let handle; + try { + handle = await open(taskOutputFile(sessionDir, taskId), 'r'); + } catch { + return ''; + } + try { + const size = (await handle.stat()).size; + if (start >= size) return ''; + const length = Math.min(limit, size - start); + const buffer = Buffer.allocUnsafe(length); + const { bytesRead } = await handle.read(buffer, 0, length, start); + return buffer.toString('utf-8', 0, bytesRead); + } catch { + return ''; + } finally { + await handle.close(); + } +} + +/** Enumerate all persisted tasks for a session. Skips corrupt entries. */ +export async function listTasks(sessionDir: string): Promise { + let entries: string[]; + try { + entries = await readdir(tasksDirOf(sessionDir)); + } catch { + return []; + } + const out: PersistedTask[] = []; + for (const entry of entries) { + if (!entry.endsWith('.json')) continue; + const taskId = entry.slice(0, -'.json'.length); + // Silently drop: filename basename is not a valid task id (stray files, + // legacy bg_* leftovers, etc.). + if (!VALID_TASK_ID.test(taskId)) continue; + const task = await readTask(sessionDir, taskId); + // Silently drop: JSON parse failed or file disappeared between readdir + // and readTask. writeTask uses an atomic temp+rename pattern so a + // genuinely truncated file in production is rare; if it happens we + // accept the loss rather than emit a ghost with no recoverable + // metadata beyond the filename. + if (task === undefined) continue; + // Silently drop: parsed JSON is missing one or more required fields + // for a PersistedTask. Treated the same as a missing file. + if (!isValidPersistedTask(task)) continue; + out.push(task); + } + return out; +} + +/** + * Validate that the parsed JSON actually shapes like a PersistedTask. + * Cheap shape check (not a full zod schema) — rejects the canonical + * "spec with missing fields" failure mode. + */ +function isValidPersistedTask(obj: unknown): obj is PersistedTask { + if (typeof obj !== 'object' || obj === null) return false; + const o = obj as Record; + return ( + typeof o['task_id'] === 'string' && + typeof o['command'] === 'string' && + typeof o['description'] === 'string' && + typeof o['pid'] === 'number' && + typeof o['started_at'] === 'number' && + (o['ended_at'] === null || typeof o['ended_at'] === 'number') && + (o['exit_code'] === null || typeof o['exit_code'] === 'number') && + typeof o['status'] === 'string' + ); +} + +/** Remove a task file (idempotent). */ +export async function removeTask(sessionDir: string, taskId: string): Promise { + // Path-traversal validation outside try/catch. + const path = taskFile(sessionDir, taskId); + try { + await unlink(path); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error; + } + await rm(taskOutputDir(sessionDir, taskId), { recursive: true, force: true }); +} diff --git a/packages/agent-core/src/tools/background/task-list.md b/packages/agent-core/src/tools/background/task-list.md new file mode 100644 index 000000000..c2826c1ef --- /dev/null +++ b/packages/agent-core/src/tools/background/task-list.md @@ -0,0 +1,24 @@ +List background tasks and their current status. + +Use this tool to discover which background tasks exist and where each one +stands. It is the entry point for inspecting background work: it returns a +task ID, status, command, description, and PID for every task it reports, +plus the exit code and stop reason for tasks that have already finished. + +Guidelines: + +- After a context compaction, or whenever you are unsure which background + tasks are running or what their task IDs are, call this tool to + re-enumerate them instead of guessing a task ID. +- Prefer the default `active_only=true`, which lists only non-terminal tasks. + Pass `active_only=false` only when you specifically need to see tasks that + have already finished. With `active_only=false` the result may also include + `lost` tasks — tasks left over from a previous process that can no longer be + inspected or controlled; treat them as already terminated. +- `limit` caps how many tasks are returned. It accepts a value between 1 and + 100 and defaults to 20 when omitted. +- This tool only lists tasks; it does not return their output. Use it first + to locate the task ID you need, then call `TaskOutput` with that ID to read + the task's output and details. +- This tool is read-only and does not change any state, so it is always safe + to call, including in plan mode. diff --git a/packages/agent-core/src/tools/background/task-list.ts b/packages/agent-core/src/tools/background/task-list.ts new file mode 100644 index 000000000..1a8c5ccd5 --- /dev/null +++ b/packages/agent-core/src/tools/background/task-list.ts @@ -0,0 +1,83 @@ +/** + * TaskListTool — list background tasks. + */ + +import { z } from 'zod'; + +import type { BuiltinTool } from '../../agent/tool'; +import type { ToolExecution } from '../../loop/types'; +import { toInputJsonSchema } from '../support/input-schema'; +import type { BackgroundProcessManager, BackgroundTaskInfo } from './manager'; +import { isBackgroundTaskTerminal } from './manager'; +import TASK_LIST_DESCRIPTION from './task-list.md'; + +// ── Input schema ───────────────────────────────────────────────────── + +export const TaskListInputSchema = z.object({ + active_only: z + .boolean() + .optional() + .default(true) + .describe('Whether to list only non-terminal background tasks.'), + limit: z + .number() + .int() + .min(1) + .max(100) + .default(20) + .describe('Maximum number of tasks to return.') + .optional(), +}); + +export type TaskListInput = z.Infer; + +// ── Implementation ─────────────────────────────────────────────────── + +function formatTask(t: BackgroundTaskInfo): string { + const lines = [ + `task_id: ${t.taskId}`, + `status: ${t.status}`, + `command: ${t.command}`, + `description: ${t.description}`, + `pid: ${String(t.pid ?? 'N/A')}`, + ]; + // Terminal tasks carry an outcome the AI needs to act on: the process + // exit code, and — when the task was ended via TaskStop — the stop reason. + if (isBackgroundTaskTerminal(t.status)) { + if (t.exitCode !== null) lines.push(`exit_code: ${String(t.exitCode)}`); + if (t.stopReason !== undefined) lines.push(`reason: ${t.stopReason}`); + } + return lines.join('\n'); +} + +function formatTaskList(tasks: BackgroundTaskInfo[], activeOnly: boolean): string { + // `active_only=false` mixes in terminal/lost tasks, so the count is no + // longer purely "active" — use a neutral label to avoid mislabeling them. + const label = activeOnly ? 'active_background_tasks' : 'background_tasks'; + const header = `${label}: ${String(tasks.length)}`; + if (tasks.length === 0) return `${header}\nNo background tasks found.`; + return `${header}\n${tasks.map(formatTask).join('\n---\n')}`; +} + +export class TaskListTool implements BuiltinTool { + readonly name = 'TaskList' as const; + readonly description = TASK_LIST_DESCRIPTION; + readonly parameters: Record = toInputJsonSchema(TaskListInputSchema); + + constructor(private readonly manager: BackgroundProcessManager) {} + + resolveExecution(args: TaskListInput): ToolExecution { + return { + description: 'Listing background tasks', + execute: async () => { + await this.manager.settlePendingExits(); + const activeOnly = args.active_only ?? true; + const tasks = this.manager.list(activeOnly, args.limit ?? 20); + return { + output: formatTaskList(tasks, activeOnly), + isError: false, + }; + }, + }; + } +} diff --git a/packages/agent-core/src/tools/background/task-output.md b/packages/agent-core/src/tools/background/task-output.md new file mode 100644 index 000000000..395f25379 --- /dev/null +++ b/packages/agent-core/src/tools/background/task-output.md @@ -0,0 +1,12 @@ +Retrieve output from a running or completed background task. + +Use this after `Bash(run_in_background=true)` when you need to inspect progress or explicitly wait for completion. + +Guidelines: +- Prefer relying on automatic completion notifications. Use this tool only when you need task output before the automatic notification arrives. +- By default this tool is non-blocking and returns a current status/output snapshot. +- Use block=true only when you intentionally want to wait for completion or timeout. +- This tool returns structured task metadata, a fixed-size output preview, and an output_path for the full log. +- For a terminal task, the metadata also explains why it ended: `timed_out` when an agent task was aborted by its deadline, and `stop_reason` when the task was explicitly stopped. `terminal_reason` is a categorical label for the same event — its value is `timed_out` or `stopped` — and is emitted alongside the matching `timed_out` / `stop_reason` field. A task that ended on its own emits none of these three fields. +- The full, never-truncated log is always available at output_path; use the `Read` tool with that path to page through it, whether or not the preview was truncated. +- This tool works with the generic background task system and should remain the primary read path for future task types, not just bash. diff --git a/packages/agent-core/src/tools/background/task-output.ts b/packages/agent-core/src/tools/background/task-output.ts new file mode 100644 index 000000000..f77de21bf --- /dev/null +++ b/packages/agent-core/src/tools/background/task-output.ts @@ -0,0 +1,179 @@ +/** + * TaskOutputTool — read output from a background task. + * + * Returns structured task metadata plus a fixed-size tail preview of the + * task's output. The full, never-truncated output lives on disk at + * `output_path`; the caller is always pointed at the `Read` tool to page + * through the complete log, and the preview also carries a banner when it + * has been truncated to a tail. + * + * For terminal tasks the output also surfaces why the task ended: + * `timed_out` when an agent task was aborted by its deadline, and + * `stop_reason` when the task was explicitly stopped via `TaskStop`. + */ + +import { z } from 'zod'; + +import type { BuiltinTool } from '../../agent/tool'; +import type { ExecutableToolResult, ToolExecution } from '../../loop/types'; +import { + isBackgroundTaskTerminal, + type BackgroundProcessManager, + type BackgroundTaskStatus, +} from './manager'; +import { toInputJsonSchema } from '../support/input-schema'; +import TASK_OUTPUT_DESCRIPTION from './task-output.md'; + +/** + * Maximum bytes of output included inline as a preview. Output larger + * than this is truncated to its tail; the full log is read separately + * via the `Read` tool with the returned `output_path`. + */ +const OUTPUT_PREVIEW_BYTES = 32 * 1024; // 32 KiB + +/** Number of lines the paging hint suggests reading per `Read` call. */ +const PAGING_HINT_LINES = 300; + +// ── Input schema ───────────────────────────────────────────────────── + +export const TaskOutputInputSchema = z.object({ + task_id: z.string().describe('The background task ID to inspect.'), + block: z + .boolean() + .default(false) + .describe('Whether to wait for the task to finish before returning.') + .optional(), + timeout: z + .number() + .int() + .min(0) + .max(3600) + .default(30) + .describe('Maximum number of seconds to wait when block=true.') + .optional(), +}); + +export type TaskOutputInput = z.Infer; + +// ── Implementation ─────────────────────────────────────────────────── + +function retrievalStatus( + status: BackgroundTaskStatus, + block: boolean | undefined, +): 'success' | 'timeout' | 'not_ready' { + if (isBackgroundTaskTerminal(status)) return 'success'; + return block ? 'timeout' : 'not_ready'; +} + +export class TaskOutputTool implements BuiltinTool { + readonly name = 'TaskOutput' as const; + readonly description: string = TASK_OUTPUT_DESCRIPTION; + readonly parameters: Record = toInputJsonSchema(TaskOutputInputSchema); + + constructor(private readonly manager: BackgroundProcessManager) {} + + resolveExecution(args: TaskOutputInput): ToolExecution { + return { + description: `Reading output of task ${args.task_id}`, + execute: () => this.execute(args), + }; + } + + private async execute(args: TaskOutputInput): Promise { + await this.manager.settlePendingExits(); + const info = this.manager.getTask(args.task_id); + if (!info) { + return { isError: true, output: `Task not found: ${args.task_id}` }; + } + + if (args.block && !isBackgroundTaskTerminal(info.status)) { + await this.manager.wait(args.task_id, (args.timeout ?? 30) * 1000); + } + + // Re-fetch after potential wait. + const current = this.manager.getTask(args.task_id); + if (!current) { + return { isError: true, output: `Task not found: ${args.task_id}` }; + } + + // A single manager-owned snapshot drives the tail window and every + // reported metric below. Persisted logs remain authoritative when + // available; detached managers fall back to their live ring buffer. + const output = await this.manager.getOutputSnapshot(args.task_id, OUTPUT_PREVIEW_BYTES); + + const lines = [ + `retrieval_status: ${retrievalStatus(current.status, args.block)}`, + `task_id: ${current.taskId}`, + `status: ${current.status}`, + `description: ${current.description}`, + `command: ${current.command}`, + ]; + if (output.outputPath !== undefined) { + lines.push(`output_path: ${output.outputPath}`); + } + if (current.exitCode !== null) { + lines.push(`exit_code: ${String(current.exitCode)}`); + } + // Surface why a terminal task ended. `terminal_reason` is a categorical + // label; `timed_out` / `stop_reason` carry the concrete detail. + // - timed_out: an agent task aborted by its external deadline. + // - stopped: the task was explicitly cancelled via `TaskStop` + // (`stop_reason` is the reason text supplied there). + // A task that ended on its own (completed / failed / lost) emits none + // of these so the absence is itself meaningful. + if (current.timedOut === true) { + lines.push('timed_out: true', 'terminal_reason: timed_out'); + } else if (current.stopReason !== undefined) { + lines.push(`stop_reason: ${current.stopReason}`, 'terminal_reason: stopped'); + } + lines.push( + `output_size_bytes: ${String(output.outputSizeBytes)}`, + `output_preview_bytes: ${String(output.previewBytes)}`, + `output_truncated: ${String(output.truncated)}`, + ); + // The full, never-truncated log is readable from disk via the `Read` + // tool whenever it was persisted. Surface that guidance unconditionally + // — even when the preview already shows everything — so the model knows + // it can page the complete output. The hint text adapts to whether the + // preview was truncated. When no full log was persisted, say so instead. + if (output.fullOutputAvailable && output.outputPath !== undefined) { + lines.push('full_output_available: true', 'full_output_tool: Read'); + lines.push( + output.truncated + ? `full_output_hint: Only the last ${String(OUTPUT_PREVIEW_BYTES)} bytes are shown ` + + 'above. Use the Read tool with the output_path to page through the full log ' + + `(parameters: path, line_offset, n_lines; read about ${String(PAGING_HINT_LINES)} ` + + 'lines per page).' + : 'full_output_hint: The preview above is the complete output. Use the Read tool ' + + 'with the output_path if you need to re-read the full log later ' + + `(parameters: path, line_offset, n_lines; read about ${String(PAGING_HINT_LINES)} ` + + 'lines per page).', + ); + } else { + lines.push('full_output_available: false'); + } + + // When the preview omits the head of the log, emit an explicit + // banner just before the `[output]` marker so the model knows it is + // looking at a tail, not the full output. + lines.push(''); + if (output.truncated) { + lines.push( + output.fullOutputAvailable && output.outputPath !== undefined + ? `[Truncated. Full output: ${output.outputPath}]` + : '[Truncated. No persisted full log is available for this task.]', + ); + } + lines.push('[output]', output.preview || '[no output available]'); + + // Side-channel brief for the host UI / log readers. Distinct from + // the `output` body which is parsed by the LLM. Kept short so log + // readers can render it as a one-liner. + return { + output: lines.join('\n'), + isError: false, + message: 'Task snapshot retrieved.', + }; + } + +} diff --git a/packages/agent-core/src/tools/background/task-stop.md b/packages/agent-core/src/tools/background/task-stop.md new file mode 100644 index 000000000..c1ff20a01 --- /dev/null +++ b/packages/agent-core/src/tools/background/task-stop.md @@ -0,0 +1,13 @@ +Stop a running background task. + +Only use this when a task must genuinely be cancelled — for a task that is +finishing normally, wait for its completion notification or inspect it with +`TaskOutput` instead of stopping it. + +Guidelines: +- This is a general-purpose stop capability for any background task. It is not + a bash-specific kill. +- Stopping a task is destructive: it may leave partial side effects behind. + Use it with care. +- If the task has already finished, this tool simply returns its current + status. diff --git a/packages/agent-core/src/tools/background/task-stop.ts b/packages/agent-core/src/tools/background/task-stop.ts new file mode 100644 index 000000000..20b93fa72 --- /dev/null +++ b/packages/agent-core/src/tools/background/task-stop.ts @@ -0,0 +1,87 @@ +/** + * TaskStopTool — stop a running background task. + */ + +import { z } from 'zod'; + +import type { BuiltinTool } from '../../agent/tool'; +import type { ToolExecution } from '../../loop/types'; +import { toInputJsonSchema } from '../support/input-schema'; +import { isBackgroundTaskTerminal, type BackgroundProcessManager } from './manager'; +import TASK_STOP_DESCRIPTION from './task-stop.md'; + +// ── Input schema ───────────────────────────────────────────────────── + +export const TaskStopInputSchema = z.object({ + task_id: z.string().describe('The background task ID to stop.'), + reason: z + .string() + .default('Stopped by TaskStop') + .describe('Short reason recorded when the task is stopped.') + .optional(), +}); + +export type TaskStopInput = z.Infer; + +// ── Implementation ─────────────────────────────────────────────────── + +export class TaskStopTool implements BuiltinTool { + readonly name = 'TaskStop' as const; + readonly description = TASK_STOP_DESCRIPTION; + readonly parameters: Record = toInputJsonSchema(TaskStopInputSchema); + + constructor(private readonly manager: BackgroundProcessManager) {} + + resolveExecution(args: TaskStopInput): ToolExecution { + return { + description: `Stopping task ${args.task_id}`, + execute: async () => { + await this.manager.settlePendingExits(); + const info = this.manager.getTask(args.task_id); + if (!info) { + return { isError: true, output: `Task not found: ${args.task_id}` }; + } + + // A blank or whitespace-only reason falls back to the default. `?? default` + // would not cover the empty-string case, so trim and coalesce explicitly. + const trimmedReason = args.reason?.trim(); + const reason = + trimmedReason === undefined || trimmedReason.length === 0 + ? 'Stopped by TaskStop' + : trimmedReason; + + if (isBackgroundTaskTerminal(info.status)) { + // Already-terminal tasks report their current state using the same + // structured multi-line format as the normal stop path below. + return { + output: + `task_id: ${info.taskId}\n` + + `status: ${info.status}\n` + + // A task persisted by an older build may carry a blank stopReason; + // `??` would not coalesce `''`, so trim-and-`||` to the placeholder. + `reason: ${terminalStopReason(info.stopReason)}`, + isError: false, + }; + } + + const result = await this.manager.stop(args.task_id, reason); + if (!result) { + return { isError: true, output: `Failed to stop task: ${args.task_id}` }; + } + + return { + output: + `task_id: ${result.taskId}\n` + + `status: ${result.status}\n` + + `reason: ${result.stopReason ?? reason}`, + isError: false, + }; + }, + }; + } +} + +function terminalStopReason(reason: string | undefined): string { + const trimmed = reason?.trim(); + return trimmed === undefined || trimmed.length === 0 ? 'Task already in terminal state' : trimmed; +} diff --git a/packages/agent-core/src/tools/builtin/collaboration/agent-background-disabled.md b/packages/agent-core/src/tools/builtin/collaboration/agent-background-disabled.md new file mode 100644 index 000000000..d44efcddc --- /dev/null +++ b/packages/agent-core/src/tools/builtin/collaboration/agent-background-disabled.md @@ -0,0 +1 @@ +Background agent execution is disabled for this agent. Do not set `run_in_background=true`. \ No newline at end of file diff --git a/packages/agent-core/src/tools/builtin/collaboration/agent-background-enabled.md b/packages/agent-core/src/tools/builtin/collaboration/agent-background-enabled.md new file mode 100644 index 000000000..0732a8cc3 --- /dev/null +++ b/packages/agent-core/src/tools/builtin/collaboration/agent-background-enabled.md @@ -0,0 +1,3 @@ +When `run_in_background=true`, the subagent runs detached from this turn. The completion arrives in a later turn as a synthetic user-role message containing its result — you do not need to poll, sleep, or check on its progress. Continue with other work or respond to the user. Never fabricate or predict what the result will say. + +For a background task, when `timeout` is omitted it falls back to the operator-configured background timeout, if one is set. If the operator has not configured a background timeout, an omitted `timeout` means the task runs with no time limit. diff --git a/packages/agent-core/src/tools/builtin/collaboration/agent.md b/packages/agent-core/src/tools/builtin/collaboration/agent.md new file mode 100644 index 000000000..0c6bae3bf --- /dev/null +++ b/packages/agent-core/src/tools/builtin/collaboration/agent.md @@ -0,0 +1,15 @@ +Launch a subagent to handle a task. The subagent runs as a same-process loop instance with its own context and wire file. + +Writing the prompt: +- The subagent starts with zero context — it has not seen this conversation. Brief it like a colleague who just walked into the room: state the goal, list what you already know, hand over the specifics. +- Lookups (read this file, run that test): put the exact path or command in the prompt. The subagent should not have to search for things you already know. +- Investigations (figure out X, find why Y): give the question, not prescribed steps — fixed steps become dead weight when the premise is wrong. +- Do not delegate understanding. If the task hinges on a file path or line number, find it yourself first and write it into the prompt. + +Usage notes: +- When the task continues earlier work a subagent already did, prefer resuming that agent (pass its `resume` id) over spawning a fresh instance — the resumed agent keeps its prior context. +- A subagent's result is only visible to you, not to the user. When the user needs to see what a subagent produced, summarize the relevant parts yourself in your own reply. + +When NOT to use Agent: skip delegation for trivial work you can do directly — reading a file whose path you already know, searching a small known set of files, or any task that takes only a step or two. Delegation has a context-handoff cost; it pays off only when the task is substantial enough to outweigh it. + +Once a subagent is running, leave that scope to it: do not redo its searches or reads in parallel, and do not abandon it midway and finish the job manually. Both undo the context savings the delegation was meant to buy. \ No newline at end of file diff --git a/packages/agent-core/src/tools/builtin/collaboration/agent.ts b/packages/agent-core/src/tools/builtin/collaboration/agent.ts new file mode 100644 index 000000000..062a1ff00 --- /dev/null +++ b/packages/agent-core/src/tools/builtin/collaboration/agent.ts @@ -0,0 +1,334 @@ +/** + * AgentTool — collaboration tool for spawning task subagents. + * + * Unlike the built-in tools (Read/Write/Edit/Bash/Grep/Glob), this is a + * "collaboration tool". It uses `SessionSubagentHost` (injected via the + * constructor rather than through the Runtime) to create in-process subagent + * loop instances. + * + * Two modes: + * - **Foreground** (default): blocks the parent turn, `await handle.completion` + * - **Background**: returns the agent id immediately; the result is delivered + * via a notification. + * + * `ToolResult.content` is textual; the structured output exposed by + * `AgentToolOutputSchema` is only used for drift-guard and is not consumed at + * runtime. + */ + +import { z } from 'zod'; + +import type { BuiltinTool } from '../../../agent/tool'; +import type { Logger } from '../../../logging'; +import { ToolAccesses } from '../../../loop/tool-access'; +import type { ExecutableToolContext, ExecutableToolResult, ToolExecution } from '../../../loop/types'; +import type { ResolvedAgentProfile } from '../../../profile'; +import type { SessionSubagentHost, SubagentHandle } from '../../../session/subagent-host'; +import { createDeadlineAbortSignal, type DeadlineAbortSignal } from '../../../utils/abort'; +import type { BackgroundProcessManager } from '../../background/manager'; +import { toInputJsonSchema } from '../../support/input-schema'; +import AGENT_BACKGROUND_DISABLED_DESCRIPTION from './agent-background-disabled.md'; +import AGENT_BACKGROUND_DESCRIPTION from './agent-background-enabled.md'; +import AGENT_DESCRIPTION_BASE from './agent.md'; + +// ── AgentTool input ────────────────────────────────────────────────── + +export const AgentToolInputSchema = z.preprocess( + (input) => { + if (typeof input !== 'object' || input === null || Array.isArray(input)) { + return input; + } + const record = input as Record; + const normalized = { ...record }; + const hasResumeId = + typeof normalized['resume'] === 'string' && normalized['resume'].trim().length > 0; + const hasSubagentType = + typeof normalized['subagent_type'] === 'string' && normalized['subagent_type'].length > 0; + if (!hasSubagentType && !hasResumeId) { + normalized['subagent_type'] = 'coder'; + } else if (!hasSubagentType) { + delete normalized['subagent_type']; + } + return normalized; + }, + z.object({ + prompt: z.string().describe('Full task prompt for the subagent'), + description: z.string().describe('Short task description (3-5 words) for UI display'), + subagent_type: z + .string() + .optional() + .describe( + 'One of the available agent types (see "Available agent types" in this tool description). Defaults to "coder" when omitted.', + ), + resume: z + .string() + .optional() + .describe('Optional agent ID to resume instead of creating a new instance'), + run_in_background: z + .boolean() + .optional() + .describe( + 'If true, return immediately without waiting for completion. Prefer false unless the task can run independently and there is a clear benefit to not waiting.', + ), + timeout: z + .number() + .int() + .min(30) + .max(3600) + .optional() + .describe( + 'Timeout in seconds for the agent task (min 30s, max 3600s / 1hr). When omitted, a foreground task runs until completion with no timeout. The agent is stopped if it exceeds this limit.', + ), + }), +); + +export type AgentToolInput = z.infer; + +// ── AgentTool output ───────────────────────────────────────────────── + +export const AgentToolOutputSchema = z.object({ + result: z.string().describe('Aggregated text output from the subagent'), + usage: z + .object({ + input: z.number().int().nonnegative(), + output: z.number().int().nonnegative(), + cache_read: z.number().int().nonnegative().optional(), + cache_write: z.number().int().nonnegative().optional(), + }) + .describe('Cumulative token usage'), +}); + +export type AgentToolOutput = z.infer; + +const BACKGROUND_AGENT_UNAVAILABLE = + 'Background agent execution is not available for this agent because TaskList, TaskOutput, and TaskStop are not enabled.'; + +// ── AgentTool class ────────────────────────────────────────────────── + +export class AgentTool implements BuiltinTool { + readonly name: string = 'Agent'; + readonly description: string; + readonly parameters: Record = toInputJsonSchema(AgentToolInputSchema); + private readonly allowBackground: boolean; + + constructor( + private readonly subagentHost: SessionSubagentHost, + private readonly backgroundManager?: BackgroundProcessManager | undefined, + subagents?: ResolvedAgentProfile['subagents'] | undefined, + options?: { + allowBackground?: boolean; + log?: Logger; + }, + ) { + this.allowBackground = options?.allowBackground ?? this.backgroundManager !== undefined; + const log = options?.log; + const typeLines = buildSubagentDescriptions(subagents); + const baseDescription = `${AGENT_DESCRIPTION_BASE}\n\n${ + this.allowBackground ? AGENT_BACKGROUND_DESCRIPTION : AGENT_BACKGROUND_DISABLED_DESCRIPTION + }`; + this.description = typeLines + ? `${baseDescription}\n\nAvailable agent types (pass via subagent_type):\n${typeLines}` + : baseDescription; + this.log = log; + } + + private readonly log?: Logger; + + resolveExecution(args: AgentToolInput): ToolExecution { + let profileName = args.subagent_type?.length ? args.subagent_type : 'coder'; + const resumeAgentId = args.resume?.trim(); + if (resumeAgentId !== undefined && resumeAgentId.length > 0) { + profileName = this.subagentHost.getProfileName?.(resumeAgentId) ?? 'subagent'; + } + const prefix = args.run_in_background === true ? 'Launching background' : 'Launching'; + return { + description: `${prefix} ${profileName} agent: ${args.description}`, + accesses: ToolAccesses.none(), + execute: (ctx) => this.execution(args, ctx), + }; + } + + private async execution( + args: AgentToolInput, + { + toolCallId, + signal, + }: ExecutableToolContext, + ): Promise { + let foregroundDeadline: DeadlineAbortSignal | undefined; + try { + signal.throwIfAborted(); + const runInBackground = args.run_in_background === true; + const requestedProfileName = args.subagent_type?.length ? args.subagent_type : undefined; + const resumeAgentId = args.resume?.trim(); + if ( + resumeAgentId !== undefined && + resumeAgentId.length > 0 && + requestedProfileName !== undefined + ) { + return { + output: 'Cannot set subagent_type when resuming an existing agent. Resume by agent id only.', + isError: true, + }; + } + + let reservation: ReturnType | undefined; + let backgroundManager: BackgroundProcessManager | undefined; + if (runInBackground) { + const configuredBackgroundManager = this.backgroundManager; + if (!this.allowBackground || configuredBackgroundManager === undefined) { + return { + output: BACKGROUND_AGENT_UNAVAILABLE, + isError: true, + }; + } + try { + reservation = configuredBackgroundManager.reserveSlot(); + backgroundManager = configuredBackgroundManager; + } catch (error) { + return { + output: error instanceof Error ? error.message : String(error), + isError: true, + }; + } + } + const backgroundController = runInBackground ? new AbortController() : undefined; + const timeoutMs = args.timeout === undefined ? undefined : args.timeout * 1000; + foregroundDeadline = + !runInBackground && timeoutMs !== undefined + ? createDeadlineAbortSignal(signal, timeoutMs) + : undefined; + + const options = { + parentToolCallId: toolCallId, + prompt: args.prompt, + description: args.description, + runInBackground, + signal: backgroundController?.signal ?? foregroundDeadline?.signal ?? signal, + }; + + let handle: SubagentHandle; + const operation = resumeAgentId !== undefined && resumeAgentId.length > 0 ? 'resume' : 'spawn'; + try { + if (resumeAgentId !== undefined && resumeAgentId.length > 0) { + handle = await this.subagentHost.resume(resumeAgentId, options); + } else { + const profileName = requestedProfileName ?? 'coder'; + handle = await this.subagentHost.spawn(profileName, options); + } + } catch (error) { + reservation?.release(); + this.log?.warn('subagent launch failed', { + toolCallId, + runInBackground, + operation, + agentId: resumeAgentId, + subagentType: operation === 'spawn' ? requestedProfileName ?? 'coder' : undefined, + error, + }); + throw error; + } + + if (runInBackground) { + if (backgroundManager === undefined) { + reservation?.release(); + return { + output: BACKGROUND_AGENT_UNAVAILABLE, + isError: true, + }; + } + let taskId: string; + try { + taskId = backgroundManager.registerAgentTask(handle.completion, args.description, { + timeoutMs: timeoutMs ?? this.subagentHost.backgroundTaskTimeoutMs, + reservation, + agentId: handle.agentId, + subagentType: handle.profileName, + abort: () => { + backgroundController?.abort(); + }, + }); + } catch (error) { + reservation?.release(); + backgroundController?.abort(); + void handle.completion.catch(() => {}); + this.log?.warn('background agent task registration failed', { + toolCallId, + agentId: handle.agentId, + subagentType: handle.profileName, + error, + }); + return { + output: error instanceof Error ? error.message : String(error), + isError: true, + }; + } + const lines = [ + `task_id: ${taskId}`, + 'status: running', + `agent_id: ${handle.agentId}`, + `actual_subagent_type: ${handle.profileName}`, + 'automatic_notification: true', + '', + `description: ${args.description}`, + '', + `next_step: The completion arrives automatically in a later turn — no polling needed. To peek at progress without blocking, call TaskOutput(task_id="${taskId}", block=false).`, + `resume_hint: To continue this same subagent instance later, call Agent(resume="${handle.agentId}", prompt="...").`, + ]; + return { output: lines.join('\n') }; + } + + try { + const result = await handle.completion; + const lines = [ + `agent_id: ${handle.agentId}`, + `actual_subagent_type: ${handle.profileName}`, + 'status: completed', + '', + '[summary]', + result.result, + ]; + return { output: lines.join('\n') }; + } catch (error) { + const message = + foregroundDeadline?.timedOut() === true && args.timeout !== undefined + ? `Agent timed out after ${args.timeout}s.` + : error instanceof Error + ? error.message + : String(error); + const lines = [ + `agent_id: ${handle.agentId}`, + `actual_subagent_type: ${handle.profileName}`, + 'status: failed', + '', + `subagent error: ${message}`, + ]; + return { output: lines.join('\n'), isError: true }; + } + } catch (error) { + const message = + foregroundDeadline?.timedOut() === true && args.timeout !== undefined + ? `Agent timed out after ${args.timeout}s.` + : error instanceof Error + ? error.message + : String(error); + return { output: `subagent error: ${message}`, isError: true }; + } finally { + foregroundDeadline?.clear(); + } + } +} + +function buildSubagentDescriptions(subagents: ResolvedAgentProfile['subagents']): string { + if (subagents === undefined) return ''; + return Object.entries(subagents) + .map(([name, subagent]) => { + const details = [subagent.description, subagent.whenToUse].filter( + (part): part is string => part !== undefined && part.length > 0, + ); + const header = details.length === 0 ? `- ${name}` : `- ${name}: ${details.join(' ')}`; + if (subagent.tools.length === 0) return header; + return `${header}\n Tools: ${subagent.tools.join(', ')}`; + }) + .join('\n'); +} diff --git a/packages/agent-core/src/tools/builtin/collaboration/ask-user.md b/packages/agent-core/src/tools/builtin/collaboration/ask-user.md new file mode 100644 index 000000000..597cd8eb9 --- /dev/null +++ b/packages/agent-core/src/tools/builtin/collaboration/ask-user.md @@ -0,0 +1,19 @@ +Use this tool when you need to ask the user questions with structured options during execution. This allows you to: +1. Collect user preferences or requirements before proceeding +2. Resolve ambiguous or underspecified instructions +3. Let the user decide between implementation approaches as you work +4. Present concrete options when multiple valid directions exist + +**When NOT to use:** +- When you can infer the answer from context — be decisive and proceed +- Trivial decisions that don't materially affect the outcome + +Overusing this tool interrupts the user's flow. Only use it when the user's input genuinely changes your next action. + +**Usage notes:** +- Users always have an "Other" option for custom input — don't create one yourself +- Use multi_select to allow multiple answers to be selected for a question +- Keep option labels concise (1-5 words), use descriptions for trade-offs and details +- Each question should have 2-4 meaningful, distinct options +- You can ask 1-4 questions at a time; group related questions to minimize interruptions +- If you recommend a specific option, list it first and append "(Recommended)" to its label \ No newline at end of file diff --git a/packages/agent-core/src/tools/builtin/collaboration/ask-user.ts b/packages/agent-core/src/tools/builtin/collaboration/ask-user.ts new file mode 100644 index 000000000..a1dbf6179 --- /dev/null +++ b/packages/agent-core/src/tools/builtin/collaboration/ask-user.ts @@ -0,0 +1,187 @@ +/** + * AskUserQuestionTool — structured user question tool. + * + * The LLM calls this tool when it needs structured input from the user + * (multiple-choice, preference selection, disambiguation). The tool + * delegates to the SDK reverse-RPC question handler, which owns the + * actual UI interaction. + * + * Permission policy decides whether this tool is available for the + * current mode. Once executed, it dispatches through `requestQuestion` + * and awaits the user's answer. + */ + +import { z } from 'zod'; + +import type { Agent } from '../../../agent'; +import type { BuiltinTool } from '../../../agent/tool'; +import { ErrorCodes, KimiError } from '../../../errors'; +import { isAbortError } from '../../../loop/errors'; +import type { ExecutableToolContext, ExecutableToolResult, ToolExecution } from '../../../loop/types'; +import type { + QuestionAnswers, + QuestionAnswerMethod, + QuestionResponse, + QuestionResult, +} from '../../../rpc'; +import type { TelemetryPropertyValue } from '../../../telemetry'; +import { toInputJsonSchema } from '../../support/input-schema'; +import DESCRIPTION from './ask-user.md'; + +// ── Input schema ───────────────────────────────────────────────────── + +const QuestionOptionSchema = z.object({ + label: z + .string() + .describe("Concise display text (1-5 words). If recommended, append '(Recommended)'."), + description: z.string().default('').describe('Brief explanation of trade-offs or implications.'), +}); + +const QuestionItemSchema = z.object({ + question: z.string().describe("A specific, actionable question. End with '?'."), + header: z + .string() + .default('') + .describe("Short category tag (max 12 chars, e.g. 'Auth', 'Style')."), + options: z + .array(QuestionOptionSchema) + .min(2) + .max(4) + .describe( + "2-4 meaningful, distinct options. Do NOT include an 'Other' option — the system adds one automatically.", + ), + multi_select: z + .boolean() + .default(false) + .describe('Whether the user can select multiple options.'), +}); + +export interface AskUserQuestionInput { + questions: Array<{ + question: string; + header: string; + options: Array<{ label: string; description: string }>; + multi_select: boolean; + }>; +} + +export const AskUserQuestionInputSchema: z.ZodType = z.object({ + questions: z + .array(QuestionItemSchema) + .min(1) + .max(4) + .describe('The questions to ask the user (1-4 questions).'), +}); + +const QUESTION_DISMISSED_MESSAGE = 'User dismissed the question without answering.'; + +const QUESTION_UNSUPPORTED_FAILURE_MESSAGE = + 'The connected client does not support interactive questions. Do NOT call this tool again. Ask the user directly in your text response instead.'; + +// ── Implementation ─────────────────────────────────────────────────── + +export class AskUserQuestionTool implements BuiltinTool { + readonly name = 'AskUserQuestion' as const; + readonly description: string = DESCRIPTION; + readonly parameters: Record = toInputJsonSchema(AskUserQuestionInputSchema); + + constructor(private readonly agent: Agent) {} + + resolveExecution(args: AskUserQuestionInput): ToolExecution { + return { + description: 'Asking user questions', + execute: (ctx) => this.execution(args, ctx), + }; + } + + private async execution( + args: AskUserQuestionInput, + { + toolCallId, + signal, + turnId, + }: ExecutableToolContext, + ): Promise { + try { + const result = await this.agent.rpc.requestQuestion( + { + turnId: numericTurnId(turnId), + toolCallId, + questions: args.questions.map((q) => ({ + question: q.question, + header: q.header, + options: q.options.map((o) => ({ + label: o.label, + description: o.description, + })), + multiSelect: q.multi_select, + })), + }, + { signal }, + ); + + const normalized = normalizeQuestionResult(result); + if (normalized === null || Object.keys(normalized.answers).length === 0) { + this.agent.telemetry.track('question_dismissed'); + return dismissedQuestionResult(); + } + + const properties: Record = { + answered: Object.keys(normalized.answers).length, + }; + if (normalized.method !== undefined) properties['method'] = normalized.method; + this.agent.telemetry.track('question_answered', properties); + return { + isError: false, + output: JSON.stringify({ answers: normalized.answers }), + }; + } catch (error) { + if (isAbortError(error) || signal.aborted) throw error; + + if (error instanceof KimiError && error.code === ErrorCodes.NOT_IMPLEMENTED) { + return { + isError: true, + output: QUESTION_UNSUPPORTED_FAILURE_MESSAGE, + }; + } + + return dismissedQuestionResult(); + } + } +} + +function dismissedQuestionResult(): ExecutableToolResult { + return { + isError: false, + output: JSON.stringify({ + answers: {}, + note: QUESTION_DISMISSED_MESSAGE, + }), + }; +} + +function numericTurnId(turnId: string): number | undefined { + if (turnId.trim().length === 0) return undefined; + const parsed = Number(turnId); + return Number.isFinite(parsed) ? parsed : undefined; +} + +function normalizeQuestionResult( + result: QuestionResult, +): { readonly answers: QuestionAnswers; readonly method?: QuestionAnswerMethod | undefined } | null { + if (result === null) return null; + if (isQuestionResponse(result)) { + return { + answers: result.answers, + method: result.method, + }; + } + return { answers: result }; +} + +function isQuestionResponse(result: Exclude): result is QuestionResponse { + if (typeof result !== 'object' || result === null) return false; + if (!Object.hasOwn(result, 'answers')) return false; + const answers = (result as { readonly answers?: unknown }).answers; + return typeof answers === 'object' && answers !== null && !Array.isArray(answers); +} diff --git a/packages/agent-core/src/tools/builtin/collaboration/skill-tool.md b/packages/agent-core/src/tools/builtin/collaboration/skill-tool.md new file mode 100644 index 000000000..bc67f43f5 --- /dev/null +++ b/packages/agent-core/src/tools/builtin/collaboration/skill-tool.md @@ -0,0 +1 @@ +Invoke a registered skill from the current skill listing. BLOCKING REQUIREMENT: when a skill from the listing matches the user's request, you MUST call this tool (not free-form text). Do NOT call the same skill repeatedly inside one turn — recursive depth is capped at {{ MAX_SKILL_QUERY_DEPTH }}. \ No newline at end of file diff --git a/packages/agent-core/src/tools/builtin/collaboration/skill-tool.ts b/packages/agent-core/src/tools/builtin/collaboration/skill-tool.ts new file mode 100644 index 000000000..1e8ec8d96 --- /dev/null +++ b/packages/agent-core/src/tools/builtin/collaboration/skill-tool.ts @@ -0,0 +1,168 @@ +/** + * SkillTool — invoke a registered skill. + * + * Collaboration tool that lets the LLM proactively invoke an inline + * registered skill. Inline skills record their activation through the + * owning agent; non-inline skill types are intentionally not model-invocable + * in the v1 default runtime. + * + * Anti-loop: `MAX_SKILL_QUERY_DEPTH` caps Skill→Skill recursion so a + * skill that re-invokes itself (or chains into another) cannot recurse + * without bound. + */ + +import { randomUUID } from 'node:crypto'; + +import { z } from 'zod'; + +import type { Agent } from '../../../agent'; +import type { SkillActivationOrigin } from '../../../agent/context'; +import type { BuiltinTool } from '../../../agent/tool'; +import type { ExecutableToolResult, ToolExecution } from '../../../loop/types'; +import { isInlineSkillType, type SkillDefinition } from '../../../skill'; +import { renderPrompt } from '../../../utils/render-prompt'; +import { toInputJsonSchema } from '../../support/input-schema'; +import skillDescriptionTemplate from './skill-tool.md'; + +export const MAX_SKILL_QUERY_DEPTH = 3; + +export class NestedSkillTooDeepError extends Error { + readonly skillName?: string; + readonly depth: number; + + constructor(depth: number, skillName?: string) { + const label = skillName !== undefined ? ` "${skillName}"` : ''; + super( + `Nested skill invocation${label} exceeded the maximum depth of ${String(depth)} — refusing to recurse further.`, + ); + this.name = 'NestedSkillTooDeepError'; + this.depth = depth; + if (skillName !== undefined) this.skillName = skillName; + } +} + +export interface SkillToolInput { + skill: string; + args?: string; +} + +export const SkillToolInputSchema: z.ZodType = z.object({ + skill: z.string(), + args: z.string().optional(), +}); + +export interface SkillToolOptions { + /** + * Current inline skill recursion depth. + */ + readonly queryDepth?: number; + /** + * Alias for `queryDepth`. Kept so older call sites can seed the + * inline recursion depth without knowing the internal field name. + */ + readonly initialQueryDepth?: number; +} + +export class SkillTool implements BuiltinTool { + readonly name = 'Skill'; + readonly description: string = renderPrompt(skillDescriptionTemplate, { + MAX_SKILL_QUERY_DEPTH, + }); + readonly parameters: Record = toInputJsonSchema(SkillToolInputSchema); + + constructor( + private readonly agent: Agent, + private readonly options: SkillToolOptions = {}, + ) {} + + resolveExecution(args: SkillToolInput): ToolExecution { + return { + description: `Invoke skill ${args.skill}`, + execute: () => this.execution(args), + }; + } + + withInitialQueryDepth(initialQueryDepth: number): SkillTool { + return new SkillTool(this.agent, { + ...this.options, + initialQueryDepth, + }); + } + + private async execution(args: SkillToolInput): Promise { + // Recursion hard cap. Once `currentDepth` has reached + // MAX_SKILL_QUERY_DEPTH, firing another Skill call would push the + // child to depth+1 which violates the invariant. Throw a structured + // error (rather than a soft tool-error) so Runtime can distinguish + // "LLM mis-dispatched" from "safety net fired". + const currentDepth = this.options.initialQueryDepth ?? this.options.queryDepth ?? 0; + if (currentDepth >= MAX_SKILL_QUERY_DEPTH) { + throw new NestedSkillTooDeepError(MAX_SKILL_QUERY_DEPTH, args.skill); + } + + const skills = this.agent.skills; + if (skills === undefined) { + return errorResult(`Skill "${args.skill}" not found in the current skill listing.`); + } + const skill = skills.registry.getSkill(args.skill); + if (skill === undefined) { + return errorResult(`Skill "${args.skill}" not found in the current skill listing.`); + } + if (skill.metadata.disableModelInvocation === true) { + // Keep the exact wording "can only be triggered by the user" so + // contract audits and integration tests stay deterministic. + return errorResult( + `Skill "${args.skill}" can only be triggered by the user (model invocation is disabled).`, + ); + } + + const skillArgs = args.args ?? ''; + if (!isInlineSkillType(skill.metadata.type)) { + return errorResult( + `Skill "${skill.name}" is not an inline skill and cannot be invoked by the model in v1.`, + ); + } + + const origin = skillOrigin(skill, skillArgs, currentDepth); + skills.recordActivation(origin); + const skillContent = skills.registry.renderSkillPrompt(skill, skillArgs); + this.agent.context.appendSystemReminder( + `\n` + + `${skillContent}\n` + + ``, + origin, + ); + return { + output: `Skill "${skill.name}" loaded inline. Follow its instructions.`, + }; + } +} + +function errorResult(message: string): ExecutableToolResult { + return { isError: true, output: message }; +} + +function skillOrigin( + skill: SkillDefinition, + skillArgs: string, + currentDepth: number, +): SkillActivationOrigin { + return { + kind: 'skill_activation', + activationId: randomUUID(), + skillName: skill.name, + skillArgs: skillArgs.length > 0 ? skillArgs : undefined, + trigger: currentDepth > 0 ? 'nested-skill' : 'model-tool', + skillType: skill.metadata.type, + skillPath: skill.path, + skillSource: skill.source, + }; +} + +function escapeXml(input: string): string { + return input + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"'); +} diff --git a/packages/agent-core/src/tools/builtin/file/edit.md b/packages/agent-core/src/tools/builtin/file/edit.md new file mode 100644 index 000000000..f5de2c551 --- /dev/null +++ b/packages/agent-core/src/tools/builtin/file/edit.md @@ -0,0 +1,10 @@ +Perform exact string replacements against the text view returned by Read. + +- When copying from Read output, omit the line-number prefix and tab; match only the file content. +- By default, old_string must occur exactly once. If it matches multiple locations, add surrounding context or set replace_all when every occurrence should change. +- Prefer Edit for targeted changes to existing files; use Write only for new files or complete overwrites. +- To modify a file, always use Edit; do not run a Shell `sed` command for edits. +- When making several independent changes, issue multiple Edit calls in parallel within a single response; edits to the same file are serialized automatically by a write lock. +- When several parallel Edit calls target the same file, a write lock serializes them; they apply in the order the calls appear in your response. An edit fails with `old_string not found` if its old_string was taken from text an earlier edit already replaced — base every old_string on the latest Read view and order dependent edits accordingly. +- For pure CRLF files, Read shows LF and Edit.old_string/new_string should use LF; Edit writes the file back with CRLF preserved. +- For mixed line endings or lone carriage returns, Read displays carriage returns as \r; include actual \r escapes in old_string/new_string for those positions. \ No newline at end of file diff --git a/packages/agent-core/src/tools/builtin/file/edit.ts b/packages/agent-core/src/tools/builtin/file/edit.ts new file mode 100644 index 000000000..de14c3456 --- /dev/null +++ b/packages/agent-core/src/tools/builtin/file/edit.ts @@ -0,0 +1,149 @@ +/** + * EditTool — exact string replacement in a file. + * + * Replaces the first occurrence of `old_string` with `new_string` by + * default. When `replace_all` is true, replaces all occurrences. + * Errors when `old_string` is not found or not unique (when + * `replace_all=false`). Path access policy is resolved before any + * Kaos I/O. + */ + +import type { Kaos } from '@moonshot-ai/kaos'; +import { z } from 'zod'; + +import type { BuiltinTool } from '../../../agent/tool'; +import { ToolAccesses } from '../../../loop/tool-access'; +import type { ExecutableToolResult, ToolExecution } from '../../../loop/types'; +import { resolvePathAccessPath } from '../../policies/path-access'; +import { toInputJsonSchema } from '../../support/input-schema'; +import type { WorkspaceConfig } from '../../support/workspace'; +import { materializeModelText, toModelTextView } from './line-endings'; +import EDIT_DESCRIPTION from './edit.md'; + +// `old_string` must be non-empty: the non-replace_all branch walks +// occurrences with `content.indexOf("", pos)`, which would loop forever +// on an empty search string. +export const EditInputSchema = z.object({ + path: z + .string() + .describe( + 'Path to the text file to edit. Relative paths resolve against the working directory; a path outside the working directory must be absolute.', + ), + old_string: z + .string() + .min(1) + .describe( + 'Exact content to replace from the Read output view, without the line-number prefix. Use LF for pure CRLF files; use actual \\r escapes where Read shows \\r.', + ), + new_string: z + .string() + .describe( + 'Replacement text in the same Read output view. LF is written back as CRLF only for pure CRLF files.', + ), + replace_all: z + .boolean() + .optional() + .describe('Set true only when every occurrence of old_string should be replaced.'), +}); + +export type EditInput = z.Infer; + +function replaceOnceLiteral(content: string, oldString: string, newString: string): string { + const index = content.indexOf(oldString); + if (index === -1) return content; + return content.slice(0, index) + newString + content.slice(index + oldString.length); +} + +export class EditTool implements BuiltinTool { + readonly name = 'Edit' as const; + readonly description = EDIT_DESCRIPTION; + readonly parameters: Record = toInputJsonSchema(EditInputSchema); + + constructor( + private readonly kaos: Kaos, + private readonly workspace: WorkspaceConfig, + ) {} + + resolveExecution(args: EditInput): ToolExecution { + const path = resolvePathAccessPath(args.path, { + kaos: this.kaos, + workspace: this.workspace, + operation: 'write', + }); + return { + accesses: ToolAccesses.readWriteFile(path), + description: `Editing ${args.path}`, + execute: () => this.execution(args, path), + }; + } + + private async execution(args: EditInput, safePath: string): Promise { + if (args.old_string === args.new_string) { + return { + isError: true, + output: 'No changes to make: old_string and new_string are exactly the same.', + }; + } + + try { + const raw = await this.kaos.readText(safePath); + const modelView = toModelTextView(raw); + const content = modelView.text; + const replaceAll = args.replace_all ?? false; + + if (!replaceAll) { + let count = 0; + let pos = 0; + while (pos < content.length) { + const idx = content.indexOf(args.old_string, pos); + if (idx === -1) break; + count++; + pos = idx + args.old_string.length; + } + + if (count === 0) { + return { isError: true, output: `old_string not found in ${args.path}, The file contents may be out of date. Please use the Read Tool to reload the content. +` }; + } + if (count > 1) { + return { + isError: true, + output: + `old_string is not unique in ${args.path} (found ${String(count)} occurrences). ` + + 'To replace every occurrence, set replace_all=true. To replace only one occurrence, include more surrounding context in old_string.', + }; + } + + const newContent = replaceOnceLiteral(content, args.old_string, args.new_string); + await this.kaos.writeText( + safePath, + materializeModelText(newContent, modelView.lineEndingStyle), + ); + return { output: `Replaced 1 occurrence in ${args.path}` }; + } + + const parts = content.split(args.old_string); + const replacementCount = parts.length - 1; + if (replacementCount === 0) { + return { isError: true, output: `old_string not found in ${args.path}, The file contents may be out of date. Please use the Read Tool to reload the content. +` }; + } + + const newContent = parts.join(args.new_string); + await this.kaos.writeText( + safePath, + materializeModelText(newContent, modelView.lineEndingStyle), + ); + return { output: `Replaced ${String(replacementCount)} occurrences in ${args.path}` }; + } catch (error) { + const code = (error as { code?: unknown } | null)?.code; + if (code === 'EISDIR') { + return { isError: true, output: `${args.path} is not a file.` }; + } + return { + isError: true, + output: error instanceof Error ? error.message : String(error), + }; + } + } +} diff --git a/packages/agent-core/src/tools/builtin/file/glob.md b/packages/agent-core/src/tools/builtin/file/glob.md new file mode 100644 index 000000000..f26cc8a05 --- /dev/null +++ b/packages/agent-core/src/tools/builtin/file/glob.md @@ -0,0 +1,16 @@ +Find files (and optionally directories) by glob pattern, sorted by modification time (most recent first). + +Good patterns: +- `*.ts` — files in the current directory matching an extension +- `src/**/*.ts` — recursive with a subdirectory anchor and extension +- `test_*.py` — files whose name starts with a literal prefix + +Rejected patterns (no literal anchor — nothing bounds the result set): +- `**`, `**/*`, `*/*` — pure wildcards. Add an extension or subdirectory to give the walk a concrete target. +- Anything that starts with `**/` (e.g. `**/*.md`, `**/main/*.py`). The leading `**/` has no literal anchor in front of it. Anchor it with a top-level subdirectory like `src/**/*.md`. +- `*.{ts,tsx}` — brace expansion is not supported. Issue two calls: `*.ts` and `*.tsx`. + +Large-directory warning — avoid recursing into dependency/build output even with an anchor: +- `node_modules/**/*.js`, `.venv/**/*.py`, `__pycache__/**`, `target/**` all match technically but + typically produce thousands of results that truncate at the match cap and waste the caller context. + Prefer specific subpaths like `node_modules/react/src/**/*.js`. \ No newline at end of file diff --git a/packages/agent-core/src/tools/builtin/file/glob.ts b/packages/agent-core/src/tools/builtin/file/glob.ts new file mode 100644 index 000000000..d58e37c5c --- /dev/null +++ b/packages/agent-core/src/tools/builtin/file/glob.ts @@ -0,0 +1,376 @@ +/** + * GlobTool — file pattern matching. + * + * Finds files matching a glob pattern, returned sorted by modification + * time (most recent first). Uses `kaos.glob`. + * + * Output convention: `content` shown to the LLM is relativized to the + * search base to save tokens; `output.paths` keeps absolute paths so + * downstream Read/Edit can consume them directly. + * + * Safety rails: + * - Pure-wildcard patterns (nothing but `*` / `?` / `/`) are rejected + * because they have no literal anchor — they would enumerate every + * file under the search root and exhaust the caller's context on + * large trees. Examples: `**`, `** / *`, `** / **`, `* / *`. + * Constrained patterns (with any literal anchor such as an extension + * or subdirectory) are allowed — the literal bounds the result set. + * - Patterns using brace expansion (`{a,b,c}`) are rejected up-front + * because the underlying `_globWalk` treats `{` / `}` as literals, + * so such patterns would silently match zero files. + * - `path` is validated by `resolvePathAccess` in strict mode. Explicit + * paths must be absolute and within the workspace roots. + * - match count is capped at `MAX_MATCHES`; a separate `YIELD_SAFETY_CAP` + * (MAX_MATCHES × 2) on the raw yield stream is a secondary belt that + * still terminates the stream if the kaos layer's own symlink-cycle + * detection were ever absent or bypassed. Primary cycle defense lives + * in `packages/kaos/src/local.ts:_globWalk` via a path-local visited + * inode set. + */ + +import type { Kaos } from '@moonshot-ai/kaos'; +import { z } from 'zod'; + +import type { BuiltinTool } from '../../../agent/tool'; +import { ToolAccesses } from '../../../loop/tool-access'; +import type { ExecutableToolResult, ToolExecution } from '../../../loop/types'; +import { resolvePathAccessPath } from '../../policies/path-access'; +import type { PathClass } from '../../policies/path-access'; +import { toInputJsonSchema } from '../../support/input-schema'; +import { listDirectory } from '../../support/list-directory'; +import type { WorkspaceConfig } from '../../support/workspace'; +import GLOB_DESCRIPTION from './glob.md'; + +export const GlobInputSchema = z.object({ + pattern: z.string().describe('Glob pattern to match files/directories.'), + path: z + .string() + .optional() + .describe( + 'Absolute path to the directory to search in. Defaults to the current working directory.', + ), + include_dirs: z + .boolean() + .default(true) + .optional() + .describe( + 'Whether to include directories in results. Defaults to true. Set false to return only files.', + ), +}); + +export type GlobInput = z.Infer; + +export const MAX_MATCHES = 1000; + +/** + * Path-shape hint appended to the tool description only on a Windows + * (`win32` path class) backend. The `path` argument accepts both native + * Windows paths and POSIX-style paths, but matched paths come back in + * Windows backslash form — a command run through Bash must convert them + * to forward slashes first. Injected conditionally so non-Windows + * sessions are not shown a hint that does not apply to them. + */ +export const WINDOWS_PATH_HINT = + '\n\nWindows note: the `path` argument accepts both Windows paths ' + + '(e.g. `C:\\Users\\foo`) and POSIX-style paths (e.g. `/c/Users/foo`). Matched paths are ' + + 'returned in Windows backslash form; convert them to forward slashes before ' + + 'using them in a Bash command.'; + +// POSIX mode bits — same constants used by KaosPath.isDir (packages/kaos/src/path.ts:199). +const S_IFMT = 0o170000; +const S_IFDIR = 0o040000; + +/** + * Tool-level description shown to the LLM at tool declaration time. + * Tells the model — before any round-trip — which patterns are + * accepted, which are rejected, and which directories are too large to + * recurse into. Patterns with a literal anchor before a double-star are + * allowed; pure-wildcard patterns (a bare double-star or a double-star + * followed by `/`) are rejected outright. On a Windows backend + * the description also carries `WINDOWS_PATH_HINT` (path-shape guidance). + */ +export class GlobTool implements BuiltinTool { + readonly name = 'Glob' as const; + readonly description: string; + readonly parameters: Record = toInputJsonSchema(GlobInputSchema); + constructor( + private readonly kaos: Kaos, + private readonly workspace: WorkspaceConfig, + ) { + this.description = + this.kaos.pathClass() === 'win32' + ? GLOB_DESCRIPTION + WINDOWS_PATH_HINT + : GLOB_DESCRIPTION; + } + + resolveExecution(args: GlobInput): ToolExecution { + let path: string | undefined; + if (args.path !== undefined) { + path = resolvePathAccessPath(args.path, { + kaos: this.kaos, + workspace: this.workspace, + operation: 'search', + policy: { guardMode: 'strict', checkSensitive: false }, + }); + } + const searchRoots = [path ?? this.workspace.workspaceDir]; + return { + accesses: ToolAccesses.searchTree(searchRoots[0]!), + description: `Searching ${args.pattern}`, + execute: () => this.execution(args, searchRoots), + }; + } + + private async execution(args: GlobInput, searchRoots: string[]): Promise { + if (startsWithDoubleStarPrefix(args.pattern)) { + let tree: string; + try { + tree = await listDirectory(this.kaos, this.workspace.workspaceDir); + } catch { + tree = '(listing unavailable)'; + } + return { + isError: true, + output: + `Pattern "${args.pattern}" starts with '**' which is not allowed — ` + + `the leading '**/' has no literal anchor in front of it and would ` + + `enumerate every file under the search root, typically exhausting ` + + `the caller's context on large trees. Use more specific patterns ` + + `instead, such as "src/**/*.py" or "test/**/*.py".\n\n` + + `Top of ${this.workspace.workspaceDir}:\n${tree}`, + }; + } + + if (isPureWildcard(args.pattern)) { + const allowedRoots = [this.workspace.workspaceDir, ...this.workspace.additionalDirs]; + const rootList = allowedRoots.map((d) => ` - ${d}`).join('\n'); + let tree: string; + try { + tree = await listDirectory(this.kaos, this.workspace.workspaceDir); + } catch { + tree = '(listing unavailable)'; + } + return { + isError: true, + output: + `Pattern "${args.pattern}" is a pure wildcard (only \`*\`, \`?\`, \`**\`, \`/\`) ` + + `and would enumerate every file under the search root — with no literal ` + + `anchor to bound the result set, this typically exhausts your context on ` + + `large trees. Add an extension ` + + `("${args.pattern === '**' || args.pattern === '**/*' ? '**/*.ts' : '**/*.md'}") ` + + `or a subdirectory ("src/**/*.ts") to constrain the walk.\n\n` + + `Allowed roots for explicit path searches:\n${rootList}\n\n` + + `Top of ${this.workspace.workspaceDir}:\n${tree}`, + }; + } + + if (containsBraceExpansion(args.pattern)) { + return { + isError: true, + output: + `Pattern "${args.pattern}" uses brace expansion (\`{a,b,...}\`), which ` + + `is not supported by this Glob tool. Split it into separate calls, ` + + `one pattern per alternative. For example, instead of "*.{ts,tsx}" ` + + `issue two calls: "*.ts" and "*.tsx".`, + }; + } + + // Default true. When false, directories yielded by kaos are + // filtered out using the same stat that fuels the mtime sort + // (no second stat per path). + const includeDirs = args.include_dirs ?? true; + + // kaos.glob silently returns empty for missing or non-directory roots + // (its _globWalk catches the readdir failure and exits without yielding). + // Without this pre-check, a Glob against a missing path would report + // "No matches found" instead of "does not exist", and the model would + // not realize the search root itself was wrong. iterdir is the right + // signal: pulling one entry triggers the same readdir that kaos.glob + // would do, so ENOENT/ENOTDIR surface here for the realistic backends + // before the walker is invoked. Any other failure (e.g. an unmocked + // test backend that throws "not implemented") falls through silently + // so the existing kaos.glob path still runs. + for (const root of searchRoots) { + try { + const iter = this.kaos.iterdir(root); + await iter.next(); + if (typeof iter.return === 'function') { + await iter.return(undefined); + } + } catch (error) { + if (error !== null && typeof error === 'object' && 'code' in error) { + const code = (error as { code?: string }).code; + if (code === 'ENOENT') { + return { isError: true, output: `${root} does not exist` }; + } + if (code === 'ENOTDIR') { + return { isError: true, output: `${root} is not a directory` }; + } + } + // Unknown failure (including unmocked test backends): fall + // through and let kaos.glob run; it will either yield results + // or its own catch path will surface the error. + } + } + + try { + // Two counters, two jobs: + // - `entries.length` caps the *unique* paths we return, so a + // truncation warning only fires after MAX_MATCHES real hits. + // - `yielded` counts every path the kaos stream emits, including + // duplicates. Secondary safety belt: the kaos `_globWalk` + // itself detects symlink cycles, so a well-formed kaos layer + // never re-yields the same real + // file. `yielded` still terminates the stream if that primary + // defense were ever absent or bypassed (e.g. a future kaos + // backend without inode tracking), so the tool layer doesn't + // depend on the kaos implementation for cycle safety. + const seen = new Set(); + const entries: Array<{ path: string; mtime: number }> = []; + const YIELD_SAFETY_CAP = MAX_MATCHES * 2; + let yielded = 0; + let truncated = false; + + outer: for (const root of searchRoots) { + for await (const filePath of this.kaos.glob(root, args.pattern)) { + yielded++; + if (yielded >= YIELD_SAFETY_CAP) { + truncated = true; + break outer; + } + if (seen.has(filePath)) continue; + if (entries.length >= MAX_MATCHES) { + truncated = true; + break outer; + } + seen.add(filePath); + let mtime = 0; + let isDir = false; + try { + const st = await this.kaos.stat(filePath); + mtime = st.stMtime ?? 0; + isDir = (st.stMode & S_IFMT) === S_IFDIR; + } catch { + // stat failure — use 0 mtime / assume file so it still surfaces + } + // Apply include_dirs *after* marking seen so a filtered dir + // doesn't re-enter via a later duplicate yield, and *before* + // pushing to entries so MAX_MATCHES continues to cap output + // (not pre-filter) size. + if (!includeDirs && isDir) continue; + entries.push({ path: filePath, mtime }); + } + } + + entries.sort((a, b) => b.mtime - a.mtime); + + const paths = entries.map((e) => e.path); + // Content shown to the LLM uses paths relative to the search base + // to save tokens; `output.paths` keeps the absolute form so callers + // can feed them into Read/Edit without further resolution. + const pathClass = this.kaos.pathClass(); + const relBase = searchRoots[0] ?? this.workspace.workspaceDir; + const displayLines = paths.map((p) => relativizeIfUnder(p, relBase, pathClass)); + + if (entries.length === 0 && !truncated) { + return { output: 'No matches found' }; + } + const lines: string[] = []; + if (truncated) { + lines.push(`[Truncated at ${String(MAX_MATCHES)} matches — use a more specific pattern]`); + lines.push(`Only the first ${String(MAX_MATCHES)} matches are returned.`); + } + lines.push(...displayLines); + if (!truncated && entries.length === MAX_MATCHES) { + lines.push(`Found ${String(entries.length)} matches`); + } + return { output: lines.join('\n') }; + } catch (error) { + if (error !== null && typeof error === 'object' && 'code' in error) { + const code = (error as { code?: string }).code; + const path = searchRoots[0] ?? this.workspace.workspaceDir; + if (code === 'ENOENT') { + return { isError: true, output: `${path} does not exist` }; + } + if (code === 'ENOTDIR') { + return { isError: true, output: `${path} is not a directory` }; + } + } + return { isError: true, output: error instanceof Error ? error.message : String(error) }; + } + } + +} + +/** + * If `candidate` is under `base`, return the portion after `base/`. + * Otherwise return `candidate` unchanged (absolute). Both arguments + * should be canonical absolute paths. + */ +function relativizeIfUnder(candidate: string, base: string, pathClass: PathClass): string { + const sep = pathClass === 'win32' ? '\\' : '/'; + const comparableCandidate = pathClass === 'win32' ? candidate.toLowerCase() : candidate; + const comparableBase = pathClass === 'win32' ? base.toLowerCase() : base; + if (comparableCandidate === comparableBase) return '.'; + const prefix = comparableBase.endsWith(sep) ? comparableBase : comparableBase + sep; + if (comparableCandidate.startsWith(prefix)) { + return candidate.slice(prefix.length); + } + return candidate; +} + +// Return true iff `pattern` begins with the literal sequence `**` followed +// by a `/`. Such patterns have no literal anchor in front of the recursive +// wildcard, so the walk has nothing to bound it on the left and would +// descend into every top-level directory of the search root before any +// suffix constraint can filter. Rejected up-front to match the Python Glob +// behavior — callers must anchor with a top-level subdirectory. +function startsWithDoubleStarPrefix(pattern: string): boolean { + return pattern.startsWith('**/'); +} + +/** + * Return true if `pattern` is pure wildcards — only `*`, `?`, `**`, `/`. + * Such patterns have no literal anchor and would enumerate every file + * under the search root. Backslash-escaped characters (`\X`) count as + * literals so `\*` or `\?` still means "pattern has an anchor". + */ +function isPureWildcard(pattern: string): boolean { + if (pattern === '') return false; + for (let i = 0; i < pattern.length; i++) { + const ch = pattern[i]; + if (ch === '\\' && i + 1 < pattern.length) { + // escaped literal — pattern has an anchor + return false; + } + if (ch !== '*' && ch !== '?' && ch !== '/') { + return false; + } + } + return true; +} + +/** Return true iff `pattern` looks like it uses `{a,b,c}` brace expansion. */ +function containsBraceExpansion(pattern: string): boolean { + let inBrace = false; + let sawCommaInsideBrace = false; + for (let i = 0; i < pattern.length; i++) { + const ch = pattern[i]; + if (ch === '\\' && i + 1 < pattern.length) { + i++; + continue; + } + if (ch === '{') { + inBrace = true; + sawCommaInsideBrace = false; + continue; + } + if (ch === '}') { + if (inBrace && sawCommaInsideBrace) return true; + inBrace = false; + continue; + } + if (ch === ',' && inBrace) sawCommaInsideBrace = true; + } + return false; +} diff --git a/packages/agent-core/src/tools/builtin/file/grep.md b/packages/agent-core/src/tools/builtin/file/grep.md new file mode 100644 index 000000000..c0d2776b3 --- /dev/null +++ b/packages/agent-core/src/tools/builtin/file/grep.md @@ -0,0 +1,9 @@ +Search file contents using regular expressions (powered by ripgrep). + +Use Grep when the task is to find unknown content or unknown file locations. Do not use shell `grep` or `rg` directly; this tool applies workspace path policy, output limits, and sensitive-file filtering. +ALWAYS use Grep tool instead of running `grep` or `rg` from a shell — direct shell calls bypass workspace policy, output limits, and sensitive-file filtering. +If you already know a concrete file path and need to inspect its contents, use Read directly instead. + +Write patterns in ripgrep regex syntax, which differs from POSIX `grep` syntax. For example, braces are special, so escape them as `\{` to match a literal `{`. + +Hidden files (dotfiles such as `.gitlab-ci.yml` or `.eslintrc.json`) are searched by default. To also search files excluded by `.gitignore` (such as `node_modules` or build outputs), set `include_ignored` to `true`. Sensitive files (such as `.env`) are always skipped for safety, even when `include_ignored` is `true`. diff --git a/packages/agent-core/src/tools/builtin/file/grep.ts b/packages/agent-core/src/tools/builtin/file/grep.ts new file mode 100644 index 000000000..00dbcf25f --- /dev/null +++ b/packages/agent-core/src/tools/builtin/file/grep.ts @@ -0,0 +1,955 @@ +/** + * GrepTool — content search via ripgrep. + * + * Shells out to `rg` through Kaos. Supports glob/type filtering, context + * lines, output modes, pagination, multiline, and case-insensitive search. + * + * Path safety is enforced before any Kaos I/O. Explicit absolute paths outside + * the workspace are allowed; relative paths that escape the workspace are + * rejected. + * + * Output is bounded and post-processed before it reaches the model: + * - timeout and ambient abort both terminate the rg subprocess; + * - stdout/stderr are capped while streams continue draining; + * - hidden files are searched, but VCS metadata and common sensitive glob + * patterns are prefiltered where possible; + * - parsed path records are filtered again after rg returns, using the active + * backend path class. + */ + +import type { Readable } from 'node:stream'; + +import type { Kaos, KaosProcess } from '@moonshot-ai/kaos'; +import { z } from 'zod'; + +import type { BuiltinTool } from '../../../agent/tool'; +import { isAbortError } from '../../../loop/errors'; +import { ToolAccesses } from '../../../loop/tool-access'; +import type { ExecutableToolResult, ToolExecution } from '../../../loop/types'; +import { resolvePathAccessPath } from '../../policies/path-access'; +import type { PathClass } from '../../policies/path-access'; +import { isSensitiveFile, SENSITIVE_DOT_VARIANT_SUFFIXES } from '../../policies/sensitive'; +import { toInputJsonSchema } from '../../support/input-schema'; +import { ensureRgPath, rgUnavailableMessage } from '../../support/rg-locator'; +import { ToolResultBuilder } from '../../support/result-builder'; +import type { WorkspaceConfig } from '../../support/workspace'; +import GREP_DESCRIPTION from './grep.md'; + +export const GrepInputSchema = z.object({ + pattern: z.string().describe('Regular expression to search for.'), + path: z + .string() + .optional() + .describe( + 'File or directory to search. Accepts an absolute path, or a path relative to the current working directory. Omit to search the current working directory. Use Read instead when you already know a concrete file path and need its contents.', + ), + glob: z.string().optional().describe('Optional glob filter passed to ripgrep.'), + type: z + .string() + .optional() + .describe( + 'Optional ripgrep file type filter, such as ts or py. Prefer this over `glob` when filtering by language or file kind: it is more efficient and less error-prone than an equivalent glob pattern.', + ), + output_mode: z + .enum(['content', 'files_with_matches', 'count_matches']) + .optional() + .describe( + 'Shape of the result. `content` shows matching lines (honors `-A`, `-B`, `-C`, `-n`, and `head_limit`); `files_with_matches` shows only the paths of files that contain a match (honors `head_limit`); `count_matches` shows the total number of matches. Defaults to `files_with_matches`.', + ), + '-i': z.boolean().optional().describe('Perform a case-insensitive search. Defaults to false.'), + '-n': z + .boolean() + .optional() + .describe( + 'Prefix each matching line with its line number. Applies only when `output_mode` is `content`. Defaults to true.', + ), + '-A': z + .number() + .int() + .nonnegative() + .optional() + .describe( + 'Number of lines to show after each match. Applies only when `output_mode` is `content`.', + ), + '-B': z + .number() + .int() + .nonnegative() + .optional() + .describe( + 'Number of lines to show before each match. Applies only when `output_mode` is `content`.', + ), + '-C': z + .number() + .int() + .nonnegative() + .optional() + .describe( + 'Number of lines to show before and after each match. Applies only when `output_mode` is `content`; takes precedence over `-A` and `-B`.', + ), + head_limit: z + .number() + .int() + .nonnegative() + .optional() + .describe( + 'Limit output to the first N lines/entries after offset. Defaults to 250. Pass 0 for unlimited.', + ), + offset: z + .number() + .int() + .nonnegative() + .optional() + .describe( + 'Number of leading lines/entries to skip before applying `head_limit`. Use it together with `head_limit` to page through large result sets. Defaults to 0.', + ), + multiline: z + .boolean() + .optional() + .describe( + 'Enable multiline matching, where the pattern can span line boundaries and `.` also matches newlines. Defaults to false.', + ), + include_ignored: z + .boolean() + .optional() + .describe( + 'Also search files excluded by ignore files such as `.gitignore`, `.ignore`, and `.rgignore` (for example `node_modules` or build outputs). Sensitive files (such as `.env`) remain filtered out for safety. Defaults to false.', + ), +}); + +export const GrepOutputSchema = z.object({ + mode: z.enum(['content', 'files_with_matches', 'count_matches']), + numFiles: z.number().int().nonnegative(), + filenames: z.array(z.string()), + content: z.string().optional(), + numLines: z.number().int().nonnegative().optional(), + numMatches: z.number().int().nonnegative().optional(), + appliedLimit: z.number().int().nonnegative().optional(), +}); + +export type GrepInput = z.Infer; +export type GrepOutput = z.Infer; + +const DEFAULT_TIMEOUT_MS = 20_000; +const SIGTERM_GRACE_MS = 5_000; +const MAX_OUTPUT_BYTES = 10 * 1024 * 1024; +// Column cap applied to non-content output modes only; `content` mode returns +// matching lines in full so the cap is intentionally skipped there. +const RG_MAX_COLUMNS = 500; +const DEFAULT_HEAD_LIMIT = 250; +const MTIME_STAT_CONCURRENCY = 32; +const VCS_DIRECTORIES_TO_EXCLUDE = ['.git', '.svn', '.hg', '.bzr', '.jj', '.sl'] as const; +// This is a conservative prefilter. The authoritative sensitive-file check +// still happens on parsed rg records after execution. +const SENSITIVE_KEY_BASENAMES = ['id_rsa', 'id_ed25519', 'id_ecdsa'] as const; +const SENSITIVE_KEY_GLOBS_TO_EXCLUDE = SENSITIVE_KEY_BASENAMES.flatMap((name) => [ + `**/${name}`, + `**/${name}[-_]*`, + ...SENSITIVE_DOT_VARIANT_SUFFIXES.map((suffix) => `**/${name}${suffix}`), +]); +const SENSITIVE_GLOBS_TO_EXCLUDE = [ + '**/.env', + ...SENSITIVE_KEY_GLOBS_TO_EXCLUDE, + '**/.aws/credentials', + '**/.aws/credentials/**', + '**/.gcp/credentials', + '**/.gcp/credentials/**', +] as const; + +// Line formats produced by ripgrep: +// content match with --null: "file.py10:matched text" +// context line with --null: "file.py9-context text" +// count_matches with --null: "file.py2" +// non-NUL content fallback: "file.py:10:matched text" +// context divider: "--" +// Runtime rg output uses NUL as the path boundary; the regex handles +// line-oriented output without NUL delimiters. +const CONTENT_LINE_RE = /^(.*?)([:-])(\d+)\2/; + +export class GrepTool implements BuiltinTool { + readonly name = 'Grep' as const; + readonly description = GREP_DESCRIPTION; + readonly parameters: Record = toInputJsonSchema(GrepInputSchema); + constructor( + private readonly kaos: Kaos, + private readonly workspace: WorkspaceConfig, + ) {} + + resolveExecution(args: GrepInput): ToolExecution { + let path: string | undefined; + if (args.path !== undefined) { + path = resolvePathAccessPath(args.path, { + kaos: this.kaos, + workspace: this.workspace, + operation: 'search', + policy: { guardMode: 'absolute-outside-allowed', checkSensitive: false }, + }); + } + const searchPaths = [path ?? this.workspace.workspaceDir]; + const searchPath = args.path ?? this.workspace.workspaceDir; + return { + accesses: ToolAccesses.searchTree(searchPaths[0]!), + description: `Searching for '${args.pattern}' in ${searchPath}`, + execute: ({ signal }) => this.execution(args, signal, searchPaths), + }; + } + + private async execution( + args: GrepInput, + signal: AbortSignal, + searchPaths: string[], + ): Promise { + if (signal.aborted) { + return { isError: true, output: 'Aborted before search started' }; + } + + const pathClass = this.kaos.pathClass(); + let rgPath: string; + try { + const resolution = await ensureRgPath({ signal }); + rgPath = resolution.path; + } catch (error) { + if (isAbortError(error)) { + return { isError: true, output: 'Grep aborted' }; + } + return { isError: true, output: rgUnavailableMessage(error) }; + } + + let runResult = await runRipgrepOnce(this.kaos, buildRgArgs(rgPath, args, searchPaths), signal); + if (runResult.kind === 'tool-error') return runResult.result; + if (shouldRetryRipgrepEagain(runResult)) { + runResult = await runRipgrepOnce( + this.kaos, + buildRgArgs(rgPath, args, searchPaths, true), + signal, + ); + if (runResult.kind === 'tool-error') return runResult.result; + } + + const { exitCode, stderrText, bufferTruncated, stderrTruncated, timedOut } = runResult; + let { stdoutText } = runResult; + + // rg exit codes: 0 = matches, 1 = no matches, 2 = error. Timeout kills + // usually surface as a signal exit code; keep any complete partial records. + if (exitCode !== 0 && exitCode !== 1 && !timedOut) { + return { + isError: true, + output: formatRipgrepError(exitCode, stderrText, stderrTruncated), + }; + } + + const mode = args.output_mode ?? 'files_with_matches'; + if (bufferTruncated || timedOut) { + stdoutText = omitIncompleteTrailingRecord(stdoutText, mode); + } + if (timedOut && stdoutText.trim() === '') { + return { + isError: true, + output: `Grep timed out after ${String(DEFAULT_TIMEOUT_MS / 1000)}s. Try a more specific path or pattern.`, + }; + } + if (signal.aborted) { + return { isError: true, output: 'Grep aborted' }; + } + + const rawLines = parseRipgrepOutput(stdoutText, mode); + + const filteredSensitive = new Set(); + const keptLines = filterSensitiveLines(rawLines, mode, filteredSensitive, pathClass); + let orderedLines: ParsedGrepLine[]; + try { + orderedLines = + mode === 'files_with_matches' && !timedOut + ? await sortFilesWithMatchesByMtime(keptLines, this.kaos, signal) + : keptLines; + } catch (error) { + if (error instanceof GrepAbortedError) { + return { isError: true, output: 'Grep aborted' }; + } + throw error; + } + + const offset = args.offset ?? 0; + const headLimit = args.head_limit ?? DEFAULT_HEAD_LIMIT; + const afterOffset = offset > 0 ? orderedLines.slice(offset) : orderedLines; + const limitActive = headLimit > 0; + const limited = limitActive ? afterOffset.slice(0, headLimit) : afterOffset; + const paginationTruncated = limitActive && afterOffset.length > headLimit; + + // Human-readable annotations are appended after visible matches. + // In count mode, the data stream must stay pure `path:count` lines + // — the count summary and pagination notice move to a side channel + // (returned via `result.message`) so they don't contaminate it. + // Other modes keep these notices inline in `output`. + const messages: string[] = []; + const sideChannelMessages: string[] = []; + if (filteredSensitive.size > 0) { + const displayedFilteredPaths = [...filteredSensitive].map((path) => + relativizeIfUnder(path, this.workspace.workspaceDir, pathClass), + ); + messages.push( + `Filtered ${String(filteredSensitive.size)} sensitive file(s): ${displayedFilteredPaths.join(', ')}`, + ); + } + if (mode === 'count_matches' && orderedLines.length > 0) { + sideChannelMessages.push(formatCountSummary(orderedLines, filteredSensitive.size > 0)); + } + if (paginationTruncated) { + const total = afterOffset.length + offset; + const nextOffset = offset + headLimit; + const paginationNotice = `Results truncated to ${String(headLimit)} lines (total: ${String(total)}). Use offset=${String(nextOffset)} to see more.`; + if (mode === 'count_matches') { + sideChannelMessages.push(paginationNotice); + } else { + messages.push(paginationNotice); + } + } + if (bufferTruncated) { + messages.push( + `[stdout truncated at ${String(MAX_OUTPUT_BYTES)} bytes; incomplete trailing line omitted]`, + ); + } + if (timedOut) { + messages.push( + `Grep timed out after ${String(DEFAULT_TIMEOUT_MS / 1000)}s; partial results returned`, + ); + } + + const contentIncludesLineNumbers = mode === 'content' && args['-n'] !== false; + const displayedLines = limited.map((line) => + formatDisplayLine( + line, + mode, + this.workspace.workspaceDir, + pathClass, + contentIncludesLineNumbers, + ), + ); + const contentBody = displayedLines.join('\n'); + const visibleBody = + orderedLines.length === 0 && filteredSensitive.size > 0 + ? 'No non-sensitive matches found' + : contentBody; + const emptyResultMessage = + SENSITIVE_GLOBS_TO_EXCLUDE.length > 0 ? 'No non-sensitive matches found' : 'No matches found'; + const combined = + visibleBody === '' && messages.length === 0 + ? emptyResultMessage + : messages.length > 0 + ? visibleBody === '' + ? messages.join('\n') + : `${visibleBody}\n${messages.join('\n')}` + : visibleBody; + + const builder = new ToolResultBuilder(); + builder.write(combined); + return builder.ok(sideChannelMessages.join('\n')); + } + +} + +interface RipgrepRunResult { + readonly kind: 'result'; + readonly exitCode: number; + readonly stdoutText: string; + readonly stderrText: string; + readonly bufferTruncated: boolean; + readonly stderrTruncated: boolean; + readonly timedOut: boolean; +} + +type RipgrepRunOutcome = + | RipgrepRunResult + | { readonly kind: 'tool-error'; readonly result: ExecutableToolResult }; + +type GrepMode = 'content' | 'files_with_matches' | 'count_matches'; + +type ParsedGrepLine = + | { + readonly kind: 'record'; + readonly filePath: string; + readonly payload: string; + } + | { + readonly kind: 'separator'; + } + | { + readonly kind: 'legacy'; + readonly text: string; + }; + +class GrepAbortedError extends Error { + constructor() { + super('Grep aborted'); + this.name = 'GrepAbortedError'; + } +} + +async function runRipgrepOnce( + kaos: Kaos, + rgArgs: readonly string[], + signal: AbortSignal, +): Promise { + if (signal.aborted) { + return { kind: 'tool-error', result: { isError: true, output: 'Grep aborted' } }; + } + + let proc: KaosProcess; + try { + proc = await kaos.exec(...rgArgs); + } catch (error) { + // Spawn can still fail after path resolution, e.g. permissions or a + // corrupt binary. ENOENT gets the same actionable hint as locator failures. + const isEnoent = + error instanceof Error && + 'code' in error && + (error as NodeJS.ErrnoException).code === 'ENOENT'; + return { + kind: 'tool-error', + result: { + isError: true, + output: isEnoent + ? rgUnavailableMessage(error) + : error instanceof Error + ? error.message + : String(error), + }, + }; + } + + try { + proc.stdin.end(); + } catch { + /* already gone */ + } + + let timedOut = false; + let aborted = false; + let killed = false; + + const killProc = async (): Promise => { + if (killed) return; + killed = true; + try { + await proc.kill('SIGTERM'); + } catch { + /* process already gone */ + } + const exited = proc + .wait() + .then(() => true) + .catch(() => true); + const raced = await Promise.race([ + exited, + new Promise((resolve) => { + setTimeout(() => { + resolve(false); + }, SIGTERM_GRACE_MS); + }), + ]); + if (!raced && proc.exitCode === null) { + try { + await proc.kill('SIGKILL'); + } catch { + /* ignore */ + } + } + }; + + const onAbort = (): void => { + aborted = true; + void killProc(); + }; + signal.addEventListener('abort', onAbort); + // AbortSignal does not replay past abort events; check once after registering + // the listener so already-aborted calls still run the cleanup path. + if (signal.aborted) onAbort(); + + const timeoutHandle = setTimeout(() => { + timedOut = true; + void killProc(); + }, DEFAULT_TIMEOUT_MS); + + let exitCode = 0; + let stdoutText = ''; + let stderrText = ''; + let bufferTruncated = false; + let stderrTruncated = false; + + try { + const [stdoutResult, stderrResult, code] = await Promise.all([ + readStreamWithCap(proc.stdout, MAX_OUTPUT_BYTES), + readStreamWithCap(proc.stderr, MAX_OUTPUT_BYTES), + proc.wait(), + ]); + stdoutText = stdoutResult.text; + stderrText = stderrResult.text; + bufferTruncated = stdoutResult.truncated; + stderrTruncated = stderrResult.truncated; + exitCode = code; + } catch (error) { + return { + kind: 'tool-error', + result: { + isError: true, + output: error instanceof Error ? error.message : String(error), + }, + }; + } finally { + clearTimeout(timeoutHandle); + signal.removeEventListener('abort', onAbort); + } + + if (aborted) { + return { kind: 'tool-error', result: { isError: true, output: 'Grep aborted' } }; + } + + return { + kind: 'result', + exitCode, + stdoutText, + stderrText, + bufferTruncated, + stderrTruncated, + timedOut, + }; +} + +function shouldRetryRipgrepEagain(result: RipgrepRunResult): boolean { + return ( + result.exitCode !== 0 && + result.exitCode !== 1 && + !result.timedOut && + isEagainRipgrepError(result.stderrText) + ); +} + +function isEagainRipgrepError(stderr: string): boolean { + return stderr.includes('os error 11') || stderr.includes('Resource temporarily unavailable'); +} + +async function sortFilesWithMatchesByMtime( + lines: readonly ParsedGrepLine[], + kaos: Kaos, + signal: AbortSignal, +): Promise { + const entries = await mapWithConcurrency( + lines, + MTIME_STAT_CONCURRENCY, + signal, + async (line, index) => { + const path = + line.kind === 'record' ? line.filePath : line.kind === 'legacy' ? line.text : undefined; + let mtime = 0; + if (path !== undefined) { + try { + mtime = (await kaos.stat(path)).stMtime ?? 0; + } catch { + // Keep stat failures visible; use mtime=0 so they sort after known files. + } + } + return { line, mtime, index }; + }, + ); + entries.sort((a, b) => b.mtime - a.mtime || a.index - b.index); + return entries.map((entry) => entry.line); +} + +async function mapWithConcurrency( + items: readonly T[], + concurrency: number, + signal: AbortSignal, + mapper: (item: T, index: number) => Promise, +): Promise { + if (signal.aborted) throw new GrepAbortedError(); + if (items.length === 0) return []; + + const results: U[] = []; + results.length = items.length; + let nextIndex = 0; + const workerCount = Math.min(Math.max(1, concurrency), items.length); + await Promise.all( + Array.from({ length: workerCount }, async () => { + while (true) { + if (signal.aborted) return; + const index = nextIndex; + nextIndex += 1; + if (index >= items.length) return; + results[index] = await mapper(items[index] as T, index); + } + }), + ); + if (signal.aborted) throw new GrepAbortedError(); + return results; +} + +function buildRgArgs( + rgPath: string, + args: GrepInput, + searchPaths: readonly string[], + singleThreaded = false, +): string[] { + const cmd: string[] = [rgPath]; + if (singleThreaded) cmd.push('-j', '1'); + cmd.push('--hidden'); + const mode = args.output_mode ?? 'files_with_matches'; + // `content` mode returns matching lines verbatim. Capping columns here would + // make rg replace any line wider than the cap with a placeholder, silently + // dropping the actual match text. The cap is only useful outside `content` + // mode, where line text is never surfaced. + if (mode !== 'content') { + cmd.push('--max-columns', String(RG_MAX_COLUMNS)); + } + cmd.push('--null'); + for (const dir of VCS_DIRECTORIES_TO_EXCLUDE) { + cmd.push('--glob', `!${dir}`); + } + + if (mode === 'files_with_matches') cmd.push('-l'); + else if (mode === 'count_matches') { + // rg omits the filename when only one file is searched, so pin it on. Without + // this, the per-file line collapses to a bare count and the summary parser + // disagrees with the displayed number. + cmd.push('--count-matches', '--with-filename'); + } + + if (args['-i']) cmd.push('-i'); + if (mode === 'content') { + cmd.push('--with-filename'); + if (args['-n'] !== false) { + cmd.push('-n'); + } else { + cmd.push('--field-context-separator', ':'); + } + if (args['-C'] !== undefined) { + cmd.push('-C', String(args['-C'])); + } else { + if (args['-A'] !== undefined) cmd.push('-A', String(args['-A'])); + if (args['-B'] !== undefined) cmd.push('-B', String(args['-B'])); + } + } + if (args.glob !== undefined) cmd.push('--glob', args.glob); + if (args.type !== undefined) cmd.push('--type', args.type); + if (args.multiline) cmd.push('-U', '--multiline-dotall'); + if (args.include_ignored) cmd.push('--no-ignore'); + for (const glob of SENSITIVE_GLOBS_TO_EXCLUDE) { + // Appended after user globs so a broad include such as `**/.env` cannot + // undo this first-pass exclusion. Explicit file paths are still protected + // by the post-processing filter because rg intentionally searches them. + cmd.push('--glob', `!${glob}`); + } + // Do not forward `head_limit` to `rg --max-count`: omitted means "use the + // tool default", head_limit=0 means "unlimited", while `rg --max-count 0` + // means "zero matches per file". Pagination happens in post-processing. + + cmd.push('--', args.pattern, ...searchPaths); + return cmd; +} + +function splitRgLines(text: string): string[] { + if (text === '') return []; + const lines = text.split('\n'); + // Strip the trailing empty line left by a final newline. + while (lines.length > 0 && lines.at(-1) === '') { + lines.pop(); + } + return lines.map((line) => stripTrailingCarriageReturn(line)); +} + +function parseRipgrepOutput(text: string, mode: GrepMode): ParsedGrepLine[] { + if (text === '') return []; + if (!text.includes('\0')) { + return splitRgLines(text).map((line) => + mode === 'content' && line === '--' ? { kind: 'separator' } : { kind: 'legacy', text: line }, + ); + } + + if (mode === 'files_with_matches') { + return text + .split('\0') + .map((filePath) => stripTrailingCarriageReturn(filePath)) + .filter((filePath) => filePath !== '') + .map((filePath) => ({ kind: 'record', filePath, payload: '' })); + } + + const records: ParsedGrepLine[] = []; + let cursor = 0; + while (cursor < text.length) { + if (text[cursor] === '\n') { + cursor += 1; + continue; + } + if (text.startsWith('--\r\n', cursor)) { + records.push({ kind: 'separator' }); + cursor += 4; + continue; + } + if (text.startsWith('--\n', cursor)) { + records.push({ kind: 'separator' }); + cursor += 3; + continue; + } + + const nulIndex = text.indexOf('\0', cursor); + if (nulIndex < 0) { + const tail = stripTrailingCarriageReturn(text.slice(cursor)); + if (tail !== '') records.push({ kind: 'legacy', text: tail }); + break; + } + + const lineEnd = text.indexOf('\n', nulIndex + 1); + const payloadEnd = lineEnd >= 0 ? lineEnd : text.length; + const filePath = text.slice(cursor, nulIndex); + const payload = stripTrailingCarriageReturn(text.slice(nulIndex + 1, payloadEnd)); + records.push({ kind: 'record', filePath, payload }); + cursor = lineEnd >= 0 ? lineEnd + 1 : text.length; + } + return records; +} + +function formatDisplayLine( + line: ParsedGrepLine, + mode: GrepMode, + workspaceDir: string, + pathClass: PathClass, + contentIncludesLineNumbers: boolean, +): string { + if (line.kind === 'separator') return '--'; + if (line.kind === 'record') { + const displayPath = relativizeIfUnder(line.filePath, workspaceDir, pathClass); + if (mode === 'files_with_matches') return displayPath; + if (mode === 'count_matches') return `${displayPath}:${line.payload}`; + const separator = contentIncludesLineNumbers ? contentPayloadPathSeparator(line.payload) : ':'; + return `${displayPath}${separator}${line.payload}`; + } + + const text = line.text; + if (mode === 'files_with_matches') { + return relativizeIfUnder(text, workspaceDir, pathClass); + } + if (mode === 'count_matches') { + const idx = text.lastIndexOf(':'); + if (idx <= 0) return text; + return relativizeIfUnder(text.slice(0, idx), workspaceDir, pathClass) + text.slice(idx); + } + + const filePath = extractContentFilePath(text, pathClass); + if (filePath !== undefined) { + return relativizeIfUnder(filePath, workspaceDir, pathClass) + text.slice(filePath.length); + } + return text; +} + +/** + * If `candidate` is under `base`, return the portion after `base/`. + * Otherwise return `candidate` unchanged. Both arguments should be + * canonical absolute paths in the active backend path class. + */ +function relativizeIfUnder(candidate: string, base: string, pathClass: PathClass): string { + const sep = pathClass === 'win32' ? '\\' : '/'; + const comparableCandidate = pathClass === 'win32' ? candidate.toLowerCase() : candidate; + const comparableBase = pathClass === 'win32' ? base.toLowerCase() : base; + if (comparableCandidate === comparableBase) return '.'; + const prefix = comparableBase.endsWith(sep) ? comparableBase : comparableBase + sep; + if (comparableCandidate.startsWith(prefix)) { + return candidate.slice(prefix.length); + } + return candidate; +} + +function omitIncompleteTrailingRecord(text: string, mode: GrepMode): string { + if (!text.includes('\0')) return omitIncompleteTrailingLine(text); + if (mode === 'files_with_matches') { + const lastNul = text.lastIndexOf('\0'); + return lastNul >= 0 ? text.slice(0, lastNul + 1) : ''; + } + + let cursor = 0; + let lastCompleteEnd = 0; + while (cursor < text.length) { + if (text[cursor] === '\n') { + cursor += 1; + lastCompleteEnd = cursor; + continue; + } + if (text.startsWith('--\r\n', cursor)) { + cursor += 4; + lastCompleteEnd = cursor; + continue; + } + if (text.startsWith('--\n', cursor)) { + cursor += 3; + lastCompleteEnd = cursor; + continue; + } + + const nulIndex = text.indexOf('\0', cursor); + if (nulIndex < 0) break; + const lineEnd = text.indexOf('\n', nulIndex + 1); + if (lineEnd < 0) break; + cursor = lineEnd + 1; + lastCompleteEnd = cursor; + } + return text.slice(0, lastCompleteEnd); +} + +function omitIncompleteTrailingLine(text: string): string { + const lastNewline = text.lastIndexOf('\n'); + return lastNewline >= 0 ? text.slice(0, lastNewline) : ''; +} + +function formatRipgrepError( + exitCode: number, + stderrText: string, + stderrTruncated: boolean, +): string { + const stderr = stderrText.trim(); + if (stderr.length === 0) { + return `Failed to grep: ripgrep exited with code ${String(exitCode)}`; + } + + const summary = summarizeRipgrepStderr(stderr); + const lines = [`Failed to grep: ${summary}`, '', 'ripgrep stderr:', stderr]; + if (stderrTruncated) { + lines.push(`[stderr truncated at ${String(MAX_OUTPUT_BYTES)} bytes]`); + } + return lines.join('\n'); +} + +function summarizeRipgrepStderr(stderr: string): string { + const lines = splitRgLines(stderr) + .map((line) => line.trim()) + .filter((line) => line.length > 0); + const errorLine = lines.findLast((line) => line.toLowerCase().startsWith('error:')); + return errorLine ?? lines.at(-1) ?? 'ripgrep error'; +} + +function filterSensitiveLines( + lines: readonly ParsedGrepLine[], + mode: GrepMode, + filteredPaths: Set, + pathClass: PathClass, +): ParsedGrepLine[] { + const kept: ParsedGrepLine[] = []; + for (const line of lines) { + if (line.kind === 'separator') { + kept.push(line); + continue; + } + const filePath = parsedFilePath(line, mode, pathClass); + if (filePath !== undefined && isSensitiveFile(filePath, pathClass)) { + filteredPaths.add(filePath); + continue; + } + kept.push(line); + } + return mode === 'content' ? normalizeContextSeparators(kept) : kept; +} + +function normalizeContextSeparators(lines: readonly ParsedGrepLine[]): ParsedGrepLine[] { + const normalized: ParsedGrepLine[] = []; + for (const line of lines) { + if ( + line.kind === 'separator' && + (normalized.length === 0 || normalized.at(-1)?.kind === 'separator') + ) { + continue; + } + normalized.push(line); + } + while (normalized.length > 0 && normalized.at(-1)?.kind === 'separator') { + normalized.pop(); + } + return normalized; +} + +function parsedFilePath( + line: ParsedGrepLine, + mode: GrepMode, + pathClass: PathClass, +): string | undefined { + if (line.kind === 'record') return line.filePath; + if (line.kind === 'separator') return undefined; + const text = line.text; + if (mode === 'files_with_matches') return text; + if (mode === 'count_matches') { + const idx = text.lastIndexOf(':'); + return idx > 0 ? text.slice(0, idx) : text; + } + return extractContentFilePath(text, pathClass); +} + +function extractContentFilePath(line: string, pathClass: PathClass): string | undefined { + const m = CONTENT_LINE_RE.exec(line); + if (m?.[1] !== undefined) return m[1]; + + const separatorIndex = noLineNumberContentSeparatorIndex(line, pathClass); + return separatorIndex > 0 ? line.slice(0, separatorIndex) : undefined; +} + +function noLineNumberContentSeparatorIndex(line: string, pathClass: PathClass): number { + const searchFrom = pathClass === 'win32' && /^[A-Za-z]:/.test(line) ? 2 : 0; + return line.indexOf(':', searchFrom); +} + +function contentPayloadPathSeparator(payload: string): ':' | '-' { + const m = /^(\d+)([:-])/.exec(payload); + return m?.[2] === '-' ? '-' : ':'; +} + +function stripTrailingCarriageReturn(value: string): string { + return value.endsWith('\r') ? value.slice(0, -1) : value; +} + +function formatCountSummary(lines: readonly ParsedGrepLine[], redactedSensitive: boolean): string { + let totalMatches = 0; + let totalFiles = 0; + for (const line of lines) { + const rawCount = + line.kind === 'record' + ? line.payload + : line.kind === 'legacy' + ? countPayloadFromLegacyLine(line.text) + : undefined; + if (rawCount === undefined) continue; + const count = Number(rawCount); + if (!Number.isSafeInteger(count) || count < 0) continue; + totalMatches += count; + totalFiles++; + } + + const occurrenceWord = totalMatches === 1 ? 'occurrence' : 'occurrences'; + const fileWord = totalFiles === 1 ? 'file' : 'files'; + const scope = redactedSensitive ? 'total non-sensitive' : 'total'; + return `Found ${String(totalMatches)} ${scope} ${occurrenceWord} across ${String(totalFiles)} ${fileWord}.`; +} + +function countPayloadFromLegacyLine(line: string): string | undefined { + const idx = line.lastIndexOf(':'); + return idx > 0 ? line.slice(idx + 1) : undefined; +} + +interface CappedStreamResult { + readonly text: string; + readonly truncated: boolean; +} + +async function readStreamWithCap(stream: Readable, maxBytes: number): Promise { + const chunks: Buffer[] = []; + let total = 0; + let truncated = false; + for await (const chunk of stream) { + const buf: Buffer = typeof chunk === 'string' ? Buffer.from(chunk, 'utf8') : (chunk as Buffer); + if (truncated) continue; + if (total + buf.length > maxBytes) { + const remaining = maxBytes - total; + if (remaining > 0) chunks.push(buf.subarray(0, remaining)); + total = maxBytes; + truncated = true; + continue; + } + chunks.push(buf); + total += buf.length; + } + return { text: Buffer.concat(chunks).toString('utf8'), truncated }; +} diff --git a/packages/agent-core/src/tools/builtin/file/line-endings.ts b/packages/agent-core/src/tools/builtin/file/line-endings.ts new file mode 100644 index 000000000..38e117a77 --- /dev/null +++ b/packages/agent-core/src/tools/builtin/file/line-endings.ts @@ -0,0 +1,51 @@ +export type LineEndingStyle = 'lf' | 'crlf' | 'mixed'; + +export interface ModelTextView { + text: string; + lineEndingStyle: LineEndingStyle; +} + +export function detectLineEndingStyle(text: string): LineEndingStyle { + let hasCrLf = false; + let hasLf = false; + let hasLoneCr = false; + + for (let i = 0; i < text.length; i++) { + const code = text.codePointAt(i); + if (code === 13) { + if (text.codePointAt(i + 1) === 10) { + hasCrLf = true; + i++; + } else { + hasLoneCr = true; + } + } else if (code === 10) { + hasLf = true; + } + } + + if (hasLoneCr || (hasCrLf && hasLf)) return 'mixed'; + if (hasCrLf) return 'crlf'; + return 'lf'; +} + +export function toModelTextView(raw: string): ModelTextView { + const lineEndingStyle = detectLineEndingStyle(raw); + if (lineEndingStyle !== 'crlf') { + return { text: raw, lineEndingStyle }; + } + + return { + text: raw.replaceAll('\r\n', '\n'), + lineEndingStyle, + }; +} + +export function materializeModelText(text: string, lineEndingStyle: LineEndingStyle): string { + if (lineEndingStyle !== 'crlf') return text; + return text.replaceAll('\r\n', '\n').replaceAll('\n', '\r\n'); +} + +export function makeCarriageReturnsVisible(text: string): string { + return text.replaceAll('\r', '\\r'); +} diff --git a/packages/agent-core/src/tools/builtin/file/read-media.md b/packages/agent-core/src/tools/builtin/file/read-media.md new file mode 100644 index 000000000..0ff49fb6c --- /dev/null +++ b/packages/agent-core/src/tools/builtin/file/read-media.md @@ -0,0 +1,13 @@ +Read media content from a file. + +**Tips:** +- Make sure you follow the description of each tool parameter. +- A `` tag is given before the file content; it summarizes the mime type, byte size and, for images, the original pixel dimensions. When outputting coordinates, give relative coordinates first and compute absolute coordinates from the original image size. After generating or editing media via commands or scripts, read the result back before continuing. +- The system will notify you when there is anything wrong when reading the file. +- This tool is a tool that you typically want to use in parallel. Always read multiple files in one response when possible. +- This tool can only read image or video files. To read text files, use the Read tool. To list directories, use `ls` via Bash for a known directory, or Glob for pattern search. +- If the file doesn't exist or path is invalid, an error will be returned. +- The maximum size that can be read is {{ MAX_MEDIA_MEGABYTES }}MB. An error will be returned if the file is larger than this limit. +- The media content will be returned in a form that you can directly view and understand. + +**Capabilities** \ No newline at end of file diff --git a/packages/agent-core/src/tools/builtin/file/read-media.ts b/packages/agent-core/src/tools/builtin/file/read-media.ts new file mode 100644 index 000000000..b486d74ff --- /dev/null +++ b/packages/agent-core/src/tools/builtin/file/read-media.ts @@ -0,0 +1,264 @@ +/** + * ReadMediaFileTool — read image/video files as multi-modal content. + * + * Returns a 4-part wrap: + * `[TextPart(''), TextPart(''), + * ImageContent|VideoContent, TextPart('')]` + * and gates on the model's `image_in` / `video_in` capability. + * + * The leading `` block summarizes mime type, byte size and (for + * images) original pixel dimensions, guides the model to derive absolute + * coordinates from that original size, and reminds it to re-read any media + * it generates or edits. + * + * Path safety: goes through the shared path access resolver used by + * Read/Write/Edit. + */ + +import type { Kaos } from '@moonshot-ai/kaos'; +import type { + ContentPart, + ModelCapability, + VideoURLPart, + VideoUploadInput as ProviderVideoUploadInput, +} from '@moonshot-ai/kosong'; +import { z } from 'zod'; + +import type { BuiltinTool } from '../../../agent/tool'; +import { ToolAccesses } from '../../../loop/tool-access'; +import type { ExecutableToolResult, ToolExecution } from '../../../loop/types'; +import { renderPrompt } from '../../../utils/render-prompt'; +import { resolvePathAccessPath } from '../../policies/path-access'; +import { MEDIA_SNIFF_BYTES, detectFileType, sniffImageDimensions } from '../../support/file-type'; +import { toInputJsonSchema } from '../../support/input-schema'; +import type { WorkspaceConfig } from '../../support/workspace'; +import readMediaDescriptionHead from './read-media.md'; + +// ── Constants ──────────────────────────────────────────────────────── + +const MAX_MEDIA_MEGABYTES = 100; +const MAX_MEDIA_BYTES = MAX_MEDIA_MEGABYTES * 1024 * 1024; + +export type VideoUploadInput = ProviderVideoUploadInput; + +export type VideoUploader = (input: VideoUploadInput) => Promise; + +// ── Input schema ───────────────────────────────────────────────────── + +export const ReadMediaFileInputSchema = z.object({ + path: z + .string() + .describe( + 'Path to an image or video file. Relative paths resolve against the working directory; ' + + 'a path outside the working directory must be absolute. ' + + 'Directories and text files are not supported.', + ), +}); + +export type ReadMediaFileInput = z.Infer; + +// ── Tool description (capability-driven) ───────────────────────────── + +function buildDescription(capabilities: ModelCapability): string { + const head = renderPrompt(readMediaDescriptionHead, { MAX_MEDIA_MEGABYTES }); + const lines: string[] = [head]; + const hasImage = capabilities.image_in; + const hasVideo = capabilities.video_in; + if (hasImage && hasVideo) { + lines.push('- This tool supports image and video files for the current model.'); + } else if (hasImage) { + lines.push( + '- This tool supports image files for the current model.', + '- Video files are not supported by the current model.', + ); + } else if (hasVideo) { + lines.push( + '- This tool supports video files for the current model.', + '- Image files are not supported by the current model.', + ); + } else { + lines.push('- The current model does not support image or video input.'); + } + return lines.join('\n'); +} + +// ── System summary ─────────────────────────────────────────────────── + +/** + * Build the `` summary that precedes the media content. + * + * Carries mime type, byte size and (for images) the original pixel + * dimensions. When the dimensions are known it also guides the model to + * derive absolute coordinates from that original size; it always reminds + * the model to re-read any media it generates or edits. + */ +function buildSystemSummary(input: { + readonly kind: 'image' | 'video'; + readonly mimeType: string; + readonly byteSize: number; + readonly dimensions: { readonly width: number; readonly height: number } | null; +}): string { + const parts: string[] = [ + `Read ${input.kind} file.`, + `Mime type: ${input.mimeType}.`, + `Size: ${String(input.byteSize)} bytes.`, + ]; + // Coordinate guidance is only emitted when the original size is actually + // known — sniffing fails for some image formats (TIFF/ICO/HEIC/…), and + // telling the model to use a size that is not in the block would mislead it. + if (input.kind === 'image' && input.dimensions) { + parts.push( + `Original dimensions: ${String(input.dimensions.width)}x${String(input.dimensions.height)} pixels.`, + 'If you need to output coordinates, output relative coordinates first ' + + 'and compute absolute coordinates using the original image size.', + ); + } + parts.push( + 'If you generate or edit images or videos via commands or scripts, ' + + 'read the result back immediately before continuing.', + ); + return `${parts.join(' ')}`; +} + +// ── Implementation ─────────────────────────────────────────────────── + +export class ReadMediaFileTool implements BuiltinTool { + readonly name = 'ReadMediaFile' as const; + readonly description: string; + readonly parameters: Record = toInputJsonSchema(ReadMediaFileInputSchema); + constructor( + private readonly kaos: Kaos, + private readonly workspace: WorkspaceConfig, + private readonly capabilities: ModelCapability, + private readonly videoUploader?: VideoUploader | undefined, + ) { + if (!capabilities.image_in && !capabilities.video_in) { + const skip = new Error('ReadMediaFile requires image_in or video_in capability'); + skip.name = 'SkipThisTool'; + throw skip; + } + this.description = buildDescription(capabilities); + } + + resolveExecution(args: ReadMediaFileInput): ToolExecution { + const path = resolvePathAccessPath(args.path, { + kaos: this.kaos, + workspace: this.workspace, + operation: 'read', + }); + return { + accesses: ToolAccesses.readFile(path), + description: `Reading media: ${args.path}`, + execute: () => this.execution(args, path), + }; + } + + private async execution( + args: ReadMediaFileInput, + safePath: string, + ): Promise { + if (!args.path) { + return { isError: true, output: 'File path cannot be empty.' }; + } + + try { + // Sniff header first — read the first 512 bytes before deciding + // anything about MIME. + const header = await this.kaos.readBytes(safePath, MEDIA_SNIFF_BYTES); + const fileType = detectFileType(safePath, header); + + if (fileType.kind === 'text') { + return { + isError: true, + output: `"${args.path}" is a text file. Use Read to read text files.`, + }; + } + if (fileType.kind === 'unknown') { + return { + isError: true, + output: + `"${args.path}" is not a supported image or video file. ` + + 'Use Read for text files, or Bash or an MCP tool for other binary formats.', + }; + } + + if (fileType.kind === 'image' && !this.capabilities.image_in) { + return { + isError: true, + output: + 'The current model does not support image input. ' + + 'Tell the user to use a model with image input capability.', + }; + } + if (fileType.kind === 'video' && !this.capabilities.video_in) { + return { + isError: true, + output: + 'The current model does not support video input. ' + + 'Tell the user to use a model with video input capability.', + }; + } + + const stat = await this.kaos.stat(safePath); + if (stat.stSize === 0) { + return { isError: true, output: `"${args.path}" is empty.` }; + } + if (stat.stSize > MAX_MEDIA_BYTES) { + return { + isError: true, + output: + `"${args.path}" is ${String(stat.stSize)} bytes, which exceeds the ` + + `maximum ${String(MAX_MEDIA_MEGABYTES)}MB for media files.`, + }; + } + + const data = await this.kaos.readBytes(safePath); + const base64 = data.toString('base64'); + let mediaPart: ContentPart; + if (fileType.kind === 'image') { + mediaPart = { + type: 'image_url', + imageUrl: { url: `data:${fileType.mimeType};base64,${base64}` }, + }; + } else if (this.videoUploader !== undefined) { + mediaPart = await this.videoUploader({ + data, + mimeType: fileType.mimeType, + filename: safePath.split(/[\\/]/).at(-1), + }); + } else { + mediaPart = { + type: 'video_url', + videoUrl: { url: `data:${fileType.mimeType};base64,${base64}` }, + }; + } + + const tag = fileType.kind === 'image' ? 'image' : 'video'; + const openText = `<${tag} path="${safePath}">`; + const closeText = ``; + + const dimensions = + fileType.kind === 'image' ? sniffImageDimensions(data) : null; + const systemText = buildSystemSummary({ + kind: fileType.kind, + mimeType: fileType.mimeType, + byteSize: stat.stSize, + dimensions, + }); + + const output: ContentPart[] = [ + { type: 'text', text: systemText }, + { type: 'text', text: openText }, + mediaPart, + { type: 'text', text: closeText }, + ]; + + return { output, isError: false }; + } catch (error) { + return { + isError: true, + output: `Failed to read ${args.path}: ${error instanceof Error ? error.message : String(error)}`, + }; + } + } +} diff --git a/packages/agent-core/src/tools/builtin/file/read.md b/packages/agent-core/src/tools/builtin/file/read.md new file mode 100644 index 000000000..79bdda810 --- /dev/null +++ b/packages/agent-core/src/tools/builtin/file/read.md @@ -0,0 +1,17 @@ +Read a text file from the local filesystem. + +If the user provides a concrete file path to a text file, call Read directly. Do not `Glob`, `ls`, or otherwise pre-check known text file paths; missing or invalid file paths return errors you can handle. Do not use Read for directories; use `ls` via Bash for a known directory, or Glob when you need files/directories matching a pattern. Use `Grep` only when the task is to search for unknown content or locations. + +When you need several files, prefer to read them in parallel: emit multiple `Read` calls in a single response instead of reading one file per turn. + +- Relative paths resolve against the working directory; a path outside the working directory must be absolute. +- Returns up to {{ MAX_LINES }} lines or {{ MAX_BYTES_KB }} KB per call, whichever comes first; lines longer than {{ MAX_LINE_LENGTH }} chars are truncated mid-line. +- Page larger files with `line_offset` (1-based start line) and `n_lines`. Omit `n_lines` to read up to the {{ MAX_LINES }}-line cap. +- Sensitive files (`.env` files, credential stores, SSH keys, and similar secrets) are refused to protect secrets; do not attempt to read them. +- Only UTF-8 text files can be read. Non-UTF-8 encodings, binary files, and files containing NUL bytes are refused; use `ReadMediaFile` for images or video, and Bash or an MCP tool for other binary formats. +- Negative line_offset reads from the end of the file (for example, -100 reads the last 100 lines); the absolute value cannot exceed {{ MAX_LINES }}. +- Output format: `\t` per line. +- A `...` status block is appended after the file content; it summarizes how much was read (line and byte counts, truncation, line-ending notes) and is not part of the file itself. +- Pure CRLF files are displayed with LF line endings; `Edit` matches this output and preserves CRLF when writing back. +- Mixed or lone carriage-return line endings are shown as `\r` and require exact `Edit.old_string` escapes. +- After a successful `Edit`/`Write`, do not re-read solely to prove the write landed. When the task depends on an exact file, API, or output shape, inspect the final external contract before finishing. diff --git a/packages/agent-core/src/tools/builtin/file/read.ts b/packages/agent-core/src/tools/builtin/file/read.ts new file mode 100644 index 000000000..2e27b06a8 --- /dev/null +++ b/packages/agent-core/src/tools/builtin/file/read.ts @@ -0,0 +1,444 @@ +import type { Kaos, StatResult } from '@moonshot-ai/kaos'; +import { z } from 'zod'; + +import type { BuiltinTool } from '../../../agent/tool'; +import { ToolAccesses } from '../../../loop/tool-access'; +import type { ExecutableToolResult, ToolExecution } from '../../../loop/types'; +import { renderPrompt } from '../../../utils/render-prompt'; +import { resolvePathAccessPath } from '../../policies/path-access'; +import { MEDIA_SNIFF_BYTES, detectFileType } from '../../support/file-type'; +import { toInputJsonSchema } from '../../support/input-schema'; +import type { WorkspaceConfig } from '../../support/workspace'; +import { makeCarriageReturnsVisible, type LineEndingStyle } from './line-endings'; +import readDescriptionTemplate from './read.md'; + +export const MAX_LINES: number = 1000; +export const MAX_LINE_LENGTH: number = 2000; +export const MAX_BYTES: number = 100 * 1024; +const S_IFMT = 0o170000; +const S_IFREG = 0o100000; + +const PositiveLineOffsetSchema = z.number().int().min(1); +const TailLineOffsetSchema = z.number().int().min(-MAX_LINES).max(-1); + +export const ReadInputSchema = z.object({ + path: z + .string() + .describe( + 'Path to a text file. Relative paths resolve against the working directory; a path outside the working directory must be absolute. Directories are not supported; use `ls` via Bash for a known directory, or Glob for pattern search.', + ), + line_offset: z + .union([PositiveLineOffsetSchema, TailLineOffsetSchema]) + .optional() + .describe( + `The line number to start reading from. Omit to start at line 1. Negative values read from the end of the file; the absolute value cannot exceed ${String(MAX_LINES)}.`, + ), + n_lines: z + .number() + .int() + .positive() + .optional() + .describe( + `The number of lines to read; the tool also applies its internal cap. Omit to read up to the internal cap of ${String(MAX_LINES)} lines.`, + ), +}); + +export const ReadOutputSchema = z.object({ + content: z.string(), + lineCount: z.number().int().nonnegative(), +}); + +export type ReadInput = z.Infer; +export type ReadOutput = z.Infer; + +interface LineEndingFlags { + hasCrLf: boolean; + hasLf: boolean; + hasLoneCr: boolean; +} + +interface ReadLineEntry { + readonly lineNo: number; + readonly rawContent: string; +} + +interface RenderedLine { + readonly line: string; + readonly wasTruncated: boolean; +} + +interface FinishReadResultInput { + readonly renderedLines: readonly string[]; + readonly truncatedLineNumbers: readonly number[]; + readonly maxLinesReached: boolean; + readonly maxBytesReached: boolean; + readonly lineEndingStyle: LineEndingStyle; + readonly startLine: number; + readonly totalLines: number; + readonly requestedLines: number; +} + +function truncateLine(line: string, maxLength: number): string { + if (line.length <= maxLength) return line; + const marker = '...'; + const target = Math.max(maxLength, marker.length); + return line.slice(0, target - marker.length) + marker; +} + +function stripTrailingLf(line: string): string { + return line.endsWith('\n') ? line.slice(0, -1) : line; +} + +function updateLineEndingFlags(flags: LineEndingFlags, text: string): void { + for (let i = 0; i < text.length; i += 1) { + const code = text.codePointAt(i); + if (code === 13) { + if (text.codePointAt(i + 1) === 10) { + flags.hasCrLf = true; + i += 1; + } else { + flags.hasLoneCr = true; + } + } else if (code === 10) { + flags.hasLf = true; + } + } +} + +function lineEndingStyleFromFlags(flags: LineEndingFlags): LineEndingStyle { + if (flags.hasLoneCr || (flags.hasCrLf && flags.hasLf)) return 'mixed'; + if (flags.hasCrLf) return 'crlf'; + return 'lf'; +} + +function renderLine(entry: ReadLineEntry, lineEndingStyle: LineEndingStyle): RenderedLine { + const modelContent = + lineEndingStyle === 'crlf' && entry.rawContent.endsWith('\r') + ? entry.rawContent.slice(0, -1) + : entry.rawContent; + const truncated = truncateLine(modelContent, MAX_LINE_LENGTH); + const renderedContent = + lineEndingStyle === 'mixed' ? makeCarriageReturnsVisible(truncated) : truncated; + return { + line: `${String(entry.lineNo)}\t${renderedContent}`, + wasTruncated: truncated !== modelContent, + }; +} + +function renderedLineBytes(renderedLine: string, isFirst: boolean): number { + return (isFirst ? 0 : 1) + Buffer.byteLength(renderedLine, 'utf8'); +} + +function isRegularFileMode(stMode: number): boolean { + return (stMode & S_IFMT) === S_IFREG; +} + +function isFileNotFoundError(error: unknown): boolean { + if (typeof error !== 'object' || error === null) return false; + const code = (error as { code?: unknown })['code']; + return code === 'ENOENT' || code === 'ENOTDIR'; +} + +function isTextDecodeError(error: unknown): boolean { + if (typeof error !== 'object' || error === null) return false; + const code = (error as { code?: unknown })['code']; + if (code === 'ERR_ENCODING_INVALID_ENCODED_DATA') return true; + if (!(error instanceof Error)) return false; + return /encoded data was not valid|invalid.*encoding|invalid.*utf-?8/i.test(error.message); +} + +function containsNulByte(text: string): boolean { + return text.includes('\u0000'); +} + +function notReadableFileOutput(path: string): string { + return ( + `"${path}" is not readable as UTF-8 text. ` + + 'If it is an image or video, use ReadMediaFile. ' + + 'For other binary formats, use Bash or an MCP tool if available.' + ); +} + +const READ_DESCRIPTION = renderPrompt(readDescriptionTemplate, { + MAX_LINES, + MAX_BYTES_KB: MAX_BYTES / 1024, + MAX_LINE_LENGTH, +}); + +export class ReadTool implements BuiltinTool { + readonly name = 'Read' as const; + readonly description = READ_DESCRIPTION; + readonly parameters: Record = toInputJsonSchema(ReadInputSchema); + constructor( + private readonly kaos: Kaos, + private readonly workspace: WorkspaceConfig, + ) {} + + resolveExecution(args: ReadInput): ToolExecution { + const path = resolvePathAccessPath(args.path, { + kaos: this.kaos, + workspace: this.workspace, + operation: 'read', + }); + return { + accesses: ToolAccesses.readFile(path), + description: `Reading ${args.path}`, + execute: () => this.execution(args, path), + }; + } + + private async execution(args: ReadInput, safePath: string): Promise { + try { + let stat: StatResult; + try { + stat = await this.kaos.stat(safePath); + } catch (error) { + if (isFileNotFoundError(error)) { + return { isError: true, output: `"${args.path}" does not exist.` }; + } + throw error; + } + if (!isRegularFileMode(stat.stMode)) { + return { isError: true, output: `"${args.path}" is not a file.` }; + } + + const header = await this.kaos.readBytes(safePath, MEDIA_SNIFF_BYTES); + const fileType = detectFileType(safePath, header); + if (fileType.kind === 'image' || fileType.kind === 'video') { + return { + isError: true, + output: `"${args.path}" is a ${fileType.kind} file. Use ReadMediaFile to read image or video files.`, + }; + } + if (fileType.kind === 'unknown') { + return { + isError: true, + output: notReadableFileOutput(args.path), + }; + } + + const lineOffset = args.line_offset ?? 1; + const requestedLines = args.n_lines ?? MAX_LINES; + const effectiveLimit = Math.min(requestedLines, MAX_LINES); + + if (lineOffset < 0) { + return await this.readTail( + safePath, + args.path, + lineOffset, + effectiveLimit, + requestedLines, + ); + } + return await this.readForward( + safePath, + args.path, + lineOffset, + effectiveLimit, + requestedLines, + ); + } catch (error) { + if (isTextDecodeError(error)) { + return { isError: true, output: notReadableFileOutput(args.path) }; + } + return { + isError: true, + output: error instanceof Error ? error.message : String(error), + }; + } + } + + private async readForward( + safePath: string, + displayPath: string, + lineOffset: number, + effectiveLimit: number, + requestedLines: number, + ): Promise { + const selectedEntries: ReadLineEntry[] = []; + const flags: LineEndingFlags = { hasCrLf: false, hasLf: false, hasLoneCr: false }; + let currentLineNo = 0; + let maxLinesReached = false; + let collectionClosed = false; + + for await (const rawLine of this.kaos.readLines(safePath, { errors: 'strict' })) { + if (containsNulByte(rawLine)) { + return { isError: true, output: notReadableFileOutput(displayPath) }; + } + currentLineNo += 1; + updateLineEndingFlags(flags, rawLine); + if (collectionClosed) { + if (effectiveLimit >= MAX_LINES && currentLineNo >= lineOffset) { + maxLinesReached = true; + } + continue; + } + if (currentLineNo < lineOffset) continue; + if (selectedEntries.length >= effectiveLimit) { + if (effectiveLimit >= MAX_LINES) { + maxLinesReached = true; + } + collectionClosed = true; + continue; + } + selectedEntries.push({ + lineNo: currentLineNo, + rawContent: stripTrailingLf(rawLine), + }); + if (selectedEntries.length >= effectiveLimit) { + collectionClosed = true; + } + } + + const lineEndingStyle = lineEndingStyleFromFlags(flags); + const renderedLines: string[] = []; + const truncatedLineNumbers: number[] = []; + let bytes = 0; + let maxBytesReached = false; + + for (const entry of selectedEntries) { + const rendered = renderLine(entry, lineEndingStyle); + const lineBytes = renderedLineBytes(rendered.line, renderedLines.length === 0); + if (renderedLines.length > 0 && bytes + lineBytes > MAX_BYTES) { + maxBytesReached = true; + break; + } + + if (rendered.wasTruncated) { + truncatedLineNumbers.push(entry.lineNo); + } + renderedLines.push(rendered.line); + bytes += lineBytes; + if (bytes >= MAX_BYTES) { + maxBytesReached = true; + break; + } + } + + return this.finishReadResult({ + renderedLines, + truncatedLineNumbers, + maxLinesReached, + maxBytesReached, + lineEndingStyle, + startLine: renderedLines.length > 0 ? lineOffset : 0, + totalLines: currentLineNo, + requestedLines, + }); + } + + private async readTail( + safePath: string, + displayPath: string, + lineOffset: number, + effectiveLimit: number, + requestedLines: number, + ): Promise { + const tailCount = Math.abs(lineOffset); + const entries: ReadLineEntry[] = []; + const flags: LineEndingFlags = { hasCrLf: false, hasLf: false, hasLoneCr: false }; + let currentLineNo = 0; + + for await (const rawLine of this.kaos.readLines(safePath, { errors: 'strict' })) { + if (containsNulByte(rawLine)) { + return { isError: true, output: notReadableFileOutput(displayPath) }; + } + currentLineNo += 1; + updateLineEndingFlags(flags, rawLine); + entries.push({ + lineNo: currentLineNo, + rawContent: stripTrailingLf(rawLine), + }); + if (entries.length > tailCount) { + entries.shift(); + } + } + + const lineEndingStyle = lineEndingStyleFromFlags(flags); + let renderedCandidates = entries.slice(0, effectiveLimit).map((entry) => { + return { entry, rendered: renderLine(entry, lineEndingStyle) }; + }); + + let totalBytes = 0; + for (const [index, candidate] of renderedCandidates.entries()) { + totalBytes += renderedLineBytes(candidate.rendered.line, index === 0); + } + + let maxBytesReached = false; + if (totalBytes > MAX_BYTES) { + maxBytesReached = true; + const kept: typeof renderedCandidates = []; + let bytes = 0; + for (let i = renderedCandidates.length - 1; i >= 0; i -= 1) { + const candidate = renderedCandidates[i]; + if (candidate === undefined) continue; + const lineBytes = renderedLineBytes(candidate.rendered.line, kept.length === 0); + if (bytes + lineBytes > MAX_BYTES) break; + kept.unshift(candidate); + bytes += lineBytes; + } + renderedCandidates = kept; + } + + const renderedLines: string[] = []; + const truncatedLineNumbers: number[] = []; + for (const candidate of renderedCandidates) { + renderedLines.push(candidate.rendered.line); + if (candidate.rendered.wasTruncated) { + truncatedLineNumbers.push(candidate.entry.lineNo); + } + } + + return this.finishReadResult({ + renderedLines, + truncatedLineNumbers, + maxLinesReached: false, + maxBytesReached, + lineEndingStyle, + startLine: renderedCandidates[0]?.entry.lineNo ?? 0, + totalLines: currentLineNo, + requestedLines, + }); + } + + private finishReadResult(input: FinishReadResultInput): ExecutableToolResult { + return { + output: this.finishOutput(input.renderedLines, this.finishMessage(input)), + }; + } + + private finishOutput(renderedLines: readonly string[], message: string): string { + const rendered = renderedLines.join('\n'); + const status = `${message}`; + return rendered.length > 0 ? `${rendered}\n${status}` : status; + } + + private finishMessage(input: FinishReadResultInput): string { + const lineCount = input.renderedLines.length; + const lineWord = lineCount === 1 ? 'line' : 'lines'; + const parts = + lineCount > 0 + ? [ + `${String(lineCount)} ${lineWord} read from file starting from line ${String(input.startLine)}.`, + ] + : ['No lines read from file.']; + + parts.push(`Total lines in file: ${String(input.totalLines)}.`); + if (input.maxLinesReached) { + parts.push(`Max ${String(MAX_LINES)} lines reached.`); + } else if (input.maxBytesReached) { + parts.push(`Max ${String(MAX_BYTES)} bytes reached.`); + } else if (lineCount < input.requestedLines) { + parts.push('End of file reached.'); + } + if (input.truncatedLineNumbers.length > 0) { + parts.push(`Lines [${input.truncatedLineNumbers.join(', ')}] were truncated.`); + } + if (input.lineEndingStyle === 'mixed') { + parts.push( + 'Mixed or lone carriage-return line endings are shown as \\r. Use exact \\r\\n or \\r escapes in Edit.old_string for those lines.', + ); + } + return parts.join(' '); + } +} diff --git a/packages/agent-core/src/tools/builtin/file/write.md b/packages/agent-core/src/tools/builtin/file/write.md new file mode 100644 index 000000000..5d82cd148 --- /dev/null +++ b/packages/agent-core/src/tools/builtin/file/write.md @@ -0,0 +1 @@ +Overwrite or append to a file with content exactly as provided, creating the file if needed; the parent directory must already exist. Defaults to overwrite; append adds content to the end without adding a newline. Write does not use the Read/Edit model text view and does not preserve or infer the previous line-ending style: \n stays LF, \r\n stays CRLF. Use Edit for targeted changes to existing files. When the content is very large, you can split it across multiple calls: write the first chunk with overwrite, then add the remaining chunks with append. diff --git a/packages/agent-core/src/tools/builtin/file/write.ts b/packages/agent-core/src/tools/builtin/file/write.ts new file mode 100644 index 000000000..593980070 --- /dev/null +++ b/packages/agent-core/src/tools/builtin/file/write.ts @@ -0,0 +1,144 @@ +/** + * WriteTool — overwrite or append to a file. + * + * Creates the file if it does not exist; parent directory must already exist. + * Path access policy is resolved before any Kaos I/O. + */ + +import type { Kaos } from '@moonshot-ai/kaos'; +import * as posixPath from 'node:path/posix'; +import * as win32Path from 'node:path/win32'; +import { z } from 'zod'; + +import type { BuiltinTool } from '../../../agent/tool'; +import { ToolAccesses } from '../../../loop/tool-access'; +import type { ExecutableToolResult, ToolExecution } from '../../../loop/types'; +import { + type PathClass, + resolvePathAccessPath, +} from '../../policies/path-access'; +import { toInputJsonSchema } from '../../support/input-schema'; +import type { WorkspaceConfig } from '../../support/workspace'; +import WRITE_DESCRIPTION from './write.md'; + +/** Mask isolating the file-type bits of a stat mode. */ +const S_IFMT = 0o170000; +/** File-type bits of a directory. */ +const S_IFDIR = 0o040000; + +function pathMod(pathClass: PathClass): typeof posixPath { + return pathClass === 'win32' ? win32Path : posixPath; +} + +export const WriteInputSchema = z.object({ + path: z + .string() + .describe( + 'Path to the file to create, append to, or completely overwrite. Relative paths resolve against the working directory; a path outside the working directory must be absolute. The parent directory must already exist.', + ), + content: z + .string() + .describe( + 'Raw full file content to write exactly as provided. This does not use the Read/Edit text view.', + ), + mode: z + .enum(['overwrite', 'append']) + .optional() + .describe( + 'Write mode. Defaults to overwrite. append adds content to the end exactly as provided and does not add a newline.', + ), +}); + +export const WriteOutputSchema = z.object({ + /** Number of UTF-8 bytes written to disk by this call. */ + bytesWritten: z.number().int().nonnegative(), +}); + +export type WriteInput = z.Infer; +export type WriteOutput = z.Infer; + +export class WriteTool implements BuiltinTool { + readonly name = 'Write' as const; + readonly description = WRITE_DESCRIPTION; + readonly parameters: Record = toInputJsonSchema(WriteInputSchema); + + constructor( + private readonly kaos: Kaos, + private readonly workspace: WorkspaceConfig, + ) {} + + resolveExecution(args: WriteInput): ToolExecution { + const path = resolvePathAccessPath(args.path, { + kaos: this.kaos, + workspace: this.workspace, + operation: 'write', + }); + return { + accesses: ToolAccesses.writeFile(path), + description: `Writing ${args.path}`, + execute: () => this.execution(args, path), + }; + } + + private async execution(args: WriteInput, safePath: string): Promise { + const parentError = await this.checkParentDirectory(safePath); + if (parentError !== undefined) { + return { isError: true, output: parentError }; + } + + try { + const mode = args.mode ?? 'overwrite'; + if (mode === 'append') { + await this.kaos.writeText(safePath, args.content, { mode: 'a' }); + } else { + await this.kaos.writeText(safePath, args.content); + } + // Report the number of UTF-8 bytes this call wrote to disk. The string + // length would only equal the byte count for pure ASCII content, so it + // is not used here. + const bytesWritten = Buffer.byteLength(args.content, 'utf8'); + return { + output: `${mode === 'append' ? 'Appended' : 'Wrote'} ${String(bytesWritten)} bytes to ${args.path}`, + }; + } catch (error) { + const code = (error as { code?: unknown } | null)?.code; + if (code === 'ENOENT') { + return { + isError: true, + output: `Failed to write ${args.path}: parent directory does not exist.`, + }; + } + return { + isError: true, + output: error instanceof Error ? error.message : String(error), + }; + } + } + + /** + * Best-effort check that the parent directory exists and is a directory. + * + * The path schema documents this precondition; probing it up front turns a + * bare `ENOENT` from the underlying write into an actionable message. + * Returns an error string when the precondition is definitively violated, + * or `undefined` otherwise. Any other `stat` failure (permissions, an + * environment without `stat`) is treated as inconclusive: the check is + * skipped and the write proceeds, surfacing the real I/O error if any. + */ + private async checkParentDirectory(safePath: string): Promise { + const parent = pathMod(this.kaos.pathClass()).dirname(safePath); + let stat; + try { + stat = await this.kaos.stat(parent); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + return `Parent directory does not exist: ${parent}. Create it before writing this file.`; + } + return undefined; + } + if ((stat.stMode & S_IFMT) !== S_IFDIR) { + return `Parent path is not a directory: ${parent}.`; + } + return undefined; + } +} diff --git a/packages/agent-core/src/tools/builtin/index.ts b/packages/agent-core/src/tools/builtin/index.ts new file mode 100644 index 000000000..046d47557 --- /dev/null +++ b/packages/agent-core/src/tools/builtin/index.ts @@ -0,0 +1,19 @@ +export * from '../background/manager'; +export * from '../background/task-list'; +export * from '../background/task-output'; +export * from '../background/task-stop'; +export * from './collaboration/agent'; +export * from './collaboration/ask-user'; +export * from './collaboration/skill-tool'; +export * from './file/edit'; +export * from './file/glob'; +export * from './file/grep'; +export * from './file/read'; +export * from './file/read-media'; +export * from './file/write'; +export * from './planning/enter-plan-mode'; +export * from './planning/exit-plan-mode'; +export * from './shell/bash'; +export * from './state/todo-list'; +export * from './web/fetch-url'; +export * from './web/web-search'; diff --git a/packages/agent-core/src/tools/builtin/planning/enter-plan-mode.md b/packages/agent-core/src/tools/builtin/planning/enter-plan-mode.md new file mode 100644 index 000000000..d792e71b7 --- /dev/null +++ b/packages/agent-core/src/tools/builtin/planning/enter-plan-mode.md @@ -0,0 +1,32 @@ +Use this tool proactively when you're about to start a non-trivial implementation task. +Getting user sign-off on your approach via ExitPlanMode before writing code prevents wasted effort. + +Use it when ANY of these conditions apply: + +1. New Feature Implementation - e.g. "Add a caching layer to the API" +2. Multiple Valid Approaches - e.g. "Optimize database queries" (indexing vs rewrite vs caching) +3. Code Modifications - e.g. "Refactor auth module to support OAuth" +4. Architectural Decisions - e.g. "Add WebSocket support" +5. Multi-File Changes - involves more than 2-3 files +6. Unclear Requirements - need exploration to understand scope +7. User Preferences Matter - if user input would materially change the implementation approach, use EnterPlanMode to structure the decision + +Permission mode notes: +- EnterPlanMode enters plan mode automatically without an approval prompt in all permission modes. +- In yolo and manual modes, ExitPlanMode still presents the plan to the user for approval. +- In auto permission mode, do not use AskUserQuestion; make the best decision from available context. +- In auto permission mode, ExitPlanMode exits plan mode without asking the user. +- Use EnterPlanMode only when planning itself adds value. + +When NOT to use: +- Single-line or few-line fixes (typos, obvious bugs, small tweaks) +- User gave very specific, detailed instructions +- Pure research/exploration tasks + +## What Happens in Plan Mode +In plan mode, you will: +1. Identify 2-3 key questions about the codebase that are critical to your plan. If you are not confident about the codebase structure or relevant code paths, use `Agent(subagent_type="explore")` to investigate these questions first - this is strongly recommended for non-trivial tasks. +2. Explore the codebase using Glob, Grep, Read, and other read-only tools for any remaining quick lookups. Use Bash only when needed; Bash follows the normal permission mode and rules. +3. Design an implementation approach based on your findings +4. Write your plan to the current plan file with Write or Edit +5. Present your plan to the user via ExitPlanMode for approval diff --git a/packages/agent-core/src/tools/builtin/planning/enter-plan-mode.ts b/packages/agent-core/src/tools/builtin/planning/enter-plan-mode.ts new file mode 100644 index 000000000..b15b78c6c --- /dev/null +++ b/packages/agent-core/src/tools/builtin/planning/enter-plan-mode.ts @@ -0,0 +1,82 @@ +/** + * EnterPlanModeTool — plan-mode entry tool. + * + * The LLM calls this tool to enter plan mode directly. Entering plan mode + * does not require approval in any permission mode. + */ + + +import type { Agent } from '#/agent'; +import { z } from 'zod'; + +import type { BuiltinTool } from '../../../agent/tool'; +import type { ToolExecution } from '../../../loop/types'; +import { toInputJsonSchema } from '../../support/input-schema'; +import DESCRIPTION from './enter-plan-mode.md'; + +// ── Input schema ───────────────────────────────────────────────────── + +export const EnterPlanModeInputSchema = z.object({}).strict(); +export type EnterPlanModeInput = z.infer; + +export class EnterPlanModeTool implements BuiltinTool { + readonly name = 'EnterPlanMode' as const; + readonly description: string = DESCRIPTION; + readonly parameters: Record = toInputJsonSchema(EnterPlanModeInputSchema); + + constructor(private readonly agent: Agent) {} + + resolveExecution(_args: EnterPlanModeInput): ToolExecution { + return { + description: 'Requesting to enter plan mode', + execute: async () => { + // Guard: already in plan mode + if (this.agent.planMode.isActive) { + return { + isError: true, + output: 'Plan mode is already active. Use ExitPlanMode when the plan is ready.', + }; + } + + try { + await this.agent.planMode.enter(); + } catch (error) { + const message = error instanceof Error ? error.message : 'Failed to enter plan mode.'; + return { isError: true, output: `Failed to enter plan mode: ${message}` }; + } + + this.agent.telemetry.track('plan_enter_resolved', { outcome: 'auto_approved' }); + return { output: enteredPlanModeMessage(this.agent.planMode.planFilePath) }; + }, + }; + } +} + +function enteredPlanModeMessage(planPath: string | null): string { + if (planPath === null) { + return [ + 'Plan mode is now active. Your workflow:', + '', + '1. Use read-only tools (Read, Grep, Glob) to investigate the codebase. Use Bash only when needed.', + '2. Design a concrete, step-by-step plan.', + '3. Wait for the host to provide a plan file path before calling ExitPlanMode.', + '', + 'Do NOT use Write or Edit while plan mode is active in this host; no plan file path is available.', + 'Use Bash only when needed; Bash follows the normal permission mode and rules.', + ].join('\n'); + } + + return [ + 'Plan mode is now active. Your workflow:', + '', + `Plan file: ${planPath}`, + '', + '1. Use read-only tools (Read, Grep, Glob) to investigate the codebase. Use Bash only when needed.', + '2. Design a concrete, step-by-step plan.', + '3. Write the plan to the plan file with Write or Edit.', + '4. When the plan is ready, call ExitPlanMode for user approval.', + '', + 'Do NOT edit files other than the plan file while plan mode is active.', + 'Use Bash only when needed; Bash follows the normal permission mode and rules.', + ].join('\n'); +} diff --git a/packages/agent-core/src/tools/builtin/planning/exit-plan-mode.md b/packages/agent-core/src/tools/builtin/planning/exit-plan-mode.md new file mode 100644 index 000000000..b83f47f38 --- /dev/null +++ b/packages/agent-core/src/tools/builtin/planning/exit-plan-mode.md @@ -0,0 +1,29 @@ +Use this tool when you are in plan mode and have finished writing your plan to the plan file and are ready for user approval. + +## How This Tool Works +- You should have already written your plan to the plan file specified in the plan mode reminder. +- This tool does NOT take the plan content as a parameter - it reads the plan from the file you wrote. +- The user will see the contents of your plan file when they review it. In auto permission mode, the tool reads the file and exits plan mode without asking the user. + +## When to Use +Only use this tool for tasks that require planning implementation steps. For research tasks (searching files, reading code, understanding the codebase), do NOT use this tool. + +## Multiple Approaches +If your plan contains multiple alternative approaches: +- Pass them via the `options` parameter so the user can choose which approach to execute. +- Each option should have a concise label and a brief description of trade-offs. +- If you recommend one option, append "(Recommended)" to its label. +- In yolo and manual modes, the user will see all options alongside Reject and Revise choices. +- Provide up to 3 options; the host adds the standard rejection and revision controls. When the plan offers a real choice, 2-3 distinct approaches work best. +- Passing a single option is allowed and is equivalent to a plain plan approval (no approach choice is surfaced to the user). +- Do NOT use "Reject", "Reject and Exit", "Revise", or "Approve" as option labels - these are reserved by the system. + +## Before Using +- In auto permission mode, do NOT use AskUserQuestion; make the best decision from available context. +- In auto permission mode, this tool exits plan mode without asking the user. +- In yolo and manual modes, this tool still presents the plan to the user for approval. +- If auto permission mode is not active and you have unresolved questions, use AskUserQuestion first. +- If auto permission mode is not active and you have multiple approaches and haven't narrowed down yet, consider using AskUserQuestion first to let the user choose, then write a plan for the chosen approach only. +- Once your plan is finalized, use THIS tool to request approval. +- Do NOT use AskUserQuestion to ask "Is this plan OK?" or "Should I proceed?" - that is exactly what ExitPlanMode does. +- If rejected, revise based on feedback and call ExitPlanMode again. diff --git a/packages/agent-core/src/tools/builtin/planning/exit-plan-mode.ts b/packages/agent-core/src/tools/builtin/planning/exit-plan-mode.ts new file mode 100644 index 000000000..32a0983bb --- /dev/null +++ b/packages/agent-core/src/tools/builtin/planning/exit-plan-mode.ts @@ -0,0 +1,242 @@ +/** + * ExitPlanModeTool — plan-mode exit tool. + * + * The LLM calls this tool to surface a finalised plan to the user and + * exit plan mode. The plan must already be written to the current plan + * file; this tool reads that file and flips plan mode off. PermissionManager + * handles plan approval before this tool runs and passes any selected option + * through execution metadata. + */ + +import type { Agent } from '#/agent'; +import { z } from 'zod'; + +import type { BuiltinTool } from '../../../agent/tool'; +import type { ExecutableToolContext, ExecutableToolResult, ToolExecution } from '../../../loop/types'; +import { toInputJsonSchema } from '../../support/input-schema'; +import DESCRIPTION from './exit-plan-mode.md'; + +// ── Input schema ───────────────────────────────────────────────────── + +/** + * User-selectable option surfaced at plan approval time. The LLM supplies + * up to 3 of these when the plan contains multiple approaches; the host's + * ApprovalRuntime presents them to the user and returns the chosen `label` + * (or `{kind:'revise', feedback}` when the user asks for revisions). + */ +export interface ExitPlanModeOption { + label: string; + description: string; +} + +export interface ExitPlanModeInput { + options?: readonly ExitPlanModeOption[] | undefined; +} + +const RESERVED_OPTION_LABELS = new Set( + ['Approve', 'Reject', 'Reject and Exit', 'Revise'].map(normalizeOptionLabel), +); + +const ExitPlanModeOptionSchema = z + .object({ + label: z + .string() + .min(1) + .max(80) + .describe( + 'Short name for this option (1-8 words). Append "(Recommended)" if you recommend this option.', + ), + description: z + .string() + .default('') + .describe('Brief summary of this approach and its trade-offs.'), + }) + .strict(); + +export const ExitPlanModeInputSchema: z.ZodType = z + .object({ + options: z + .array(ExitPlanModeOptionSchema) + .min(1) + .max(3) + .refine(hasUniqueOptionLabels, 'Option labels must be unique.') + .refine(hasNoReservedOptionLabels, 'Option labels must not use reserved approval labels.') + .optional() + .describe( + 'When the plan contains multiple alternative approaches, list them here so the user can choose which one to execute. Provide up to 3 options; 2-3 distinct approaches work best when the plan offers a real choice. Passing a single option is allowed and is equivalent to a plain plan approval. Each option represents a distinct approach from the plan. Do not use "Reject", "Revise", "Approve", or "Reject and Exit" as labels.', + ), + }) + .strict(); + +export interface ExitPlanModePlanSource { + plan: string; + path?: string | undefined; +} + +type ResolvePlanResult = + | { readonly ok: true; readonly plan: string; readonly path?: string | undefined } + | { readonly ok: false; readonly error: ExecutableToolResult }; + +// ── Implementation ─────────────────────────────────────────────────── + +export class ExitPlanModeTool implements BuiltinTool { + readonly name = 'ExitPlanMode' as const; + readonly description: string = DESCRIPTION; + readonly parameters: Record = toInputJsonSchema(ExitPlanModeInputSchema); + + constructor(private readonly agent: Agent) {} + + resolveExecution(args: ExitPlanModeInput): ToolExecution { + return { + description: 'Presenting plan and exiting plan mode', + execute: (ctx) => this.execution(args, ctx), + }; + } + + private async execution( + args: ExitPlanModeInput, + { + metadata, + }: ExecutableToolContext, + ): Promise { + if (!this.agent.planMode.isActive) { + return { + isError: true, + output: + 'ExitPlanMode can only be called while plan mode is active. Use EnterPlanMode (or /plan) first.', + }; + } + + const resolvedPlan = await this.resolvePlan(); + if (!resolvedPlan.ok) return resolvedPlan.error; + + if (!planTelemetryWasSubmitted(metadata)) { + this.agent.telemetry.track('plan_submitted', { + has_options: args.options !== undefined && args.options.length >= 2, + }); + } + return this.exitWithPlan( + resolvedPlan.plan, + resolvedPlan.path, + selectedOptionFromMetadata(metadata), + planTelemetryWasResolved(metadata), + ); + } + + private async exitWithPlan( + plan: string, + path: string | undefined, + option: ExitPlanModeOption | undefined = undefined, + telemetryResolved = false, + ): Promise { + const failed = this.exitPlanMode(); + if (failed !== undefined) return failed; + + if (!telemetryResolved) { + this.agent.telemetry.track('plan_resolved', { outcome: 'auto_approved' }); + } + const optionPrefix = + option === undefined + ? '' + : `Selected approach: ${option.label}\nExecute ONLY the selected approach. Do not execute any unselected alternatives.\n\n`; + return { + isError: false, + output: `Exited plan mode. ${optionPrefix}${formatPlanForOutput(plan, path)}`, + }; + } + + private exitPlanMode(): ExecutableToolResult | undefined { + try { + this.agent.planMode.exit(); + } catch (error) { + const message = error instanceof Error ? error.message : 'Failed to exit plan mode.'; + return { + isError: true, + output: `Failed to exit plan mode: ${message}`, + }; + } + } + + private async resolvePlan(): Promise { + let source: ExitPlanModePlanSource | null; + try { + const data = await this.agent.planMode.data(); + source = data === null ? null : { plan: data.content, path: data.path }; + } catch (error) { + const message = error instanceof Error ? error.message : 'Failed to read plan file.'; + return { + ok: false, + error: { isError: true, output: `Failed to read plan file: ${message}` }, + }; + } + + if (source !== null && source.plan.trim().length > 0) { + return { + ok: true, + plan: source.plan, + path: source.path, + }; + } + + const path = source?.path ?? this.agent.planMode.planFilePath; + return { + ok: false, + error: { + isError: true, + output: + path === null + ? 'No plan file found. Write the plan to the current plan file first, then call ExitPlanMode.' + : `No plan file found. Write your plan to ${path} first, then call ExitPlanMode.`, + }, + }; + } +} + +function hasUniqueOptionLabels(options: readonly ExitPlanModeOption[]): boolean { + const labels = new Set(); + for (const option of options) { + const label = normalizeOptionLabel(option.label); + if (labels.has(label)) return false; + labels.add(label); + } + return true; +} + +function hasNoReservedOptionLabels(options: readonly ExitPlanModeOption[]): boolean { + return options.every((option) => !RESERVED_OPTION_LABELS.has(normalizeOptionLabel(option.label))); +} + +function normalizeOptionLabel(label: string): string { + return label.trim().toLowerCase(); +} + +function selectedOptionFromMetadata(metadata: unknown): ExitPlanModeOption | undefined { + if (metadata === null || typeof metadata !== 'object') return undefined; + const selectedOption = (metadata as { readonly selectedOption?: unknown }).selectedOption; + if (selectedOption === null || typeof selectedOption !== 'object') return undefined; + const label = (selectedOption as { readonly label?: unknown }).label; + const description = (selectedOption as { readonly description?: unknown }).description; + if (typeof label !== 'string' || typeof description !== 'string') return undefined; + return { label, description }; +} + +function planTelemetryWasSubmitted(metadata: unknown): boolean { + return ( + metadata !== null && + typeof metadata === 'object' && + (metadata as { readonly planTelemetrySubmitted?: unknown }).planTelemetrySubmitted === true + ); +} + +function planTelemetryWasResolved(metadata: unknown): boolean { + return ( + metadata !== null && + typeof metadata === 'object' && + (metadata as { readonly planTelemetryResolved?: unknown }).planTelemetryResolved === true + ); +} + +function formatPlanForOutput(plan: string, path: string | undefined): string { + const savedTo = path !== undefined ? `Plan saved to: ${path}\n\n` : ''; + return `Plan mode deactivated. All tools are now available.\n${savedTo}## Approved Plan:\n${plan}`; +} diff --git a/packages/agent-core/src/tools/builtin/shell/bash.md b/packages/agent-core/src/tools/builtin/shell/bash.md new file mode 100644 index 000000000..a2afff7b4 --- /dev/null +++ b/packages/agent-core/src/tools/builtin/shell/bash.md @@ -0,0 +1,43 @@ +Execute a `{{ SHELL_NAME }}` command. Use this for shell semantics — pipes, env, processes, git, package managers, build/test runners, anything genuinely interactive or multi-step. + +**Translate these to a dedicated tool instead:** +- `cat` / `head` / `tail` (known path) → `Read` +- `sed` / `awk` (in-place edit) → `Edit` +- `echo > file` / `cat <` is fine for listing a directory) +- `grep` / `rg` (search file contents) → `Grep` +- `echo` / `printf` (talk to the user) → just output text directly + +The dedicated tools render in the per-tool permission UI and keep raw stdout out of the conversation; that is why they are worth reaching for whenever one fits. + +**Output:** +The stdout and stderr will be combined and returned as a string. The output may be truncated if it is too long. If the command failed, the output will end with a `Command failed with exit code: N` line stating the non-zero exit code. + +If `run_in_background=true`, the command will be started as a background task and this tool will return a task ID instead of waiting for command completion. When doing that, you must provide a short `description`. Background commands default to a {{ DEFAULT_BACKGROUND_TIMEOUT_S }}s timeout and `timeout` is capped at {{ MAX_BACKGROUND_TIMEOUT_S }}s; set `disable_timeout=true` only when the task should run without a timeout. You will be automatically notified when the task completes. Use `TaskOutput` for a non-blocking status/output snapshot, and only set `block=true` when you explicitly want to wait for completion. Use `TaskStop` only if the task must be cancelled. If a human user wants to inspect background tasks themselves, point them to the `/tasks` command, which opens an interactive panel; it has no subcommands. + +**Guidelines for safety and security:** +- Each shell tool call will be executed in a fresh shell environment. The shell variables, current working directory changes, and the shell history is not preserved between calls. +- The tool call will return after the command is finished. You shall not use this tool to execute an interactive command or a command that may run forever. For possibly long-running foreground commands, set the `timeout` argument in seconds. Foreground commands default to {{ DEFAULT_TIMEOUT_S }}s and allow up to {{ MAX_TIMEOUT_S }}s. +- Avoid using `..` to access files or directories outside of the working directory. +- Avoid modifying files outside of the working directory unless explicitly instructed to do so. +- Never run commands that require superuser privileges unless explicitly instructed to do so. + +**Guidelines for efficiency:** +- For multiple related commands, use `&&` to chain them in a single call, e.g. `cd /path && ls -la` +- Use `;` to run commands sequentially regardless of success/failure +- Use `||` for conditional execution (run second command only if first fails) +- Use pipe operations (`|`) and redirections (`>`, `>>`) to chain input and output between commands +- Always quote file paths containing spaces with double quotes (e.g., cd "/path with spaces/") +- Compose multi-step logic in a single call with `if` / `case` / `for` / `while` control flows. +- Prefer `run_in_background=true` for long-running builds, tests, watchers, or servers when you need the conversation to continue before the command finishes. + +**Commands available:** +The following common command categories are usually available. Availability still depends on the host, so when in doubt run `which ` first to confirm a command exists before relying on it. +- Navigation and inspection: `ls`, `pwd`, `cd`, `stat`, `file`, `du`, `df`, `tree` +- File and directory management: `cp`, `mv`, `rm`, `mkdir`, `touch`, `ln`, `chmod`, `chown` +- Text and data processing: `wc`, `sort`, `uniq`, `cut`, `tr`, `diff`, `xargs` +- Archives and compression: `tar`, `gzip`, `gunzip`, `zip`, `unzip` +- Networking and transfer: `curl`, `wget`, `ping`, `ssh`, `scp` +- Version control: `git` +- Process and system: `ps`, `kill`, `top`, `env`, `date`, `uname`, `whoami` +- Language and package toolchains: `node`, `npm`, `pnpm`, `yarn`, `python`, `pip` (use whichever the project actually relies on) diff --git a/packages/agent-core/src/tools/builtin/shell/bash.ts b/packages/agent-core/src/tools/builtin/shell/bash.ts new file mode 100644 index 000000000..148b899d0 --- /dev/null +++ b/packages/agent-core/src/tools/builtin/shell/bash.ts @@ -0,0 +1,473 @@ +/** + * BashTool — execute shell commands. + * + * Invokes bash (POSIX) according to an injected `Environment`. On Windows + * the shell is Git Bash; the path is resolved by `detectEnvironment`. + * + * Dependencies injected via constructor: + * - `Kaos` — shell execution abstraction (exec / execWithEnv) + * - `cwd` — default working directory for commands + * - `Environment` — cross-platform probe (shellName / shellPath) + * - `BackgroundProcessManager?` — optional: required iff run_in_background=true + * + * Execution goes through Kaos, never directly via node:child_process. + * + * Hardening: + * - `args.timeout` (seconds) and the ambient `signal` both drive + * `Promise.race`; fire-a-kill on either edge. + * - stdin is closed immediately so interactive commands (`cat`, `read`, + * `python -c 'input()'`) receive EOF instead of hanging. + * - Two-phase kill: SIGTERM → 5s grace → SIGKILL (Kaos honours this + * contract cross-platform). + * - stdout/stderr stream into ToolResultBuilder; excess is replaced with a + * truncation marker so a runaway command cannot OOM the host. + */ + +import type { Readable } from 'node:stream'; +import { StringDecoder } from 'node:string_decoder'; + +import type { Kaos, KaosProcess } from '@moonshot-ai/kaos'; +import { z } from 'zod'; + +import type { BuiltinTool } from '../../../agent/tool'; +import type { ExecutableToolResult, ToolExecution } from '../../../loop/types'; +import type { Environment } from '../../../utils/environment'; +import { renderPrompt } from '../../../utils/render-prompt'; +import type { BackgroundProcessManager } from '../../background/manager'; +import { toInputJsonSchema } from '../../support/input-schema'; +import { ToolResultBuilder } from '../../support/result-builder'; +import bashDescriptionTemplate from './bash.md'; + +const MS_PER_SECOND = 1000; +const DEFAULT_TIMEOUT_S = 60; +const MAX_TIMEOUT_S = 5 * 60; +const DEFAULT_BACKGROUND_TIMEOUT_S = 10 * 60; +const MAX_BACKGROUND_TIMEOUT_S = 24 * 60 * 60; +const SIGTERM_GRACE_MS = 5_000; + +export const BashInputSchema = z + .object({ + command: z.string().min(1, 'Command cannot be empty.').describe('The command to execute.'), + cwd: z + .string() + .optional() + .describe( + "The working directory in which to run the command. When omitted, the command runs in the session's working directory.", + ), + timeout: z + .number() + .int() + .positive() + .default(DEFAULT_TIMEOUT_S) + .describe( + `Optional timeout in seconds for the command to execute. Foreground default ${String(DEFAULT_TIMEOUT_S)}s, max ${String(MAX_TIMEOUT_S)}s. Background default ${String(DEFAULT_BACKGROUND_TIMEOUT_S)}s, max ${String(MAX_BACKGROUND_TIMEOUT_S)}s. Ignored for background commands when disable_timeout=true.`, + ) + .optional(), + description: z + .string() + .optional() + .describe( + 'A short description for the background task. Required when run_in_background is true.', + ), + run_in_background: z + .boolean() + .optional() + .describe('Whether to run the command as a background task.'), + disable_timeout: z + .boolean() + .optional() + .describe( + 'If true, do not apply a timeout to the command. Only applies when run_in_background is true.', + ), + }) + .superRefine((val, ctx) => { + if (val.timeout === undefined) return; + const isBackground = val.run_in_background === true; + if (!isValidTimeoutValue(val.timeout, isBackground)) { + const cap = isBackground ? MAX_BACKGROUND_TIMEOUT_S : MAX_TIMEOUT_S; + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ['timeout'], + message: `timeout must be ≤ ${String(cap)}s (${isBackground ? 'background' : 'foreground'})`, + }); + } + }); + +export const BashOutputSchema = z.object({ + exitCode: z.number().int(), + stdout: z.string(), + stderr: z.string(), +}); + +export type BashInput = z.Infer; +export type BashOutput = z.Infer; + +const SHELL_TIMEOUT_VARS = { + DEFAULT_TIMEOUT_S, + DEFAULT_BACKGROUND_TIMEOUT_S, + MAX_TIMEOUT_S, + MAX_BACKGROUND_TIMEOUT_S, +}; + +function timeoutCapS(isBackground: boolean): number { + return isBackground ? MAX_BACKGROUND_TIMEOUT_S : MAX_TIMEOUT_S; +} + +function isValidTimeoutValue(timeout: number, isBackground: boolean): boolean { + return timeout <= timeoutCapS(isBackground); +} + +function normalizeTimeoutMs(timeout: number | undefined, isBackground: boolean): number { + const defaultSeconds = isBackground ? DEFAULT_BACKGROUND_TIMEOUT_S : DEFAULT_TIMEOUT_S; + const value = timeout ?? defaultSeconds; + return Math.min(value, timeoutCapS(isBackground)) * MS_PER_SECOND; +} + +function renderBashDescription(shellName: string): string { + return renderPrompt(bashDescriptionTemplate, { ...SHELL_TIMEOUT_VARS, SHELL_NAME: shellName }); +} + +function withoutBackgroundDescription(description: string): string { + return description + .replace( + /\n\nIf `run_in_background=true`,[\s\S]*?point them to the `\/tasks` command, which opens an interactive panel; it has no subcommands\./, + '\n\nBackground execution is disabled for this agent. Do not set `run_in_background=true`.', + ) + .replace( + ` For possibly long-running foreground commands, set the \`timeout\` argument in seconds. Foreground commands default to ${String(DEFAULT_TIMEOUT_S)}s and allow up to ${String(MAX_TIMEOUT_S)}s.`, + ` For possibly long-running commands, set the \`timeout\` argument in seconds. The default is ${String(DEFAULT_TIMEOUT_S)}s; foreground commands allow up to ${String(MAX_TIMEOUT_S)}s.`, + ) + .replace( + /\n- Prefer `run_in_background=true`[\s\S]*?conversation to continue before the command finishes\./, + '\n- Do not set `run_in_background=true`; background task management tools are not available.', + ); +} + +export class BashTool implements BuiltinTool { + readonly name = 'Bash' as const; + readonly description: string; + readonly parameters: Record = toInputJsonSchema(BashInputSchema); + + private readonly isWindowsBash: boolean; + + private readonly allowBackground: boolean; + + constructor( + private readonly kaos: Kaos, + private readonly cwd: string, + private readonly environment: Environment, + private readonly backgroundManager?: BackgroundProcessManager, + options?: { + allowBackground?: boolean | undefined; + }, + ) { + this.isWindowsBash = this.environment.osKind === 'Windows'; + this.allowBackground = options?.allowBackground ?? this.backgroundManager !== undefined; + const rendered = renderBashDescription(this.environment.shellName); + this.description = this.allowBackground ? rendered : withoutBackgroundDescription(rendered); + } + + resolveExecution(args: BashInput): ToolExecution { + const preview = args.command.length > 50 ? `${args.command.slice(0, 50)}…` : args.command; + return { + description: args.run_in_background + ? `Starting background: ${preview}` + : `Running: ${preview}`, + execute: ({ signal }) => this.execution(args, signal), + }; + } + + private spawn(effectiveCwd: string, command: string): Promise { + const shellCwd = this.isWindowsBash ? windowsPathToPosixPath(effectiveCwd) : effectiveCwd; + const shellArgs = [ + this.environment.shellPath, + '-c', + `cd ${shellQuote(shellCwd)} && ${command}`, + ]; + + const noninteractiveEnv: Record = { + NO_COLOR: '1', + TERM: 'dumb', + // Default to '0' so git fails fast on private remotes if a TTY happens + // to be inherited; honour an explicit ambient value when the user has + // set one. + GIT_TERMINAL_PROMPT: process.env['GIT_TERMINAL_PROMPT'] ?? '0', + SHELL: this.environment.shellPath, + }; + + // Merge ambient env + noninteractive knobs so tools like git / node + // don't open a pager and paints don't colour the stream. + const mergedEnv: Record = { + ...(process.env as Record), + ...noninteractiveEnv, + }; + return this.kaos.execWithEnv(shellArgs, mergedEnv); + } + + private async execution(args: BashInput, signal: AbortSignal): Promise { + if (signal.aborted) { + return { isError: true, output: 'Aborted before command started' }; + } + if (args.command.length === 0) { + return { isError: true, output: 'Command cannot be empty.' }; + } + + if (args.run_in_background) { + if (!this.allowBackground) { + return { + isError: true, + output: + 'Background execution is not available for this agent because TaskOutput and TaskStop are not enabled.', + }; + } + return this.executeInBackground(args); + } + + const timeoutMs = normalizeTimeoutMs(args.timeout, false); + + let proc: KaosProcess; + const command = this.isWindowsBash ? rewriteWindowsNullRedirect(args.command) : args.command; + try { + const effectiveCwd = args.cwd ?? this.cwd; + proc = await this.spawn(effectiveCwd, command); + } catch (error) { + return { + isError: true, + output: error instanceof Error ? error.message : String(error), + }; + } + + try { + proc.stdin.end(); + } catch { + // Closing stdin on a process that has already exited is a no-op on + // some platforms and throws on others — either is safe to ignore. + } + + let timedOut = false; + let aborted = false; + let killed = false; + + const killProc = async (): Promise => { + if (killed) return; + killed = true; + try { + await proc.kill('SIGTERM'); + } catch { + /* process already gone */ + } + const exited = proc + .wait() + .then(() => true) + .catch(() => true); + const raced = await Promise.race([ + exited, + new Promise((resolve) => { + setTimeout(() => { + resolve(false); + }, SIGTERM_GRACE_MS); + }), + ]); + if (!raced && proc.exitCode === null) { + try { + await proc.kill('SIGKILL'); + } catch { + /* ignore */ + } + } + }; + + const onAbort = (): void => { + aborted = true; + void killProc(); + }; + signal.addEventListener('abort', onAbort); + + const timeoutHandle = setTimeout(() => { + timedOut = true; + void killProc(); + }, timeoutMs); + + try { + const builder = new ToolResultBuilder(); + const [, exitCode] = await Promise.all([ + Promise.all([ + readStreamIntoBuilder(proc.stdout, builder), + readStreamIntoBuilder(proc.stderr, builder), + ]), + proc.wait(), + ]); + + if (timedOut) { + const timeoutLabel = + timeoutMs % 1000 === 0 ? `${String(timeoutMs / 1000)}s` : `${String(timeoutMs)}ms`; + return builder.error(`Command killed by timeout (${timeoutLabel})`, { + brief: `Killed by timeout (${timeoutLabel})`, + }); + } + if (aborted) { + return builder.error('Interrupted by user', { brief: 'Interrupted by user' }); + } + + const isError = exitCode !== 0; + if (isError && builder.nChars === 0) { + builder.write(`Process exited with code ${String(exitCode)}`); + } + + if (!isError) { + return builder.ok('Command executed successfully.'); + } + return builder.error(`Command failed with exit code: ${String(exitCode)}.`, { + brief: `Failed with exit code: ${String(exitCode)}`, + }); + } catch (error) { + return { + isError: true, + output: error instanceof Error ? error.message : String(error), + }; + } finally { + clearTimeout(timeoutHandle); + signal.removeEventListener('abort', onAbort); + } + } + + private async executeInBackground(args: BashInput): Promise { + if (!this.backgroundManager) { + return { + isError: true, + output: 'Background execution is not available (no BackgroundProcessManager configured).', + }; + } + const backgroundManager = this.backgroundManager; + + if (!args.description?.trim()) { + return { + isError: true, + output: 'description is required when run_in_background is true.', + }; + } + + let reservation: ReturnType; + try { + reservation = backgroundManager.reserveSlot(); + } catch (error) { + return { + isError: true, + output: error instanceof Error ? error.message : String(error), + }; + } + + const timeoutMs = args.disable_timeout ? undefined : normalizeTimeoutMs(args.timeout, true); + + let proc: KaosProcess; + const command = this.isWindowsBash ? rewriteWindowsNullRedirect(args.command) : args.command; + try { + const effectiveCwd = args.cwd ?? this.cwd; + proc = await this.spawn(effectiveCwd, command); + } catch (error) { + reservation.release(); + return { + isError: true, + output: error instanceof Error ? error.message : String(error), + }; + } + + try { + proc.stdin.end(); + } catch { + /* process already gone */ + } + + let taskId: string; + try { + taskId = backgroundManager.register(proc, command, args.description.trim(), { + reservation, + shellInfo: { + shellName: this.environment.shellName, + shellPath: this.environment.shellPath, + cwd: args.cwd ?? this.cwd, + }, + }); + } catch (error) { + reservation.release(); + try { + await proc.kill('SIGTERM'); + } catch { + /* process already gone */ + } + return { + isError: true, + output: error instanceof Error ? error.message : String(error), + }; + } + + if (timeoutMs !== undefined) { + setTimeout(() => { + void (async (): Promise => { + if (proc.exitCode !== null) { + await backgroundManager.settlePendingExits(); + return; + } + const info = backgroundManager.getTask(taskId); + if (info && info.status === 'running') { + void backgroundManager.stop(taskId); + } + })(); + }, timeoutMs); + } + + // register() synchronously inserts taskId into the manager's Map, so + // this lookup in the same tick cannot return undefined. + const status = backgroundManager.getTask(taskId)!.status; + const builder = new ToolResultBuilder(); + builder.write( + `task_id: ${taskId}\n` + + `pid: ${String(proc.pid)}\n` + + `description: ${args.description.trim()}\n` + + `status: ${status}\n` + + `automatic_notification: true\n` + + 'next_step: You will be automatically notified when it completes.\n' + + 'next_step: Use TaskOutput with this task_id for a non-blocking status/output snapshot.\n' + + 'next_step: Use TaskStop only if the task must be cancelled.\n' + + 'human_shell_hint: Tell the human to run /tasks to open the interactive background-task panel.', + ); + return builder.ok('Background task started', { brief: `Started ${taskId}` }); + } +} + +async function readStreamIntoBuilder( + stream: Readable, + builder: ToolResultBuilder, +): Promise { + const decoder = new StringDecoder('utf8'); + for await (const chunk of stream) { + const buf: Buffer = typeof chunk === 'string' ? Buffer.from(chunk, 'utf8') : (chunk as Buffer); + builder.write(decoder.write(buf)); + } + builder.write(decoder.end()); +} + +function shellQuote(s: string): string { + return `'${s.replaceAll("'", "'\\''")}'`; +} + +function windowsPathToPosixPath(path: string): string { + if (path.startsWith('\\\\')) { + return path.replaceAll('\\', '/'); + } + + const driveMatch = /^([A-Za-z]):(?:[\\/]|$)/.exec(path); + if (driveMatch !== null) { + const drive = driveMatch[1]!.toLowerCase(); + const rest = path.slice(2).replaceAll('\\', '/'); + return `/${drive}${rest.startsWith('/') ? rest : `/${rest}`}`; + } + + return path.replaceAll('\\', '/'); +} + +const WINDOWS_NUL_REDIRECT = /(\d?&?>+\s*)[Nn][Uu][Ll](?=\s|$|[|&;)\n])/g; + +function rewriteWindowsNullRedirect(command: string): string { + return command.replace(WINDOWS_NUL_REDIRECT, '$1/dev/null'); +} diff --git a/packages/agent-core/src/tools/builtin/state/todo-list.md b/packages/agent-core/src/tools/builtin/state/todo-list.md new file mode 100644 index 000000000..2e83f6026 --- /dev/null +++ b/packages/agent-core/src/tools/builtin/state/todo-list.md @@ -0,0 +1,22 @@ +Use this tool to maintain a structured TODO list as you work through a multi-step task. This is especially useful in plan mode and for long-running investigations. + +**When to use:** +- Multi-step tasks that span several tool calls +- Tracking investigation progress across a large codebase search +- Planning a sequence of edits before making them + +**When NOT to use:** +- Single-shot answers that complete in one or two tool calls +- Trivial requests where tracking adds no clarity + +**Avoid churn:** +- Do not re-call this tool when nothing meaningful has changed since the last call — update the list only after real progress. +- When unsure of the current state, call query mode first (omit `todos`) to check the list before deciding what to update. +- If no available tool can move any task forward, tell the user where you are stuck instead of repeatedly re-ordering the same todos. + +**How to use:** +- Call with `todos: [...]` to replace the full list. Statuses: pending / in_progress / done. +- Call with no arguments to retrieve the current list without changing it. +- Call with `todos: []` to clear the list. +- Keep titles short and actionable (e.g. "Read session-control.ts", "Add planMode flag to TurnManager"). +- Update statuses as you make progress — mark one item in_progress at a time. \ No newline at end of file diff --git a/packages/agent-core/src/tools/builtin/state/todo-list.ts b/packages/agent-core/src/tools/builtin/state/todo-list.ts new file mode 100644 index 000000000..22431ff22 --- /dev/null +++ b/packages/agent-core/src/tools/builtin/state/todo-list.ts @@ -0,0 +1,133 @@ +/** + * TodoListTool — structured TODO list management tool. + * + * The LLM uses this tool to maintain a visible plan of sub-tasks during + * plan-mode workflows and multi-step operations. A single tool serves + * both reads and writes: + * + * - `resolveExecution({ todos: [...] })` — replace the full list + * - `resolveExecution({ todos: [] })` — clear the list + * - `resolveExecution({})` — query current list (no mutation) + * + * Storage: todos live in the agent-level tool store. Writes go through + * `tools.update_store`, so the store update is visible on wire replay. + */ + +import { z } from 'zod'; + +import type { BuiltinTool } from '../../../agent/tool'; +import type { ToolExecution } from '../../../loop/types'; +import { toInputJsonSchema } from '../../support/input-schema'; +import type { ToolStore } from '../../store'; +import DESCRIPTION from './todo-list.md'; + +// ── TODO state shape ───────────────────────────────────────────────── + +export type TodoStatus = 'pending' | 'in_progress' | 'done'; + +export interface TodoItem { + readonly title: string; + readonly status: TodoStatus; +} + +declare module '../../store' { + interface ToolStoreData { + todo: readonly TodoItem[]; + } +} + +// ── Schema ─────────────────────────────────────────────────────────── + +const TodoItemSchema = z.object({ + title: z.string().min(1).describe('Short, actionable title for the todo.'), + status: z.enum(['pending', 'in_progress', 'done']).describe('Current status of the todo.'), +}); + +export interface TodoListInput { + todos?: Array<{ title: string; status: TodoStatus }>; +} + +export const TodoListInputSchema: z.ZodType = z.object({ + todos: z + .array(TodoItemSchema) + .optional() + .describe( + 'The updated todo list. Omit to read the current todo list without making changes. Pass an empty array to clear the list.', + ), +}); + +const TODO_STORE_KEY = 'todo'; + +// ── Implementation ─────────────────────────────────────────────────── + +function renderTodoList(todos: readonly TodoItem[]): string { + if (todos.length === 0) { + return 'Todo list is empty.'; + } + const lines = todos.map((t) => { + const marker = statusMarker(t.status); + return ` ${marker} ${t.title}`; + }); + return ['Current todo list:', ...lines].join('\n'); +} + +function statusMarker(status: TodoStatus): string { + switch (status) { + case 'pending': + return '[pending]'; + case 'in_progress': + return '[in_progress]'; + case 'done': + return '[done]'; + default: { + const _exhaustive: never = status; + return _exhaustive; + } + } +} + +export class TodoListTool implements BuiltinTool { + readonly name = 'TodoList' as const; + readonly description: string = DESCRIPTION; + readonly parameters: Record = toInputJsonSchema(TodoListInputSchema); + + constructor(private readonly store: ToolStore) {} + + resolveExecution(args: TodoListInput): ToolExecution { + const description = + args.todos === undefined + ? 'Reading todo list' + : args.todos.length === 0 + ? 'Clearing todo list' + : 'Updating todo list'; + return { + description, + execute: async () => { + // Query mode — return the current list without mutation. + if (args.todos === undefined) { + const current = this.getTodos(); + return { isError: false, output: renderTodoList(current) }; + } + + // Write mode — replace the full list and return the new state. + this.setTodos(args.todos); + const stored = this.getTodos(); + const output = + stored.length === 0 ? 'Todo list cleared.' : `Todo list updated.\n${renderTodoList(stored)}`; + return { isError: false, output }; + }, + }; + } + + private getTodos(): readonly TodoItem[] { + const todos = this.store.get(TODO_STORE_KEY); + return todos ?? []; + } + + private setTodos(todos: readonly TodoItem[]): void { + this.store.set( + TODO_STORE_KEY, + todos.map((todo) => ({ title: todo.title, status: todo.status })), + ); + } +} diff --git a/packages/agent-core/src/tools/builtin/web/fetch-url.md b/packages/agent-core/src/tools/builtin/web/fetch-url.md new file mode 100644 index 000000000..f2356e690 --- /dev/null +++ b/packages/agent-core/src/tools/builtin/web/fetch-url.md @@ -0,0 +1,3 @@ +Fetch content from a URL. Returns the main text content extracted from the page. Use this when you need to read a specific web page. + +Only public `http`/`https` URLs are supported. Requests to private, loopback, or link-local addresses are refused, and responses larger than 10 MiB are rejected. diff --git a/packages/agent-core/src/tools/builtin/web/fetch-url.ts b/packages/agent-core/src/tools/builtin/web/fetch-url.ts new file mode 100644 index 000000000..6c1534651 --- /dev/null +++ b/packages/agent-core/src/tools/builtin/web/fetch-url.ts @@ -0,0 +1,122 @@ +/** + * FetchURLTool — host-injected URL fetcher. + * + * kimi-core defines the interface; the host provides the real fetch + * implementation via `UrlFetcher`. If no fetcher is supplied, the tool + * should not be registered (not exposed to the LLM). + */ + +import { z } from 'zod'; + +import type { BuiltinTool } from '../../../agent/tool'; +import { ToolAccesses } from '../../../loop/tool-access'; +import type { ExecutableToolContext, ExecutableToolResult, ToolExecution } from '../../../loop/types'; +import { toInputJsonSchema } from '../../support/input-schema'; +import { ToolResultBuilder } from '../../support/result-builder'; +import DESCRIPTION from './fetch-url.md'; + +// ── Provider interface (host-injected) ─────────────────────────────── + +/** + * How the returned content relates to the original response body. + * + * - `passthrough` — the body was already plain text / markdown and is + * returned verbatim, in full. + * - `extracted` — the body was an HTML page; only the main article text + * was extracted and returned. + */ +export type UrlFetchKind = 'passthrough' | 'extracted'; + +export interface UrlFetchResult { + /** The text handed to the LLM. */ + content: string; + /** Whether `content` is a verbatim passthrough or extracted main text. */ + kind: UrlFetchKind; +} + +export interface UrlFetcher { + fetch(url: string, options?: { toolCallId?: string }): Promise; +} + +/** + * Thrown by a `UrlFetcher` when the upstream HTTP request completed but + * returned a non-success status. The tool branches on this to surface + * `Status: N` in the error message; non-HTTP failures (DNS, timeout, + * connection reset, …) keep flowing through as plain `Error`. + */ +export class HttpFetchError extends Error { + override readonly name = 'HttpFetchError'; + readonly status: number; + constructor(status: number, message: string) { + super(message); + this.status = status; + } +} + +// ── Input schema ───────────────────────────────────────────────────── + +export const FetchURLInputSchema = z.object({ + url: z.string().describe('The URL to fetch content from.'), +}); + +export type FetchURLInput = z.Infer; + +// ── Implementation ─────────────────────────────────────────────────── + +export class FetchURLTool implements BuiltinTool { + readonly name = 'FetchURL' as const; + readonly description: string = DESCRIPTION; + readonly parameters: Record = toInputJsonSchema(FetchURLInputSchema); + constructor(private readonly fetcher: UrlFetcher) {} + + resolveExecution(args: FetchURLInput): ToolExecution { + const preview = args.url.length > 50 ? `${args.url.slice(0, 50)}…` : args.url; + return { + accesses: ToolAccesses.none(), + description: `Fetching: ${preview}`, + execute: (ctx) => this.execution(args, ctx), + }; + } + + private async execution( + args: FetchURLInput, + { + toolCallId, + }: ExecutableToolContext, + ): Promise { + try { + const { content, kind } = await this.fetcher.fetch(args.url, { toolCallId }); + + if (!content) { + return { + output: 'The response body is empty.', + isError: false, + }; + } + + const builder = new ToolResultBuilder({ maxLineLength: null }); + builder.write(content); + // Tell the LLM whether it received the whole body or only the + // extracted article text, so it can judge how complete the + // content is. + const message = + kind === 'passthrough' + ? 'The returned content is the full response body, returned verbatim.' + : 'The returned content is the main text extracted from the page.'; + return builder.ok(message); + } catch (error) { + const msg = error instanceof Error ? error.message : String(error); + if (error instanceof HttpFetchError) { + return { + isError: true, + output: `Failed to fetch URL. Status: ${String(error.status)}. ${msg}`, + }; + } + return { + isError: true, + output: `Failed to fetch URL due to network error: ${args.url}. ${msg}`, + }; + } + } + +} diff --git a/packages/agent-core/src/tools/builtin/web/web-search.md b/packages/agent-core/src/tools/builtin/web/web-search.md new file mode 100644 index 000000000..fbab5c828 --- /dev/null +++ b/packages/agent-core/src/tools/builtin/web/web-search.md @@ -0,0 +1,3 @@ +Search the web for information. Use this when you need up-to-date information from the internet. + +Each result includes its title, URL, snippet, and—when available—a publication date. When `include_content` is enabled, the full page content—when available—is appended after the snippet. diff --git a/packages/agent-core/src/tools/builtin/web/web-search.ts b/packages/agent-core/src/tools/builtin/web/web-search.ts new file mode 100644 index 000000000..153a14f9d --- /dev/null +++ b/packages/agent-core/src/tools/builtin/web/web-search.ts @@ -0,0 +1,152 @@ +/** + * WebSearchTool — host-injected web search. + * + * kimi-core defines the interface; the host provides the real search + * implementation via `WebSearchProvider`. If no provider is supplied, + * the tool should not be registered (not exposed to the LLM). + */ + +import { z } from 'zod'; + +import type { BuiltinTool } from '../../../agent/tool'; +import { ToolAccesses } from '../../../loop/tool-access'; +import type { ExecutableToolContext, ExecutableToolResult, ToolExecution } from '../../../loop/types'; +import { toInputJsonSchema } from '../../support/input-schema'; +import { ToolResultBuilder } from '../../support/result-builder'; +import DESCRIPTION from './web-search.md'; + +// ── Provider interface (host-injected) ─────────────────────────────── + +export interface WebSearchResult { + title: string; + url: string; + snippet: string; + date?: string | undefined; + content?: string | undefined; +} + +export interface WebSearchProvider { + search( + query: string, + options?: { limit?: number; includeContent?: boolean; toolCallId?: string }, + ): Promise; +} + +// ── Input schema ───────────────────────────────────────────────────── + +export const WebSearchInputSchema = z.object({ + query: z.string().describe('The query text to search for.'), + limit: z + .number() + .int() + .min(1) + .max(20) + .default(5) + .describe( + 'The number of results to return. Typically you do not need to set this value. When the results do not contain what you need, you probably want to give a more concrete query.', + ) + .optional(), + include_content: z + .boolean() + .default(false) + .describe( + 'Whether to include the content of the web pages in the results. It can consume a large amount of tokens when this is set to true. You should avoid enabling this when `limit` is set to a large value.', + ) + .optional(), +}); + +export type WebSearchInput = z.Infer; + +// ── Implementation ─────────────────────────────────────────────────── + +export class WebSearchTool implements BuiltinTool { + readonly name = 'WebSearch' as const; + readonly description: string = DESCRIPTION; + readonly parameters: Record = toInputJsonSchema(WebSearchInputSchema); + constructor(private readonly provider: WebSearchProvider) {} + + resolveExecution(args: WebSearchInput): ToolExecution { + const preview = args.query.length > 40 ? `${args.query.slice(0, 40)}…` : args.query; + return { + accesses: ToolAccesses.none(), + description: `Searching: ${preview}`, + execute: (ctx) => this.execution(args, ctx), + }; + } + + private async execution( + args: WebSearchInput, + { + toolCallId, + }: ExecutableToolContext, + ): Promise { + try { + const opts: { limit?: number; includeContent?: boolean; toolCallId?: string } = { + toolCallId, + }; + if (args.limit !== undefined) opts.limit = args.limit; + if (args.include_content !== undefined) opts.includeContent = args.include_content; + const results = await this.provider.search(args.query, opts); + const builder = new ToolResultBuilder({ maxLineLength: null }); + + if (results.length === 0) { + builder.write('No search results found.'); + return builder.ok(); + } + + let first = true; + for (const result of results) { + if (!first) builder.write('---\n\n'); + first = false; + + builder.write(`Title: ${result.title}\n`); + if (result.date) builder.write(`Date: ${result.date}\n`); + builder.write(`URL: ${result.url}\n`); + builder.write(`Snippet: ${result.snippet}\n\n`); + if (result.content) builder.write(`${result.content}\n\n`); + } + + return builder.ok(); + } catch (error) { + return { + isError: true, + output: classifySearchError(error), + }; + } + } + +} + +// ── Error classification ───────────────────────────────────────────── + +/** + * Maps a thrown search error to a categorised, human-readable message. + * + * The original error text is always preserved so the model can still see the + * underlying detail; the prefix only adds a category so failures are easier to + * reason about (e.g. retry vs. surface to the user). + */ +function classifySearchError(error: unknown): string { + const name = error instanceof Error ? error.name : ''; + const message = error instanceof Error ? error.message : String(error); + const lower = message.toLowerCase(); + + if (name === 'AbortError' || lower.includes('abort')) { + return `Search cancelled: ${message}`; + } + if (name === 'TimeoutError' || lower.includes('timed out') || lower.includes('timeout')) { + return `Search timed out: ${message}`; + } + if (lower.includes('401') || lower.includes('unauthorized') || lower.includes('auth')) { + return `Search failed (authentication): ${message}`; + } + if ( + lower.includes('http ') || + lower.includes('network') || + lower.includes('fetch') || + name === 'TypeError' + ) { + return `Search failed (network): ${message}`; + } + return `Search failed: ${message}`; +} diff --git a/packages/agent-core/src/tools/display/index.ts b/packages/agent-core/src/tools/display/index.ts new file mode 100644 index 000000000..e84d06c37 --- /dev/null +++ b/packages/agent-core/src/tools/display/index.ts @@ -0,0 +1 @@ +export * from './schemas'; diff --git a/packages/agent-core/src/tools/display/schemas.ts b/packages/agent-core/src/tools/display/schemas.ts new file mode 100644 index 000000000..abf180088 --- /dev/null +++ b/packages/agent-core/src/tools/display/schemas.ts @@ -0,0 +1,158 @@ +/** + * Zod schemas for the display unions. + * + * The wire-record layer validates incoming / outgoing + * `input_display` / `result_display` fields against these schemas. + */ + +import { z } from 'zod'; + +export const ToolInputDisplaySchema = z.discriminatedUnion('kind', [ + z.object({ + kind: z.literal('command'), + command: z.string(), + cwd: z.string().optional(), + description: z.string().optional(), + language: z.literal('bash').optional(), + }), + z.object({ + kind: z.literal('file_io'), + operation: z.enum(['read', 'write', 'edit', 'glob', 'grep']), + path: z.string(), + detail: z.string().optional(), + }), + z.object({ + kind: z.literal('diff'), + path: z.string(), + before: z.string(), + after: z.string(), + hunks: z.number().optional(), + }), + z.object({ + kind: z.literal('search'), + query: z.string(), + scope: z.string().optional(), + }), + z.object({ + kind: z.literal('url_fetch'), + url: z.string(), + method: z.string().optional(), + }), + z.object({ + kind: z.literal('agent_call'), + agent_name: z.string(), + prompt: z.string(), + background: z.boolean().optional(), + }), + z.object({ + kind: z.literal('skill_call'), + skill_name: z.string(), + args: z.string().optional(), + }), + z.object({ + kind: z.literal('todo_list'), + items: z.array(z.object({ title: z.string(), status: z.string() })), + }), + z.object({ + kind: z.literal('background_task'), + task_id: z.string(), + status: z.string(), + description: z.string(), + task_kind: z.string().optional(), + }), + z.object({ + kind: z.literal('task_stop'), + task_id: z.string(), + task_description: z.string(), + }), + z.object({ + kind: z.literal('plan_review'), + plan: z.string(), + path: z.string().optional(), + options: z + .array( + z.object({ + label: z.string(), + description: z.string(), + }), + ) + .readonly() + .optional(), + }), + z.object({ + kind: z.literal('generic'), + summary: z.string(), + detail: z.unknown().optional(), + }), +]); + +export const ToolResultDisplaySchema = z.discriminatedUnion('kind', [ + z.object({ + kind: z.literal('command_output'), + exit_code: z.number(), + stdout: z.string().optional(), + stderr: z.string().optional(), + }), + z.object({ + kind: z.literal('file_content'), + path: z.string(), + content: z.string(), + range: z.object({ start: z.number(), end: z.number() }).optional(), + truncated: z.boolean().optional(), + }), + z.object({ + kind: z.literal('diff'), + path: z.string(), + before: z.string(), + after: z.string(), + hunks: z.number().optional(), + }), + z.object({ + kind: z.literal('search_results'), + query: z.string(), + matches: z.array(z.object({ file: z.string(), line: z.number(), text: z.string() })), + }), + z.object({ + kind: z.literal('url_content'), + url: z.string(), + status: z.number(), + preview: z.string().optional(), + content_type: z.string().optional(), + }), + z.object({ + kind: z.literal('agent_summary'), + agent_name: z.string(), + result: z.string().optional(), + steps: z.number().optional(), + }), + z.object({ + kind: z.literal('background_task'), + task_id: z.string(), + status: z.string(), + description: z.string(), + }), + z.object({ + kind: z.literal('todo_list'), + items: z.array(z.object({ title: z.string(), status: z.string() })), + }), + z.object({ kind: z.literal('structured'), data: z.unknown() }), + z.object({ + kind: z.literal('text'), + text: z.string(), + truncated: z.boolean().optional(), + }), + z.object({ + kind: z.literal('error'), + message: z.string(), + code: z.string().optional(), + }), + z.object({ + kind: z.literal('generic'), + summary: z.string(), + detail: z.unknown().optional(), + }), +]); + +// Types inferred from schemas — single source of truth. +export type ToolInputDisplay = z.infer; +export type ToolResultDisplay = z.infer; diff --git a/packages/agent-core/src/tools/policies/default-permissions.ts b/packages/agent-core/src/tools/policies/default-permissions.ts new file mode 100644 index 000000000..01a562e4a --- /dev/null +++ b/packages/agent-core/src/tools/policies/default-permissions.ts @@ -0,0 +1,69 @@ +/** + * Default permission posture for built-in tools. + * + * This table captures the Python-parity distinction between tools that + * call Approval.request() and tools that run without an approval prompt. + * The rule layer still applies explicit deny rules before consulting this + * table. + */ + +export type BuiltinToolDefaultPermission = 'auto_allow' | 'ask'; +export type KnownBuiltinToolName = + | 'Read' + | 'Grep' + | 'Glob' + | 'ReadMediaFile' + | 'Think' + | 'TodoList' + | 'TaskList' + | 'TaskOutput' + | 'WebSearch' + | 'FetchURL' + | 'Agent' + | 'AskUserQuestion' + | 'EnterPlanMode' + | 'ExitPlanMode' + | 'Skill' + | 'Bash' + | 'Write' + | 'Edit' + | 'TaskStop'; + +type BuiltinToolDefaultPermissionTable = Readonly< + Record +>; + +const BUILTIN_TOOL_DEFAULT_PERMISSION_TABLE: BuiltinToolDefaultPermissionTable = { + Read: 'auto_allow', + Grep: 'auto_allow', + Glob: 'auto_allow', + ReadMediaFile: 'auto_allow', + Think: 'auto_allow', + TodoList: 'auto_allow', + TaskList: 'auto_allow', + TaskOutput: 'auto_allow', + WebSearch: 'auto_allow', + FetchURL: 'auto_allow', + Agent: 'auto_allow', + AskUserQuestion: 'auto_allow', + EnterPlanMode: 'auto_allow', + ExitPlanMode: 'auto_allow', + Skill: 'auto_allow', + Bash: 'ask', + Write: 'ask', + Edit: 'ask', + TaskStop: 'ask', +}; + +export const BUILTIN_TOOL_DEFAULT_PERMISSIONS: BuiltinToolDefaultPermissionTable = + BUILTIN_TOOL_DEFAULT_PERMISSION_TABLE; + +export function getBuiltinToolDefaultPermission( + toolName: string, +): BuiltinToolDefaultPermission | undefined { + return BUILTIN_TOOL_DEFAULT_PERMISSIONS[toolName as KnownBuiltinToolName]; +} + +export function isDefaultAutoAllowTool(toolName: string): boolean { + return getBuiltinToolDefaultPermission(toolName) === 'auto_allow'; +} diff --git a/packages/agent-core/src/tools/policies/path-access.ts b/packages/agent-core/src/tools/policies/path-access.ts new file mode 100644 index 000000000..00bcd4f9a --- /dev/null +++ b/packages/agent-core/src/tools/policies/path-access.ts @@ -0,0 +1,334 @@ +/** + * Path safety guards used by Read/Write/Edit/Grep/Glob. + * + * Canonicalization is **lexical** only (no `realpath` / symlink following). + * Mirrors `KaosPath.canonical()` and keeps the guard backend-aware: + * callers should pass the active Kaos path class so SSH paths stay POSIX + * even when the host Node process is running on Windows. + * + * Shared-prefix escapes (a path like `/workspace-evil` passing a naive + * `startswith('/workspace')` check) are blocked by requiring a path + * separator (or exact equality) after the base prefix in + * `isWithinDirectory`. + */ + +import * as nativePath from 'node:path'; +import * as posixPath from 'node:path/posix'; +import * as win32Path from 'node:path/win32'; + +import type { Kaos } from '@moonshot-ai/kaos'; + +import type { WorkspaceConfig } from '../support/workspace'; +import { isSensitiveFile } from './sensitive'; + +export type PathClass = 'posix' | 'win32'; +export type PathSecurityCode = 'PATH_OUTSIDE_WORKSPACE' | 'PATH_SENSITIVE' | 'PATH_INVALID'; +export type PathAccessOperation = 'read' | 'write' | 'search'; +export type WorkspaceGuardMode = 'strict' | 'absolute-outside-allowed' | 'disabled'; + +export interface WorkspaceAccessPolicy { + readonly guardMode: WorkspaceGuardMode; + readonly checkSensitive: boolean; +} + +export const STRICT_WORKSPACE_ACCESS_POLICY: WorkspaceAccessPolicy = { + guardMode: 'strict', + checkSensitive: true, +}; + +export const DEFAULT_WORKSPACE_ACCESS_POLICY: WorkspaceAccessPolicy = { + guardMode: 'absolute-outside-allowed', + checkSensitive: true, +}; + +export interface PathAccess { + readonly path: string; + readonly outsideWorkspace: boolean; +} + +export class PathSecurityError extends Error { + readonly code: PathSecurityCode; + readonly rawPath: string; + readonly canonicalPath: string; + + constructor(code: PathSecurityCode, rawPath: string, canonicalPath: string, message: string) { + super(message); + this.name = 'PathSecurityError'; + this.code = code; + this.rawPath = rawPath; + this.canonicalPath = canonicalPath; + } +} + +const DEFAULT_PATH_CLASS: PathClass = nativePath.sep === '\\' ? 'win32' : 'posix'; + +function pathMod(pathClass: PathClass): typeof posixPath { + return pathClass === 'win32' ? win32Path : posixPath; +} + +function comparablePath(path: string, pathClass: PathClass): string { + return pathClass === 'win32' ? path.toLowerCase().replaceAll('/', '\\') : path; +} + +function isWin32DriveRelative(path: string): boolean { + return /^[A-Za-z]:(?:$|[^\\/])/.test(path); +} + +export function normalizeUserPath(path: string, pathClass: PathClass = DEFAULT_PATH_CLASS): string { + if (pathClass !== 'win32') return path; + + // A bare root slash flips to backslash so downstream win32 string + // operations (join, isAbsolute, drive-letter detection) treat it as a + // native path component. Matches the py helper's behavior. + if (path === '/') return '\\'; + + if (path.startsWith('//')) { + return path.replaceAll('/', '\\'); + } + + const cygdriveMatch = /^\/cygdrive\/([A-Za-z])(?:\/|$)/.exec(path); + if (cygdriveMatch !== null) { + const drive = cygdriveMatch[1]!.toUpperCase(); + const rest = path.slice(`/cygdrive/${cygdriveMatch[1]!}`.length).replaceAll('/', '\\'); + return `${drive}:${rest === '' ? '\\' : rest}`; + } + + const driveMatch = /^\/([A-Za-z])(?:\/|$)/.exec(path); + if (driveMatch !== null) { + const drive = driveMatch[1]!.toUpperCase(); + const rest = path.slice(2).replaceAll('/', '\\'); + return `${drive}:${rest === '' ? '\\' : rest}`; + } + + return path; +} + +function expandUserPath(path: string, homeDir: string | undefined, pathClass: PathClass): string { + if (homeDir === undefined) return path; + if (path === '~') return homeDir; + if (path.startsWith('~/') || (pathClass === 'win32' && path.startsWith('~\\'))) { + return pathMod(pathClass).join(homeDir, path.slice(2)); + } + return path; +} + +/** + * Lexical canonicalization: resolve relative → absolute against `cwd`, + * then normalize `..` / `.` segments. No filesystem I/O. + */ +export function canonicalizePath( + path: string, + cwd: string, + pathClass: PathClass = DEFAULT_PATH_CLASS, +): string { + if (path === '') { + throw new PathSecurityError('PATH_INVALID', path, path, 'Path cannot be empty'); + } + const mod = pathMod(pathClass); + const normalizedPath = normalizeUserPath(path, pathClass); + if (pathClass === 'win32' && isWin32DriveRelative(normalizedPath)) { + throw new PathSecurityError( + 'PATH_INVALID', + path, + normalizedPath, + `"${path}" is a drive-relative Windows path. Use an absolute path like C:\\path or a path relative to the working directory.`, + ); + } + if (!mod.isAbsolute(normalizedPath) && !mod.isAbsolute(cwd)) { + throw new PathSecurityError( + 'PATH_INVALID', + path, + normalizedPath, + `Cannot resolve "${path}" against non-absolute cwd "${cwd}".`, + ); + } + const abs = mod.isAbsolute(normalizedPath) ? normalizedPath : mod.resolve(cwd, normalizedPath); + return mod.normalize(abs); +} + +/** + * True iff `candidate` is `base` itself or a descendant of it, compared + * on path-component boundaries. Both arguments must already be canonical. + */ +export function isWithinDirectory( + candidate: string, + base: string, + pathClass: PathClass = DEFAULT_PATH_CLASS, +): boolean { + const mod = pathMod(pathClass); + const comparableCandidate = comparablePath(candidate, pathClass); + const comparableBase = comparablePath(base, pathClass); + if (comparableCandidate === comparableBase) return true; + const prefix = comparableBase.endsWith(mod.sep) ? comparableBase : comparableBase + mod.sep; + return comparableCandidate.startsWith(prefix); +} + +/** + * True iff `candidate` (already canonical) sits inside any of the workspace + * roots listed in `config` (primary `workspaceDir` or any `additionalDirs`). + */ +export function isWithinWorkspace( + candidate: string, + config: WorkspaceConfig, + pathClass: PathClass = DEFAULT_PATH_CLASS, +): boolean { + if (isWithinDirectory(candidate, config.workspaceDir, pathClass)) return true; + for (const dir of config.additionalDirs) { + if (isWithinDirectory(candidate, dir, pathClass)) return true; + } + return false; +} + +export interface AssertPathOptions { + readonly mode: PathAccessOperation; + /** When true (default), also reject paths matching a sensitive-file pattern. */ + readonly checkSensitive?: boolean | undefined; + readonly pathClass?: PathClass | undefined; +} + +export interface ResolvePathAccessOptions { + readonly operation: PathAccessOperation; + readonly policy?: WorkspaceAccessPolicy | undefined; + readonly pathClass?: PathClass | undefined; + readonly homeDir?: string; +} + +export interface ResolvePathAccessPathOptions { + readonly kaos: Pick; + readonly workspace: WorkspaceConfig; + readonly operation: PathAccessOperation; + readonly policy?: WorkspaceAccessPolicy; + readonly expandHome?: boolean; +} + +function outsideWorkspaceMessage( + path: string, + canonical: string, + config: WorkspaceConfig, + operation: PathAccessOperation, +): string { + const allowed = [config.workspaceDir, ...config.additionalDirs].join(', '); + const verb = operation === 'write' ? 'written' : operation === 'search' ? 'searched' : 'read'; + return ( + `"${path}" (canonical: "${canonical}") is outside the workspace ` + + `and outside the working directory "${config.workspaceDir}". ` + + `Cannot be ${verb}. Allowed roots: ${allowed}` + ); +} + +function relativeOutsideMessage(path: string, operation: PathAccessOperation): string { + const verb = + operation === 'write' + ? 'write or edit a file' + : operation === 'search' + ? 'search' + : 'read a file'; + return ( + `"${path}" is not an absolute path. ` + + `You must provide an absolute path to ${verb} outside the working directory.` + ); +} + +export function resolvePathAccess( + path: string, + cwd: string, + config: WorkspaceConfig, + options: ResolvePathAccessOptions, +): PathAccess { + const pathClass = options.pathClass ?? DEFAULT_PATH_CLASS; + const mod = pathMod(pathClass); + const normalizedPath = normalizeUserPath(path, pathClass); + const expandedPath = expandUserPath(normalizedPath, options.homeDir, pathClass); + const rawIsAbsolute = mod.isAbsolute(expandedPath); + const canonical = canonicalizePath(expandedPath, cwd, pathClass); + const outsideWorkspace = !isWithinWorkspace(canonical, config, pathClass); + const policy = options.policy ?? DEFAULT_WORKSPACE_ACCESS_POLICY; + + if (policy.checkSensitive && isSensitiveFile(canonical, pathClass)) { + throw new PathSecurityError( + 'PATH_SENSITIVE', + path, + canonical, + `"${path}" matches a sensitive-file pattern (env / credential / SSH key). ` + + `Access is blocked to protect secrets.`, + ); + } + + // Strict mode requires the input itself to be absolute, even if it + // would canonicalize to a path inside the workspace. The python Glob + // contract is "directory must be an absolute path"; resolving a + // relative argument against the workspace cwd silently re-targets the + // search and is rejected outright in that contract. + if (policy.guardMode === 'strict' && !rawIsAbsolute) { + throw new PathSecurityError( + 'PATH_OUTSIDE_WORKSPACE', + path, + canonical, + relativeOutsideMessage(path, options.operation), + ); + } + + if (outsideWorkspace) { + switch (policy.guardMode) { + case 'strict': + throw new PathSecurityError( + 'PATH_OUTSIDE_WORKSPACE', + path, + canonical, + outsideWorkspaceMessage(path, canonical, config, options.operation), + ); + case 'absolute-outside-allowed': + if (!rawIsAbsolute) { + throw new PathSecurityError( + 'PATH_OUTSIDE_WORKSPACE', + path, + canonical, + relativeOutsideMessage(path, options.operation), + ); + } + break; + case 'disabled': + break; + } + } + + return { path: canonical, outsideWorkspace }; +} + +export function resolvePathAccessPath( + path: string, + options: ResolvePathAccessPathOptions, +): string { + const { kaos, workspace, operation, policy, expandHome = true } = options; + return resolvePathAccess(path, workspace.workspaceDir, workspace, { + operation, + policy, + pathClass: kaos.pathClass(), + homeDir: expandHome ? kaos.gethome() : undefined, + }).path; +} + +/** + * Throw `PathSecurityError` if `path` is outside the workspace, a known + * sensitive file, or an empty string. Returns the canonical absolute path + * when the check passes. + * + * Note: this is purely lexical. It does NOT protect against symlink + * targets that point outside the workspace — that would require kaos-layer + * realpath support, which is not currently available. + */ +export function assertPathAllowed( + path: string, + cwd: string, + config: WorkspaceConfig, + options: AssertPathOptions, +): string { + return resolvePathAccess(path, cwd, config, { + operation: options.mode, + pathClass: options.pathClass, + policy: { + guardMode: 'strict', + checkSensitive: options.checkSensitive ?? STRICT_WORKSPACE_ACCESS_POLICY.checkSensitive, + }, + }).path; +} diff --git a/packages/agent-core/src/tools/policies/sensitive.ts b/packages/agent-core/src/tools/policies/sensitive.ts new file mode 100644 index 000000000..6557488f5 --- /dev/null +++ b/packages/agent-core/src/tools/policies/sensitive.ts @@ -0,0 +1,93 @@ +/** + * Sensitive-file detection. + * + * The pattern list is intentionally small to avoid false positives; files + * matching any of these patterns are blocked from Read/Write/Edit so + * credentials cannot be exfiltrated through a compromised prompt. Exemptions + * like `.env.example` are explicitly allowed. + */ + +import * as nativePath from 'node:path'; +import * as posixPath from 'node:path/posix'; +import * as win32Path from 'node:path/win32'; + +import type { PathClass } from './path-access'; + +const SENSITIVE_BASENAMES = new Set([ + '.env', + 'id_rsa', + 'id_ed25519', + 'id_ecdsa', + 'credentials', +]); + +const SENSITIVE_PATH_SUFFIXES = [ + ['.aws', 'credentials'], + ['.gcp', 'credentials'], +]; + +const ENV_PREFIX = '.env.'; +const ENV_EXEMPTIONS = new Set(['.env.example', '.env.sample', '.env.template']); + +const SENSITIVE_BASENAME_PREFIXES = ['id_rsa', 'id_ed25519', 'id_ecdsa', 'credentials']; +const PUBLIC_KEY_BASENAMES = new Set(['id_rsa.pub', 'id_ed25519.pub', 'id_ecdsa.pub']); +export const SENSITIVE_DOT_VARIANT_SUFFIXES = [ + '.bak', + '.backup', + '.copy', + '.disabled', + '.key', + '.old', + '.orig', + '.pem', + '.save', + '.tmp', +] as const; +const SENSITIVE_DOT_VARIANT_SUFFIX_SET = new Set(SENSITIVE_DOT_VARIANT_SUFFIXES); + +const DEFAULT_PATH_CLASS: PathClass = nativePath.sep === '\\' ? 'win32' : 'posix'; + +function pathMod(pathClass: PathClass): typeof posixPath { + return pathClass === 'win32' ? win32Path : posixPath; +} + +function comparable(path: string, pathClass: PathClass): string { + return pathClass === 'win32' ? path.toLowerCase() : path; +} + +export function isSensitiveFile(path: string, pathClass: PathClass = DEFAULT_PATH_CLASS): boolean { + const mod = pathMod(pathClass); + const name = mod.basename(path); + const comparableName = comparable(name, pathClass); + const comparablePath = comparable(path, pathClass); + + if (ENV_EXEMPTIONS.has(comparableName)) return false; + if (PUBLIC_KEY_BASENAMES.has(comparableName)) return false; + if (SENSITIVE_BASENAMES.has(comparableName)) return true; + if (comparableName.startsWith(ENV_PREFIX)) return true; + + for (const prefix of SENSITIVE_BASENAME_PREFIXES) { + if (comparableName === prefix) return true; + // Catch rename-shielded variants without flagging unrelated filenames + // like `id_rsafoo` or ordinary JSON files like `credentials.json`. + if (comparableName.length > prefix.length && comparableName.startsWith(prefix)) { + const suffix = comparableName.slice(prefix.length); + const next = suffix[0]; + if (next === '-' || next === '_') return true; + if (next === '.' && SENSITIVE_DOT_VARIANT_SUFFIX_SET.has(suffix)) return true; + } + } + + for (const suffixParts of SENSITIVE_PATH_SUFFIXES) { + const suffix = suffixParts.join(mod.sep); + const comparableSuffix = comparable(suffix, pathClass); + if ( + comparablePath.endsWith(`${mod.sep}${comparableSuffix}`) || + comparablePath.includes(`${mod.sep}${comparableSuffix}${mod.sep}`) + ) { + return true; + } + } + + return false; +} diff --git a/packages/agent-core/src/tools/providers/local-fetch-url.ts b/packages/agent-core/src/tools/providers/local-fetch-url.ts new file mode 100644 index 000000000..af10a8ca3 --- /dev/null +++ b/packages/agent-core/src/tools/providers/local-fetch-url.ts @@ -0,0 +1,229 @@ +/** + * LocalFetchURLProvider — host-side URL fetcher. + * + * Flow: + * 1. GET the URL with a Chrome-like UA. + * 2. Reject HTTP >= 400 with the status code in the message. + * 3. Reject responses larger than `maxBytes` (content-length first, + * then measured body length as a defensive second check). + * 4. `text/plain` / `text/markdown` → passthrough verbatim. + * 5. Otherwise (assumed HTML) → run Readability over a linkedom + * document. Return `# ${title}\n\n${text}` (title omitted when + * absent). If extraction yields no meaningful text, fall back to + * common content containers (`

    ` / `
    ` / ``) + * before throwing a "meaningful content" error. + */ + +import { Readability } from '@mozilla/readability'; +import { parseHTML as rawParseHTML } from 'linkedom'; + +import { HttpFetchError, type UrlFetcher, type UrlFetchResult } from '../builtin'; + +// Readability's .d.ts references the global `Document` type, but this +// package compiles with `lib: ES2023` (no DOM). Extracting the +// constructor parameter type keeps us off the global `Document` name +// while still accepting whatever Readability wants. +type ReadabilityDocument = ConstructorParameters[0]; + +// linkedom's published types depend on DOM libs we don't load. Declare +// the minimal surface we actually use so the rest of the file stays +// type-safe without pulling lib.dom.d.ts into the host build. +interface DomElementLike { + textContent: string | null; + querySelector(selector: string): DomElementLike | null; +} +interface DomParseResult { + document: DomElementLike; +} +const parseHTML = rawParseHTML as unknown as (html: string) => DomParseResult; + +const DEFAULT_USER_AGENT = + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 ' + + '(KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36'; + +const DEFAULT_MAX_BYTES = 10 * 1024 * 1024; + +export interface LocalFetchURLProviderOptions { + userAgent?: string; + fetchImpl?: typeof fetch; + maxBytes?: number; + /** + * Allow fetching loopback / RFC 1918 / link-local / ULA addresses. + * Defaults to `false` — enabled only for tests and (future) explicit + * opt-in. Keeps an LLM that's been prompt-injected from exfiltrating + * AWS/GCP metadata (169.254.169.254), probing internal services + * (10.x, 192.168.x), or reading local daemons (127.0.0.1:*). + */ + allowPrivateAddresses?: boolean; +} + +/** + * SSRF guard — reject non-http(s) schemes and (by default) any hostname + * that is, or parses as, a private / loopback / link-local / ULA IP + * literal. This is a *static* check against the URL string; it does NOT + * do DNS resolution, so a domain that resolves to a private IP via + * DNS-rebinding is **not** caught here. That attack is a known + * limitation; mitigations (e.g. pinning the resolved IP through to + * fetch) are left for a follow-up. + */ +function assertSafeFetchTarget(url: string, allowPrivate: boolean): void { + let parsed: URL; + try { + parsed = new URL(url); + } catch { + throw new Error(`Invalid URL: "${url}"`); + } + if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { + throw new Error(`Unsupported URL scheme "${parsed.protocol}" — only http(s) allowed.`); + } + if (allowPrivate) return; + // URL hostname preserves surrounding `[ ]` for IPv6 literals on some + // Node versions (and not others). Strip them for uniform comparison. + const hostRaw = parsed.hostname.toLowerCase(); + const host = hostRaw.startsWith('[') && hostRaw.endsWith(']') ? hostRaw.slice(1, -1) : hostRaw; + // Literal "localhost" / loopback aliases. + if (host === 'localhost' || host.endsWith('.localhost')) { + throw new Error(`Refusing to fetch private host: "${host}"`); + } + // IPv6 loopback / ULA / link-local. Check after bracket strip. + if ( + host === '::1' || + host === '::' || + host.startsWith('fe80:') || + host.startsWith('fc') || + host.startsWith('fd') + ) { + throw new Error(`Refusing to fetch private host: "${host}"`); + } + // IPv4 literal — only check when the hostname is a dotted-quad; normal + // domains will never match. + const v4 = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(host); + if (v4 !== null) { + const octets = [v4[1], v4[2], v4[3], v4[4]].map(Number); + if (octets.some((n) => !Number.isInteger(n) || n < 0 || n > 255)) { + throw new Error(`Invalid IPv4 literal: "${host}"`); + } + const [a, b] = octets as [number, number, number, number]; + // 127.0.0.0/8 loopback, 10.0.0.0/8, 192.168.0.0/16, + // 172.16.0.0/12, 169.254.0.0/16 link-local / AWS metadata, + // 0.0.0.0/8 "this network", 100.64.0.0/10 CGNAT. + const isLoopback = a === 127; + const isPrivate10 = a === 10; + const isPrivate192 = a === 192 && b === 168; + const isPrivate172 = a === 172 && b >= 16 && b <= 31; + const isLinkLocal = a === 169 && b === 254; + const isZero = a === 0; + const isCgnat = a === 100 && b >= 64 && b <= 127; + if ( + isLoopback || + isPrivate10 || + isPrivate192 || + isPrivate172 || + isLinkLocal || + isZero || + isCgnat + ) { + throw new Error(`Refusing to fetch private address: "${host}"`); + } + } +} + +export class LocalFetchURLProvider implements UrlFetcher { + private readonly userAgent: string; + private readonly fetchImpl: typeof fetch; + private readonly maxBytes: number; + private readonly allowPrivateAddresses: boolean; + + constructor(options: LocalFetchURLProviderOptions = {}) { + this.userAgent = options.userAgent ?? DEFAULT_USER_AGENT; + this.fetchImpl = options.fetchImpl ?? globalThis.fetch.bind(globalThis); + this.maxBytes = options.maxBytes ?? DEFAULT_MAX_BYTES; + this.allowPrivateAddresses = options.allowPrivateAddresses ?? false; + } + + async fetch(url: string, _options?: { toolCallId?: string }): Promise { + assertSafeFetchTarget(url, this.allowPrivateAddresses); + + const response = await this.fetchImpl(url, { + method: 'GET', + headers: { 'User-Agent': this.userAgent }, + }); + + if (response.status >= 400) { + // Drain the unused body so undici can release the socket back to + // the keep-alive pool instead of leaking it on error paths. + await response.body?.cancel().catch(() => { + /* already closed */ + }); + throw new HttpFetchError( + response.status, + `HTTP ${String(response.status)} ${response.statusText}`, + ); + } + + // Reject oversized responses before buffering the full body. + const contentLengthRaw = response.headers.get('content-length'); + if (contentLengthRaw !== null) { + const cl = Number(contentLengthRaw); + if (Number.isFinite(cl) && cl > this.maxBytes) { + throw new Error( + `Response body too large: ${String(cl)} bytes exceeds maxBytes (${String(this.maxBytes)}).`, + ); + } + } + + const body = await response.text(); + + // Servers may omit content-length — measure again defensively. + const actualBytes = Buffer.byteLength(body, 'utf8'); + if (actualBytes > this.maxBytes) { + throw new Error( + `Response body too large: ${String(actualBytes)} bytes exceeds maxBytes (${String(this.maxBytes)}).`, + ); + } + + const contentType = (response.headers.get('content-type') ?? '').toLowerCase(); + if (contentType.startsWith('text/plain') || contentType.startsWith('text/markdown')) { + return { content: body, kind: 'passthrough' }; + } + + return { content: this.extractMainContent(body), kind: 'extracted' }; + } + + private extractMainContent(html: string): string { + // Readability mutates the DOM it parses, so parse twice — once for + // the primary extractor and once for the fallback path. + const primary = parseHTML(html); + try { + const reader = new Readability(primary.document as unknown as ReadabilityDocument, { + charThreshold: 0, + }); + const article = reader.parse(); + if (article !== null) { + const text = (article.textContent ?? '').trim(); + if (text.length > 0) { + const title = (article.title ?? '').trim(); + return title.length > 0 ? `# ${title}\n\n${text}` : text; + } + } + } catch { + // Fall through to the container-based fallback. + } + + const { document } = parseHTML(html); + const titleText = (document.querySelector('title')?.textContent ?? '').trim(); + const container = + document.querySelector('article') ?? + document.querySelector('main') ?? + document.querySelector('body'); + const fallbackText = (container?.textContent ?? '').trim(); + + if (fallbackText.length === 0) { + throw new Error( + 'Failed to extract meaningful content from the page. The page may require JavaScript to render.', + ); + } + + return titleText.length > 0 ? `# ${titleText}\n\n${fallbackText}` : fallbackText; + } +} diff --git a/packages/agent-core/src/tools/providers/moonshot-fetch-url.ts b/packages/agent-core/src/tools/providers/moonshot-fetch-url.ts new file mode 100644 index 000000000..825781da4 --- /dev/null +++ b/packages/agent-core/src/tools/providers/moonshot-fetch-url.ts @@ -0,0 +1,128 @@ +/** + * MoonshotFetchURLProvider — host-side UrlFetcher. + * + * Flow: + * 1. Try Moonshot coding-fetch service (POST {url}, Bearer token from a + * narrow token provider, Accept: text/markdown, host-provided headers). + * 2. Moonshot 200 → return the body as `extracted` content (the + * service has already extracted the main page text on its side). + * 3. Any Moonshot failure — non-200, network error, or token + * refresh failure — → delegate to `localFallback`, forwarding its + * content kind, so the LLM still gets *something* when the service + * is down. + * 4. If localFallback also throws → propagate that error. + */ + +import { HttpFetchError, type UrlFetcher, type UrlFetchResult } from '../builtin'; + +export interface BearerTokenProvider { + getAccessToken(options?: { readonly force?: boolean | undefined }): Promise; +} + +export interface MoonshotFetchURLProviderOptions { + tokenProvider?: BearerTokenProvider; + apiKey?: string; + baseUrl: string; + defaultHeaders?: Record; + customHeaders?: Record; + localFallback: UrlFetcher; + fetchImpl?: typeof fetch; +} + +export class MoonshotFetchURLProvider implements UrlFetcher { + private readonly tokenProvider: BearerTokenProvider | undefined; + private readonly apiKey: string | undefined; + private readonly baseUrl: string; + private readonly defaultHeaders: Record; + private readonly customHeaders: Record; + private readonly localFallback: UrlFetcher; + private readonly fetchImpl: typeof fetch; + + constructor(options: MoonshotFetchURLProviderOptions) { + this.tokenProvider = options.tokenProvider; + this.apiKey = options.apiKey; + this.baseUrl = options.baseUrl; + this.defaultHeaders = options.defaultHeaders ?? {}; + this.customHeaders = options.customHeaders ?? {}; + this.localFallback = options.localFallback; + this.fetchImpl = options.fetchImpl ?? globalThis.fetch.bind(globalThis); + } + + async fetch(url: string, options?: { toolCallId?: string }): Promise { + try { + const content = await this.fetchViaMoonshot(url, options?.toolCallId); + // The service returns text it has already extracted from the page. + return { content, kind: 'extracted' }; + } catch { + // Forward an explicit options object even when the caller passed + // none, so downstream consumers always see a defined second arg. + return this.localFallback.fetch(url, options ?? {}); + } + } + + private async fetchViaMoonshot( + url: string, + toolCallId: string | undefined, + ): Promise { + const bodyJson = JSON.stringify({ url }); + + const response = await this.post(bodyJson, toolCallId); + + if (response.status !== 200) { + let detail = ''; + try { + detail = await response.text(); + } catch { + // ignore — status code alone is informative enough for the + // fallback path that catches this. + } + throw new HttpFetchError( + response.status, + `Moonshot fetch request failed: HTTP ${String(response.status)}. ${detail}`.trim(), + ); + } + + return response.text(); + } + + private async post(bodyJson: string, toolCallId: string | undefined): Promise { + const accessToken = await this.resolveApiKey(); + return this.fetchImpl(this.baseUrl, { + method: 'POST', + headers: { + ...this.defaultHeaders, + Authorization: `Bearer ${accessToken}`, + Accept: 'text/markdown', + 'Content-Type': 'application/json', + ...(toolCallId !== undefined && toolCallId.length > 0 + ? { 'X-Msh-Tool-Call-Id': toolCallId } + : {}), + ...this.customHeaders, + }, + body: bodyJson, + }); + } + + private async resolveApiKey(): Promise { + if (this.tokenProvider !== undefined) { + try { + const token = await this.tokenProvider.getAccessToken(); + if (token.trim().length > 0) { + return token; + } + if (this.apiKey !== undefined && this.apiKey.length > 0) { + return this.apiKey; + } + } catch (error) { + if (this.apiKey !== undefined && this.apiKey.length > 0) { + return this.apiKey; + } + throw error; + } + } + if (this.apiKey !== undefined && this.apiKey.length > 0) { + return this.apiKey; + } + throw new Error('Moonshot fetch service is not configured: missing API key or token provider.'); + } +} diff --git a/packages/agent-core/src/tools/providers/moonshot-web-search.ts b/packages/agent-core/src/tools/providers/moonshot-web-search.ts new file mode 100644 index 000000000..f1ef6c18b --- /dev/null +++ b/packages/agent-core/src/tools/providers/moonshot-web-search.ts @@ -0,0 +1,143 @@ +/** + * MoonshotWebSearchProvider — host-side `WebSearchProvider`. + * + * Auth uses a narrow bearer token provider per request. Host-specific + * default headers are supplied by runtime and request-level overrides + * come from `customHeaders`. + */ + +import type { WebSearchProvider, WebSearchResult } from '../builtin'; + +export interface BearerTokenProvider { + getAccessToken(options?: { readonly force?: boolean | undefined }): Promise; +} + +export interface MoonshotWebSearchProviderOptions { + tokenProvider?: BearerTokenProvider; + apiKey?: string; + baseUrl: string; + defaultHeaders?: Record; + customHeaders?: Record; + fetchImpl?: typeof fetch; +} + +interface MoonshotSearchResult { + site_name?: string; + title?: string; + url?: string; + snippet?: string; + content?: string; + date?: string; + icon?: string; + mime?: string; +} + +interface MoonshotSearchResponse { + search_results?: MoonshotSearchResult[]; +} + +export class MoonshotWebSearchProvider implements WebSearchProvider { + private readonly tokenProvider: BearerTokenProvider | undefined; + private readonly apiKey: string | undefined; + private readonly baseUrl: string; + private readonly defaultHeaders: Record; + private readonly customHeaders: Record; + private readonly fetchImpl: typeof fetch; + + constructor(options: MoonshotWebSearchProviderOptions) { + this.tokenProvider = options.tokenProvider; + this.apiKey = options.apiKey; + this.baseUrl = options.baseUrl; + this.defaultHeaders = options.defaultHeaders ?? {}; + this.customHeaders = options.customHeaders ?? {}; + this.fetchImpl = options.fetchImpl ?? globalThis.fetch.bind(globalThis); + } + + async search( + query: string, + options?: { limit?: number; includeContent?: boolean; toolCallId?: string }, + ): Promise { + const body = { + text_query: query, + limit: options?.limit ?? 5, + enable_page_crawling: options?.includeContent ?? false, + timeout_seconds: 30, + }; + const bodyJson = JSON.stringify(body); + + const toolCallId = options?.toolCallId; + const response = await this.post(bodyJson, toolCallId); + + if (response.status === 401) { + const detail = await safeReadText(response); + throw new Error( + `Moonshot search request failed: HTTP 401 (auth/unauthorized). ${detail}`.trim(), + ); + } + + if (response.status !== 200) { + const detail = await safeReadText(response); + throw new Error( + `Moonshot search request failed: HTTP ${String(response.status)}. ${detail}`.trim(), + ); + } + + const json = (await response.json()) as MoonshotSearchResponse; + const raw = Array.isArray(json.search_results) ? json.search_results : []; + + return raw.map((r): WebSearchResult => { + const out: WebSearchResult = { + title: r.title ?? '', + url: r.url ?? '', + snippet: r.snippet ?? '', + }; + if (typeof r.date === 'string' && r.date.length > 0) out.date = r.date; + if (typeof r.content === 'string' && r.content.length > 0) out.content = r.content; + return out; + }); + } + + private async post(bodyJson: string, toolCallId: string | undefined): Promise { + const accessToken = await this.resolveApiKey(); + return this.fetchImpl(this.baseUrl, { + method: 'POST', + headers: { + ...this.defaultHeaders, + Authorization: `Bearer ${accessToken}`, + 'Content-Type': 'application/json', + ...(toolCallId !== undefined && toolCallId.length > 0 + ? { 'X-Msh-Tool-Call-Id': toolCallId } + : {}), + ...this.customHeaders, + }, + body: bodyJson, + }); + } + + private async resolveApiKey(): Promise { + if (this.tokenProvider !== undefined) { + try { + return await this.tokenProvider.getAccessToken(); + } catch (error) { + if (this.apiKey !== undefined && this.apiKey.length > 0) { + return this.apiKey; + } + throw error; + } + } + if (this.apiKey !== undefined && this.apiKey.length > 0) { + return this.apiKey; + } + throw new Error( + 'Moonshot search service is not configured: missing API key or token provider.', + ); + } +} + +async function safeReadText(response: Response): Promise { + try { + return await response.text(); + } catch { + return ''; + } +} diff --git a/packages/agent-core/src/tools/store.ts b/packages/agent-core/src/tools/store.ts new file mode 100644 index 000000000..60279b68a --- /dev/null +++ b/packages/agent-core/src/tools/store.ts @@ -0,0 +1,13 @@ +export interface ToolStoreData {} + +export type ToolStoreKey = Extract; + +export interface ToolStore { + get(key: K): ToolStoreData[K] | undefined; + set(key: K, value: ToolStoreData[K]): void; +} + +export interface ToolStoreUpdate { + readonly key: K; + readonly value: ToolStoreData[K]; +} diff --git a/packages/agent-core/src/tools/support/file-type.ts b/packages/agent-core/src/tools/support/file-type.ts new file mode 100644 index 000000000..56e40529d --- /dev/null +++ b/packages/agent-core/src/tools/support/file-type.ts @@ -0,0 +1,383 @@ +/** + * file-type — magic-byte + extension detection. No npm dependency. + */ + +export const MEDIA_SNIFF_BYTES = 512; + +export interface FileType { + readonly kind: 'text' | 'image' | 'video' | 'unknown'; + readonly mimeType: string; +} + +export const IMAGE_MIME_BY_SUFFIX: Readonly> = Object.freeze({ + '.png': 'image/png', + '.jpg': 'image/jpeg', + '.jpeg': 'image/jpeg', + '.gif': 'image/gif', + '.bmp': 'image/bmp', + '.tif': 'image/tiff', + '.tiff': 'image/tiff', + '.webp': 'image/webp', + '.ico': 'image/x-icon', + '.heic': 'image/heic', + '.heif': 'image/heif', + '.avif': 'image/avif', + '.svgz': 'image/svg+xml', +}); + +export const VIDEO_MIME_BY_SUFFIX: Readonly> = Object.freeze({ + '.mp4': 'video/mp4', + '.mpg': 'video/mpeg', + '.mpeg': 'video/mpeg', + '.mkv': 'video/x-matroska', + '.avi': 'video/x-msvideo', + '.mov': 'video/quicktime', + '.ogv': 'video/ogg', + '.wmv': 'video/x-ms-wmv', + '.webm': 'video/webm', + '.m4v': 'video/x-m4v', + '.flv': 'video/x-flv', + '.3gp': 'video/3gpp', + '.3g2': 'video/3gpp2', +}); + +const TEXT_MIME_BY_SUFFIX: Readonly> = Object.freeze({ + '.svg': 'image/svg+xml', +}); + +export const NON_TEXT_SUFFIXES: ReadonlySet = new Set([ + '.icns', + '.psd', + '.ai', + '.eps', + '.pdf', + '.doc', + '.docx', + '.dot', + '.dotx', + '.rtf', + '.odt', + '.xls', + '.xlsx', + '.xlsm', + '.xlt', + '.xltx', + '.xltm', + '.ods', + '.ppt', + '.pptx', + '.pptm', + '.pps', + '.ppsx', + '.odp', + '.pages', + '.numbers', + '.key', + '.zip', + '.rar', + '.7z', + '.tar', + '.gz', + '.tgz', + '.bz2', + '.xz', + '.zst', + '.lz', + '.lz4', + '.br', + '.cab', + '.ar', + '.deb', + '.rpm', + '.mp3', + '.wav', + '.flac', + '.ogg', + '.oga', + '.opus', + '.aac', + '.m4a', + '.wma', + '.ttf', + '.otf', + '.woff', + '.woff2', + '.exe', + '.dll', + '.so', + '.dylib', + '.bin', + '.apk', + '.ipa', + '.jar', + '.class', + '.pyc', + '.pyo', + '.wasm', + '.dmg', + '.iso', + '.img', + '.sqlite', + '.sqlite3', + '.db', + '.db3', +]); + +const ASF_HEADER = Buffer.from([ + 0x30, 0x26, 0xb2, 0x75, 0x8e, 0x66, 0xcf, 0x11, 0xa6, 0xd9, 0x00, 0xaa, 0x00, 0x62, 0xce, 0x6c, +]); + +const FTYP_IMAGE_BRANDS: Readonly> = Object.freeze({ + avif: 'image/avif', + avis: 'image/avif', + heic: 'image/heic', + heif: 'image/heif', + heix: 'image/heif', + hevc: 'image/heic', + mif1: 'image/heif', + msf1: 'image/heif', +}); + +const FTYP_VIDEO_BRANDS: Readonly> = Object.freeze({ + isom: 'video/mp4', + iso2: 'video/mp4', + iso5: 'video/mp4', + mp41: 'video/mp4', + mp42: 'video/mp4', + avc1: 'video/mp4', + mp4v: 'video/mp4', + m4v: 'video/x-m4v', + qt: 'video/quicktime', + '3gp4': 'video/3gpp', + '3gp5': 'video/3gpp', + '3gp6': 'video/3gpp', + '3gp7': 'video/3gpp', + '3g2': 'video/3gpp2', +}); + +function toBuffer(data: Buffer | Uint8Array): Buffer { + return Buffer.isBuffer(data) ? data : Buffer.from(data.buffer, data.byteOffset, data.byteLength); +} + +function startsWith(buf: Buffer, prefix: Buffer | readonly number[]): boolean { + const needle = Buffer.isBuffer(prefix) ? prefix : Buffer.from(prefix); + if (buf.length < needle.length) return false; + for (let i = 0; i < needle.length; i += 1) { + if (buf[i] !== needle[i]) return false; + } + return true; +} + +function sniffFtypBrand(header: Buffer): string | null { + if (header.length < 12) return null; + if (header.subarray(4, 8).toString('latin1') !== 'ftyp') return null; + const raw = header.subarray(8, 12).toString('latin1').toLowerCase(); + // Python `.strip()` removes ASCII whitespace including trailing NULs via + // the `decode(..., errors="ignore")` semantics. We approximate: trim + // spaces and trailing NULs so brands like `qt ` → `qt`. + // oxlint-disable-next-line no-control-regex + return raw.replaceAll(/[\s\u0000]+$/g, '').trim(); +} + +export function sniffMediaFromMagic(data: Buffer | Uint8Array): FileType | null { + const buf = toBuffer(data); + const header = buf.length > MEDIA_SNIFF_BYTES ? buf.subarray(0, MEDIA_SNIFF_BYTES) : buf; + + if (startsWith(header, [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])) { + return { kind: 'image', mimeType: 'image/png' }; + } + if (startsWith(header, [0xff, 0xd8, 0xff])) { + return { kind: 'image', mimeType: 'image/jpeg' }; + } + if (startsWith(header, Buffer.from('GIF87a')) || startsWith(header, Buffer.from('GIF89a'))) { + return { kind: 'image', mimeType: 'image/gif' }; + } + if (startsWith(header, Buffer.from('BM'))) { + return { kind: 'image', mimeType: 'image/bmp' }; + } + if ( + startsWith(header, [0x49, 0x49, 0x2a, 0x00]) || + startsWith(header, [0x4d, 0x4d, 0x00, 0x2a]) + ) { + return { kind: 'image', mimeType: 'image/tiff' }; + } + if (startsWith(header, [0x00, 0x00, 0x01, 0x00])) { + return { kind: 'image', mimeType: 'image/x-icon' }; + } + if (startsWith(header, Buffer.from('RIFF')) && header.length >= 12) { + const chunk = header.subarray(8, 12).toString('latin1'); + if (chunk === 'WEBP') return { kind: 'image', mimeType: 'image/webp' }; + if (chunk === 'AVI ') return { kind: 'video', mimeType: 'video/x-msvideo' }; + } + if (startsWith(header, Buffer.from('FLV'))) { + return { kind: 'video', mimeType: 'video/x-flv' }; + } + if (startsWith(header, ASF_HEADER)) { + return { kind: 'video', mimeType: 'video/x-ms-wmv' }; + } + if (startsWith(header, [0x1a, 0x45, 0xdf, 0xa3])) { + const lowered = header.toString('latin1').toLowerCase(); + if (lowered.includes('webm')) return { kind: 'video', mimeType: 'video/webm' }; + if (lowered.includes('matroska')) return { kind: 'video', mimeType: 'video/x-matroska' }; + } + const brand = sniffFtypBrand(header); + if (brand !== null && brand !== '') { + if (brand in FTYP_IMAGE_BRANDS) { + return { kind: 'image', mimeType: FTYP_IMAGE_BRANDS[brand]! }; + } + if (brand in FTYP_VIDEO_BRANDS) { + return { kind: 'video', mimeType: FTYP_VIDEO_BRANDS[brand]! }; + } + } + return null; +} + +export interface ImageDimensions { + readonly width: number; + readonly height: number; +} + +/** + * Best-effort pixel-dimension reader for common raster formats. + * + * Inspects only the fixed region near the start of the file where each + * format records its dimensions (the IHDR/DIB header, the RIFF chunk + * after the `WEBP` tag, or the first JPEG SOFn segment). Returns `null` + * for formats whose dimensions are not locatable from that region, or + * when the supplied buffer is too short to cover it. + */ +export function sniffImageDimensions(data: Buffer | Uint8Array): ImageDimensions | null { + const buf = toBuffer(data); + + // PNG — IHDR is the first chunk; width/height are big-endian uint32 + // at offsets 16 and 20. + if (startsWith(buf, [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]) && buf.length >= 24) { + return { width: buf.readUInt32BE(16), height: buf.readUInt32BE(20) }; + } + + // GIF — logical-screen width/height are little-endian uint16 at + // offsets 6 and 8. + if ( + (startsWith(buf, Buffer.from('GIF87a')) || startsWith(buf, Buffer.from('GIF89a'))) && + buf.length >= 10 + ) { + return { width: buf.readUInt16LE(6), height: buf.readUInt16LE(8) }; + } + + // BMP — DIB header width/height are little-endian int32 at offsets 18 + // and 22 (height may be negative for top-down bitmaps). + if (startsWith(buf, Buffer.from('BM')) && buf.length >= 26) { + return { width: buf.readInt32LE(18), height: Math.abs(buf.readInt32LE(22)) }; + } + + // WEBP — RIFF container; VP8/VP8L/VP8X each store dimensions + // differently in the chunk that follows the 'WEBP' tag. + if (startsWith(buf, Buffer.from('RIFF')) && buf.length >= 30) { + const fourCc = buf.subarray(12, 16).toString('latin1'); + if (fourCc === 'VP8 ') { + return { + width: buf.readUInt16LE(26) & 0x3fff, + height: buf.readUInt16LE(28) & 0x3fff, + }; + } + if (fourCc === 'VP8L' && buf.length >= 25) { + const bits = buf.readUInt32LE(21); + return { + width: (bits & 0x3fff) + 1, + height: ((bits >> 14) & 0x3fff) + 1, + }; + } + if (fourCc === 'VP8X') { + const width = 1 + (buf[24]! | (buf[25]! << 8) | (buf[26]! << 16)); + const height = 1 + (buf[27]! | (buf[28]! << 8) | (buf[29]! << 16)); + return { width, height }; + } + } + + // JPEG — scan segment markers for a Start-Of-Frame (SOFn) marker, + // whose payload carries height/width as big-endian uint16. + if (startsWith(buf, [0xff, 0xd8])) { + let offset = 2; + while (offset + 9 < buf.length) { + if (buf[offset] !== 0xff) { + offset += 1; + continue; + } + const marker = buf[offset + 1]!; + // SOFn markers carry frame dimensions; skip SOF4/SOF8/SOF12 (0xc4/0xc8/0xcc). + if ( + marker >= 0xc0 && + marker <= 0xcf && + marker !== 0xc4 && + marker !== 0xc8 && + marker !== 0xcc + ) { + return { + height: buf.readUInt16BE(offset + 5), + width: buf.readUInt16BE(offset + 7), + }; + } + // Standalone markers (RSTn, SOI, EOI) carry no length field. + if (marker === 0xd8 || marker === 0xd9 || (marker >= 0xd0 && marker <= 0xd7)) { + offset += 2; + continue; + } + const segmentLength = buf.readUInt16BE(offset + 2); + if (segmentLength < 2) break; + offset += 2 + segmentLength; + } + } + + return null; +} + +function getSuffix(path: string): string { + const idx = path.lastIndexOf('.'); + if (idx === -1) return ''; + // POSIX `.suffix` treats `foo.tar.gz` → `.gz` and `foo/.bashrc` → '' (leading-dot is "no suffix"). + const lastSep = Math.max(path.lastIndexOf('/'), path.lastIndexOf('\\')); + if (idx <= lastSep + 1) return ''; + return path.slice(idx).toLowerCase(); +} + +export function detectFileType(path: string, header?: Buffer | Uint8Array): FileType { + const suffix = getSuffix(path); + let mediaHint: FileType | null = null; + if (suffix in TEXT_MIME_BY_SUFFIX) { + mediaHint = { kind: 'text', mimeType: TEXT_MIME_BY_SUFFIX[suffix]! }; + } else if (suffix in IMAGE_MIME_BY_SUFFIX) { + mediaHint = { kind: 'image', mimeType: IMAGE_MIME_BY_SUFFIX[suffix]! }; + } else if (suffix in VIDEO_MIME_BY_SUFFIX) { + mediaHint = { kind: 'video', mimeType: VIDEO_MIME_BY_SUFFIX[suffix]! }; + } + + // When a header is supplied, cross-validate against the ext hint — + // a mismatch reports `unknown` rather than blindly trusting the + // extension. When ext hint + sniff agree on kind, prefer the ext's + // mimeType so the reported MIME matches what the filename advertised. + // A disagreement on `kind` (e.g. `.mp4` with JPEG magic) still + // collapses to `unknown`. + if (header !== undefined) { + const buf = toBuffer(header); + const sniffed = sniffMediaFromMagic(buf); + if (sniffed) { + if (mediaHint) { + if (sniffed.kind !== mediaHint.kind) { + return { kind: 'unknown', mimeType: '' }; + } + return mediaHint; + } + return sniffed; + } + if (buf.includes(0x00)) { + return { kind: 'unknown', mimeType: '' }; + } + // No sniff and no NUL: fall through to hint / text / unknown logic. + } + + if (mediaHint) return mediaHint; + if (NON_TEXT_SUFFIXES.has(suffix)) { + return { kind: 'unknown', mimeType: '' }; + } + return { kind: 'text', mimeType: 'text/plain' }; +} diff --git a/packages/agent-core/src/tools/support/git-worktree.ts b/packages/agent-core/src/tools/support/git-worktree.ts new file mode 100644 index 000000000..fedf55e1a --- /dev/null +++ b/packages/agent-core/src/tools/support/git-worktree.ts @@ -0,0 +1,98 @@ +/** + * Marker-based git work-tree detection. Never spawns `git`; failures return + * null so callers can fall back to their safer path. + */ + +import * as posixPath from 'node:path/posix'; +import * as win32Path from 'node:path/win32'; + +import type { Kaos } from '@moonshot-ai/kaos'; + +import type { PathClass } from '../policies/path-access'; + +const S_IFMT = 0o170000; +const S_IFDIR = 0o040000; +const S_IFREG = 0o100000; + +export interface GitWorkTreeMarker { + readonly dotGitPath: string; + readonly controlDirPath: string; +} + +function pathMod(pathClass: PathClass): typeof posixPath { + return pathClass === 'win32' ? win32Path : posixPath; +} + +export async function findGitWorkTreeMarker( + kaos: Kaos, + cwd: string, +): Promise { + const pathClass = kaos.pathClass(); + const mod = pathMod(pathClass); + if (cwd.length === 0 || !mod.isAbsolute(cwd)) return null; + + let current = mod.normalize(cwd); + for (let depth = 0; depth < 256; depth += 1) { + const dotGitPath = mod.join(current, '.git'); + const hit = await probeGitMarker(kaos, dotGitPath, current, pathClass); + if (hit !== null) return hit; + + const parent = mod.dirname(current); + if (parent === current) return null; + current = parent; + } + return null; +} + +async function probeGitMarker( + kaos: Kaos, + dotGitPath: string, + markerParent: string, + pathClass: PathClass, +): Promise { + let stat: Awaited>; + try { + stat = await kaos.stat(dotGitPath); + } catch { + return null; + } + + if (isMode(stat.stMode, S_IFDIR)) return { dotGitPath, controlDirPath: dotGitPath }; + if (!isMode(stat.stMode, S_IFREG)) return null; + + let content: string; + try { + content = await kaos.readText(dotGitPath); + } catch { + return null; + } + const controlDirPath = parseGitDir(content, markerParent, pathClass); + return controlDirPath === undefined ? null : { dotGitPath, controlDirPath }; +} + +function isMode(stMode: number, mode: number): boolean { + return (stMode & S_IFMT) === mode; +} + +/** Drop UTF-8 BOM and any leading whitespace (incl. `\r\n`) before content checks. */ +function stripLeadingNoise(content: string): string { + let s = content; + if (s.codePointAt(0) === 0xfeff) s = s.slice(1); + return s.trimStart(); +} + +function parseGitDir( + content: string, + markerParent: string, + pathClass: PathClass, +): string | undefined { + const line = stripLeadingNoise(content).split(/\r?\n/, 1)[0]?.trim(); + if (line === undefined || !line.startsWith('gitdir:')) return undefined; + + const rawPath = line.slice('gitdir:'.length).trim(); + if (rawPath.length === 0) return undefined; + + const mod = pathMod(pathClass); + const absolute = mod.isAbsolute(rawPath) ? rawPath : mod.join(markerParent, rawPath); + return mod.normalize(absolute); +} diff --git a/packages/agent-core/src/tools/support/input-schema.ts b/packages/agent-core/src/tools/support/input-schema.ts new file mode 100644 index 000000000..0c0fb2d14 --- /dev/null +++ b/packages/agent-core/src/tools/support/input-schema.ts @@ -0,0 +1,62 @@ +/** + * Shared helper for deriving the JSON Schema that a tool advertises to the + * model for its parameters. + * + * A tool's parameter schema describes the *input* the model is expected to + * supply. zod v4's `toJSONSchema` defaults to the *output* view, which marks + * any field carrying a chain-tail `.default()` as `required` — producing a + * schema that simultaneously declares a `default` and lists the field as + * required. That contradiction also makes the runtime AJV validator reject + * legal calls that omit the defaulted fields. + * + * Always render parameter schemas through this helper so the `io: 'input'` + * view is applied uniformly and defaulted fields remain optional, while the + * closed-object guard (`additionalProperties: false`) is kept so unknown + * arguments are still rejected. + */ + +import { z } from 'zod'; + +/** + * Convert a zod schema into the input JSON Schema exposed to the model. + * + * @param schema - The zod schema describing the tool's parameters. + * @returns A draft-07 JSON Schema rendered with the input view. + */ +export function toInputJsonSchema(schema: z.ZodType): Record { + const jsonSchema = z.toJSONSchema(schema, { + target: 'draft-7', + io: 'input', + }); + closeObjectNodes(jsonSchema); + return jsonSchema; +} + +/** + * Re-assert `additionalProperties: false` on every object node. + * + * The input view drops `additionalProperties: false` from `z.object` nodes + * because, before unknown-key stripping, an *input* object may legally carry + * extra keys. But a tool's parameter schema is a model-facing contract that + * the runtime validates with AJV only — there is no zod parse/strip step + * before dispatch — so without the closed-object guard a misspelled argument + * passes validation and is silently ignored. Restoring it keeps unknown + * arguments rejected, matching the output view's pre-input-view behavior. + * + * Nodes that already declare `additionalProperties` (e.g. `z.record`) are + * left untouched. + */ +function closeObjectNodes(value: unknown): void { + if (Array.isArray(value)) { + for (const item of value) closeObjectNodes(item); + return; + } + if (typeof value !== 'object' || value === null) return; + const node = value as Record; + if (node['type'] === 'object' && node['additionalProperties'] === undefined) { + node['additionalProperties'] = false; + } + for (const child of Object.values(node)) { + closeObjectNodes(child); + } +} diff --git a/packages/agent-core/src/tools/support/list-directory.ts b/packages/agent-core/src/tools/support/list-directory.ts new file mode 100644 index 000000000..433aa9f6e --- /dev/null +++ b/packages/agent-core/src/tools/support/list-directory.ts @@ -0,0 +1,127 @@ +/** + * list-directory — compact 2-level directory tree for LLM context. + * + * Used by GlobTool when rejecting a `**`-leading pattern: appending a + * snapshot of the workspace root helps the LLM re-scope its pattern + * without a second round-trip. + * + * Width caps keep the system-prompt token budget bounded: + * - Depth 0 (root): up to LIST_DIR_ROOT_WIDTH entries + * - Depth 1 (children of root dirs): up to LIST_DIR_CHILD_WIDTH entries + * - Truncated levels show "... and N more" so the LLM knows more exists. + */ + +import * as posixPath from 'node:path/posix'; +import * as win32Path from 'node:path/win32'; + +import type { Kaos } from '@moonshot-ai/kaos'; + +export const LIST_DIR_ROOT_WIDTH = 30; +export const LIST_DIR_CHILD_WIDTH = 10; + +type PathClass = 'posix' | 'win32'; + +interface Entry { + readonly name: string; + readonly isDir: boolean; +} + +async function collectEntries( + kaos: Kaos, + dirPath: string, + maxWidth: number, + pathClass: PathClass, +): Promise<{ entries: Entry[]; total: number; readable: boolean }> { + const all: Entry[] = []; + try { + for await (const fullPath of kaos.iterdir(dirPath)) { + const name = basename(fullPath, pathClass); + let isDir = false; + try { + const st = await kaos.stat(fullPath); + // StatResult mirrors POSIX stat; derive the file type from the + // mode bits (S_IFMT mask → S_IFDIR == 0o040000). + isDir = (st.stMode & 0o170000) === 0o040000; + } catch { + // Unreadable entries keep isDir=false; still list the name. + } + all.push({ name, isDir }); + } + } catch { + return { entries: [], total: 0, readable: false }; + } + all.sort((a, b) => { + if (a.isDir !== b.isDir) return a.isDir ? -1 : 1; + return a.name.localeCompare(b.name); + }); + return { entries: all.slice(0, maxWidth), total: all.length, readable: true }; +} + +function pathMod(pathClass: PathClass): typeof posixPath { + return pathClass === 'win32' ? win32Path : posixPath; +} + +function basename(p: string, pathClass: PathClass): string { + return pathMod(pathClass).basename(p); +} + +/** + * Return a 2-level tree listing of `workDir` suitable for inclusion in a + * tool error message. Returns `"(empty directory)"` if the directory is + * empty, or an error marker line if the directory itself is unreadable. + */ +export async function listDirectory(kaos: Kaos, workDir: string): Promise { + const lines: string[] = []; + const pathClass = kaos.pathClass(); + const { entries, total, readable } = await collectEntries( + kaos, + workDir, + LIST_DIR_ROOT_WIDTH, + pathClass, + ); + if (!readable) return '[not readable]'; + const remaining = total - entries.length; + + for (let i = 0; i < entries.length; i++) { + const entry = entries[i]; + if (entry === undefined) continue; + const { name, isDir } = entry; + const isLast = i === entries.length - 1 && remaining === 0; + const connector = isLast ? '└── ' : '├── '; + + if (isDir) { + lines.push(`${connector}${name}/`); + const childPrefix = isLast ? ' ' : '│ '; + const childDir = joinPath(workDir, name, pathClass); + const child = await collectEntries(kaos, childDir, LIST_DIR_CHILD_WIDTH, pathClass); + if (!child.readable) { + lines.push(`${childPrefix}└── [not readable]`); + continue; + } + const childRemaining = child.total - child.entries.length; + for (let j = 0; j < child.entries.length; j++) { + const ce = child.entries[j]; + if (ce === undefined) continue; + const cIsLast = j === child.entries.length - 1 && childRemaining === 0; + const cConnector = cIsLast ? '└── ' : '├── '; + const suffix = ce.isDir ? '/' : ''; + lines.push(`${childPrefix}${cConnector}${ce.name}${suffix}`); + } + if (childRemaining > 0) { + lines.push(`${childPrefix}└── ... and ${String(childRemaining)} more`); + } + } else { + lines.push(`${connector}${name}`); + } + } + + if (remaining > 0) { + lines.push(`└── ... and ${String(remaining)} more entries`); + } + + return lines.length > 0 ? lines.join('\n') : '(empty directory)'; +} + +function joinPath(parent: string, child: string, pathClass: PathClass): string { + return pathMod(pathClass).join(parent, child); +} diff --git a/packages/agent-core/src/tools/support/result-builder.ts b/packages/agent-core/src/tools/support/result-builder.ts new file mode 100644 index 000000000..8618d5671 --- /dev/null +++ b/packages/agent-core/src/tools/support/result-builder.ts @@ -0,0 +1,149 @@ +import type { + ExecutableToolErrorResult, + ExecutableToolSuccessResult, +} from '../../loop/types'; + +const DEFAULT_MAX_CHARS = 50_000; +const DEFAULT_MAX_LINE_LENGTH = 2000; +const TRUNCATION_MARKER = '[...truncated]'; +const TRUNCATION_MESSAGE = 'Output is truncated to fit in the message.'; + +export interface ToolResultBuilderOptions { + readonly maxChars?: number; + readonly maxLineLength?: number | null; +} + +export type ExecutableToolResultBuilderResult = ( + | ExecutableToolSuccessResult + | ExecutableToolErrorResult +) & { + readonly output: string; + readonly message: string; + readonly truncated: boolean; + readonly brief?: string; +}; + +export class ToolResultBuilder { + private readonly maxChars: number; + private readonly maxLineLength: number | null; + + private readonly buffer: string[] = []; + private nCharsValue = 0; + private truncationHappened = false; + + constructor(options: ToolResultBuilderOptions = {}) { + this.maxChars = options.maxChars ?? DEFAULT_MAX_CHARS; + this.maxLineLength = + options.maxLineLength === undefined ? DEFAULT_MAX_LINE_LENGTH : options.maxLineLength; + + if (this.maxLineLength !== null && this.maxLineLength <= TRUNCATION_MARKER.length) { + throw new Error('maxLineLength must be greater than the truncation marker length.'); + } + } + + get nChars(): number { + return this.nCharsValue; + } + + write(text: string): number { + if (this.nCharsValue >= this.maxChars) { + if (text.length > 0 && !this.truncationHappened) { + this.buffer.push(TRUNCATION_MARKER); + this.nCharsValue += TRUNCATION_MARKER.length; + this.truncationHappened = true; + } + return 0; + } + + const lines = text.match(/[^\r\n]*(?:\r\n|[\n\r])|[^\r\n]+/g) ?? []; + if (lines.length === 0) return 0; + + let charsWritten = 0; + for (const originalLine of lines) { + if (this.nCharsValue >= this.maxChars) { + if (!this.truncationHappened) { + this.buffer.push(TRUNCATION_MARKER); + this.nCharsValue += TRUNCATION_MARKER.length; + this.truncationHappened = true; + } + break; + } + + const remainingChars = this.maxChars - this.nCharsValue; + const limit = + this.maxLineLength === null + ? remainingChars + : Math.min(remainingChars, this.maxLineLength); + let line = originalLine; + if (line.length > limit) { + const lineBreak = /[\r\n]+$/.exec(line)?.[0] ?? ''; + const suffix = TRUNCATION_MARKER + lineBreak; + const effectiveMaxLength = Math.max(limit, suffix.length); + line = line.slice(0, effectiveMaxLength - suffix.length) + suffix; + } + if (line !== originalLine) { + this.truncationHappened = true; + } + + this.buffer.push(line); + charsWritten += line.length; + this.nCharsValue += line.length; + } + + return charsWritten; + } + + ok(message = '', options: { readonly brief?: string } = {}): ExecutableToolResultBuilderResult { + let finalMessage = message; + if (finalMessage.length > 0 && !finalMessage.endsWith('.')) { + finalMessage += '.'; + } + if (this.truncationHappened) { + finalMessage = + finalMessage.length === 0 ? TRUNCATION_MESSAGE : `${finalMessage} ${TRUNCATION_MESSAGE}`; + } + + const output = this.buffer.join(''); + const shouldAppendMessage = + finalMessage.length > 0 && (this.truncationHappened || output.length === 0); + return { + isError: false, + output: shouldAppendMessage + ? output.length === 0 + ? finalMessage + : output.endsWith('\n') + ? `${output}${finalMessage}` + : `${output}\n${finalMessage}` + : output, + message: finalMessage, + truncated: this.truncationHappened, + brief: options.brief, + }; + } + + error( + message: string, + options: { readonly brief?: string } = {}, + ): ExecutableToolResultBuilderResult { + const finalMessage = this.truncationHappened + ? message.length === 0 + ? TRUNCATION_MESSAGE + : `${message} ${TRUNCATION_MESSAGE}` + : message; + const output = this.buffer.join(''); + return { + isError: true, + output: + finalMessage.length === 0 + ? output + : output.length === 0 + ? finalMessage + : output.endsWith('\n') + ? `${output}${finalMessage}` + : `${output}\n${finalMessage}`, + message: finalMessage, + truncated: this.truncationHappened, + brief: options.brief, + }; + } +} diff --git a/packages/agent-core/src/tools/support/rg-locator.ts b/packages/agent-core/src/tools/support/rg-locator.ts new file mode 100644 index 000000000..a551c9083 --- /dev/null +++ b/packages/agent-core/src/tools/support/rg-locator.ts @@ -0,0 +1,363 @@ +/** + * rg-locator — hybrid ripgrep binary resolution. + * + * Lookup order (first hit wins): + * 1. System PATH (`which rg`) — fastest, respects developer setup + * 2. Bundled vendor binary (hook; not wired yet — `getVendorRgPath` is a stub) + * 3. `/bin/rg` — persistent cache for this app. + * 4. CDN download to /bin/ — one-off bootstrap + * + * If steps 1-4 all fail, callers receive a structured error they can + * turn into a user-facing "install ripgrep" hint instead of the naked + * `spawn rg ENOENT`. + */ + +import { createHash } from 'node:crypto'; +import { createWriteStream, existsSync } from 'node:fs'; +import { chmod, mkdir, mkdtemp, readFile, rename, rm, stat } from 'node:fs/promises'; +import { homedir, tmpdir } from 'node:os'; +import { basename, join } from 'node:path'; +import { Readable } from 'node:stream'; +import { pipeline } from 'node:stream/promises'; + +import { extract as extractTar } from 'tar'; +import { type Entry, fromBuffer as yauzlFromBuffer } from 'yauzl'; + +import { abortable } from '../../utils/abort'; + +const RG_VERSION = '15.0.0'; +const RG_BASE_URL = 'https://code.kimi.com/kimi-code/rg'; +const DOWNLOAD_TIMEOUT_MS = 600_000; +const RG_ARCHIVE_SHA256: Record = { + 'ripgrep-15.0.0-aarch64-apple-darwin.tar.gz': + '98bb2e61e7277ba0ea72d2ae2592497fd8d2940934a16b122448d302a6637e3b', + 'ripgrep-15.0.0-aarch64-pc-windows-msvc.zip': + '572709c8770cb7f9385d725cb06d2bcd9537ec24d4dd17b1be1d65a876f8b591', + 'ripgrep-15.0.0-aarch64-unknown-linux-gnu.tar.gz': + '15f8cc2fab12d88491c54d49f38589922a9d6a7353c29b0a0856727bcdf80754', + 'ripgrep-15.0.0-x86_64-apple-darwin.tar.gz': + '44128c733d127ddbda461e01225a68b5f9997cfe7635242a797f645ca674a71a', + 'ripgrep-15.0.0-x86_64-pc-windows-msvc.zip': + '21a98bf42c4da97ca543c010e764cc6dec8b9b7538d05f8d21874016385e0860', + 'ripgrep-15.0.0-x86_64-unknown-linux-musl.tar.gz': + '253ad0fd5fef0d64cba56c70dccdacc1916d4ed70ad057cc525fcdb0c3bbd2a7', +}; + +export type RgResolutionSource = + | 'system-path' + | 'vendor' + | 'share-bin-cached' + | 'share-bin-downloaded'; + +export interface RgResolution { + readonly path: string; + readonly source: RgResolutionSource; +} + +export interface EnsureRgPathOptions { + readonly shareDir?: string | undefined; + /** + * Cancels this caller's wait. A shared bootstrap download that is already in + * progress may continue so other callers can still use the same result. + */ + readonly signal?: AbortSignal | undefined; +} + +/** + * Resolve the absolute path to a usable `rg` binary, downloading it + * into `/bin/` if necessary. Multiple concurrent callers are + * serialized by a module-level lock so the download happens at most + * once per process. + */ +export async function ensureRgPath(options: EnsureRgPathOptions = {}): Promise { + options.signal?.throwIfAborted(); + const resolution = resolveRgPath(options.shareDir ?? getShareDir(), options.signal); + return options.signal === undefined ? resolution : abortable(resolution, options.signal); +} + +async function resolveRgPath( + shareDir: string, + signal?: AbortSignal | undefined, +): Promise { + const existing = await findExistingRg(shareDir); + if (existing) return existing; + signal?.throwIfAborted(); + return downloadRgWithLock(shareDir); +} + +/** + * Pure-lookup variant for test harnesses that want to assert on the + * resolution order without triggering a real download. + */ +export async function findExistingRg(shareDir: string): Promise { + const binName = rgBinaryName(); + const systemRg = await whichRg(); + if (systemRg !== undefined) return { path: systemRg, source: 'system-path' }; + const vendorPath = getVendorRgPath(binName); + if (vendorPath !== undefined && (await isExecutableFile(vendorPath))) { + return { path: vendorPath, source: 'vendor' }; + } + const cachePath = join(shareDir, 'bin', binName); + if (await isExecutableFile(cachePath)) { + return { path: cachePath, source: 'share-bin-cached' }; + } + return undefined; +} + +let downloadPromise: Promise | undefined; +async function downloadRgWithLock(shareDir: string): Promise { + if (downloadPromise !== undefined) return downloadPromise; + downloadPromise = (async () => { + try { + const existing = await findExistingRg(shareDir); + if (existing) return existing; + const binPath = await downloadAndInstallRg(shareDir); + return { path: binPath, source: 'share-bin-downloaded' }; + } finally { + downloadPromise = undefined; + } + })(); + return downloadPromise; +} + +function rgBinaryName(): string { + return process.platform === 'win32' ? 'rg.exe' : 'rg'; +} + +function getShareDir(): string { + const override = process.env['KIMI_CODE_HOME']; + if (override !== undefined && override !== '') return override; + return join(homedir(), '.kimi-code'); +} + +function getVendorRgPath(_binName: string): string | undefined { + return undefined; +} + +async function whichRg(): Promise { + const pathEnv = process.env['PATH'] ?? ''; + const sep = process.platform === 'win32' ? ';' : ':'; + const binName = rgBinaryName(); + for (const dir of pathEnv.split(sep)) { + if (dir === '') continue; + const candidate = join(dir, binName); + try { + const st = await stat(candidate); + if (st.isFile()) return candidate; + } catch { + /* not here, try next */ + } + } + return undefined; +} + +async function isExecutableFile(p: string): Promise { + try { + const st = await stat(p); + return st.isFile(); + } catch { + return false; + } +} + +/** @internal for tests — rust-style `--` target triple. */ +export function detectTarget(): string | undefined { + const arch = process.arch === 'x64' ? 'x86_64' : process.arch === 'arm64' ? 'aarch64' : undefined; + if (arch === undefined) return undefined; + + if (process.platform === 'darwin') return `${arch}-apple-darwin`; + if (process.platform === 'linux') { + return arch === 'x86_64' ? 'x86_64-unknown-linux-musl' : 'aarch64-unknown-linux-gnu'; + } + if (process.platform === 'win32') return `${arch}-pc-windows-msvc`; + return undefined; +} + +async function downloadAndInstallRg(shareDir: string): Promise { + const target = detectTarget(); + if (target === undefined) { + throw new Error( + `Unsupported platform/arch for ripgrep download: ${process.platform}/${process.arch}`, + ); + } + + // Windows ripgrep releases ship as `.zip`; macOS / Linux as `.tar.gz`. + // The extraction branch inside the try block handles the format-specific + // unpack; the fetch + download-to-tmp pipeline is identical. + const isWindows = target.includes('windows'); + const archiveExt = isWindows ? 'zip' : 'tar.gz'; + const archiveName = `ripgrep-${RG_VERSION}-${target}.${archiveExt}`; + const expectedSha256 = RG_ARCHIVE_SHA256[archiveName]; + if (expectedSha256 === undefined) { + throw new Error(`No pinned SHA-256 is configured for ripgrep archive ${archiveName}`); + } + const url = `${RG_BASE_URL}/${archiveName}`; + + const binDir = join(shareDir, 'bin'); + await mkdir(binDir, { recursive: true }); + const destination = join(binDir, rgBinaryName()); + + const tmp = await mkdtemp(join(tmpdir(), 'kimi-rg-')); + try { + const archivePath = join(tmp, archiveName); + + const controller = new AbortController(); + const timeoutHandle = setTimeout(() => { + controller.abort(); + }, DOWNLOAD_TIMEOUT_MS); + let resp: Response; + try { + resp = await fetch(url, { signal: controller.signal }); + } finally { + clearTimeout(timeoutHandle); + } + if (!resp.ok || resp.body === null) { + throw new Error(`Failed to download ripgrep: HTTP ${String(resp.status)} ${resp.statusText}`); + } + const write = createWriteStream(archivePath); + // Readable.fromWeb is typed as accepting a web ReadableStream; the + // undici/fetch body matches that shape at runtime. + await pipeline(Readable.fromWeb(resp.body as never), write); + await verifyArchiveChecksum(archivePath, archiveName, expectedSha256); + + if (isWindows) { + await extractRgFromZip(archivePath, destination); + // Windows does not need `chmod +x`: execution is gated by the + // `.exe` extension + NTFS ACLs, which are already correct. + } else { + const extractDir = join(tmp, 'extract'); + await mkdir(extractDir, { recursive: true }); + // tar.gz uses hard-coded prefix because the CDN's tar.gz layout is stable + // and known from upstream releases; zip branch uses basename matching as + // a looser contract so a CDN prefix change doesn't silently fall through. + await extractTar({ + file: archivePath, + cwd: extractDir, + gzip: true, + filter: (entryPath: string) => entryPath.endsWith(`/${rgBinaryName()}`), + }); + const extracted = join(extractDir, `ripgrep-${RG_VERSION}-${target}`, rgBinaryName()); + if (!existsSync(extracted)) { + throw new Error( + `Ripgrep archive did not contain expected binary at ${extracted}. ` + + 'CDN content may have changed.', + ); + } + await rename(extracted, destination); + await chmod(destination, 0o755); + } + return destination; + } finally { + await rm(tmp, { recursive: true, force: true }); + } +} + +/** @internal for tests — fail closed before extracting downloaded bytes. */ +export async function verifyArchiveChecksum( + archivePath: string, + archiveName: string, + expectedSha256: string, +): Promise { + const actualSha256 = createHash('sha256') + .update(await readFile(archivePath)) + .digest('hex'); + if (actualSha256 !== expectedSha256) { + throw new Error( + `Ripgrep archive checksum mismatch for ${archiveName}: expected ${expectedSha256}, ` + + `got ${actualSha256}. CDN content may have changed.`, + ); + } +} + +/** + * Read the downloaded `.zip` at `archivePath`, find the `rg.exe` entry + * (basename match), and stream it out to `destination`. Throws with + * the shared "CDN content may have + * changed" sentinel when the archive holds no matching entry — same + * failure semantics as the tar.gz path's `existsSync(extracted)` gate + * so callers see a single actionable message. + */ +export async function extractRgFromZip(archivePath: string, destination: string): Promise { + const buf = await readFile(archivePath); + const binName = rgBinaryName(); // 'rg.exe' on win32 + await new Promise((resolve, reject) => { + yauzlFromBuffer(buf, { lazyEntries: true }, (openErr, zipfile) => { + if (openErr !== null || zipfile === undefined) { + reject(new Error(`Failed to open ripgrep archive: ${openErr?.message ?? 'unknown error'}`)); + return; + } + let found = false; + const onEntry = (entry: Entry): void => { + // Match on basename (not full path) — keeps the matcher robust + // against CDN repackaging tweaks (e.g. an unexpected + // `ripgrep-X.Y.Z-TARGET/` prefix change). + if (basename(entry.fileName) !== binName) { + zipfile.readEntry(); + return; + } + found = true; + zipfile.openReadStream(entry, (streamErr, stream) => { + if (streamErr !== null) { + reject( + new Error(`Failed to read ${entry.fileName} from archive: ${streamErr.message}`), + ); + zipfile.close(); + return; + } + const out = createWriteStream(destination); + void (async () => { + try { + await pipeline(stream, out); + zipfile.close(); + resolve(); + } catch (error) { + zipfile.close(); + reject(error instanceof Error ? error : new Error(String(error))); + } + })(); + }); + }; + zipfile.on('entry', onEntry); + zipfile.on('end', () => { + // With lazyEntries:true, `end` fires only after readEntry() is called + // for every central-directory entry. We stop calling readEntry() once + // `found` becomes true, so `end` only reaches this branch on the + // not-found path. + if (!found) { + reject( + new Error( + `Ripgrep archive did not contain expected binary '${binName}'. ` + + 'CDN content may have changed.', + ), + ); + } + }); + zipfile.on('error', (err: Error) => { + reject(err); + }); + zipfile.readEntry(); + }); + }); +} + +/** + * User-facing error message to show when `ensureRgPath` throws. Kept + * in one place so the Grep / Glob / Bash plumbing can reuse it. + */ +export function rgUnavailableMessage(cause: unknown): string { + const detail = + cause instanceof Error ? cause.message : typeof cause === 'string' ? cause : 'unknown error'; + const shareBin = join(getShareDir(), 'bin', rgBinaryName()); + return ( + `ripgrep (rg) is not available and the automatic bootstrap failed.\n` + + `\n` + + `Error: ${detail}\n` + + `\n` + + `Fix options:\n` + + ` macOS: brew install ripgrep\n` + + ` Ubuntu: sudo apt-get install ripgrep\n` + + ` Other: https://github.com/BurntSushi/ripgrep#installation\n` + + `\n` + + `Alternatively, drop a static rg binary at ${shareBin}` + ); +} diff --git a/packages/agent-core/src/tools/support/workspace.ts b/packages/agent-core/src/tools/support/workspace.ts new file mode 100644 index 000000000..c108c7c88 --- /dev/null +++ b/packages/agent-core/src/tools/support/workspace.ts @@ -0,0 +1,17 @@ +/** + * WorkspaceConfig — defines the roots that tools are allowed to access. + * + * Injected through each Tool's constructor. Not passed through Runtime: + * the Runtime keeps a small fixed shape and workspace limits live on + * the Tool side. + * + * Paths should already be canonicalized lexically (absolute + normalized); + * callers are responsible for normalizing before constructing this config. + */ + +export interface WorkspaceConfig { + /** Primary workspace directory (absolute, canonicalized). */ + readonly workspaceDir: string; + /** Extra allowed roots (e.g. `--add-dir` CLI flag). */ + readonly additionalDirs: readonly string[]; +} diff --git a/packages/agent-core/src/utils/abort.ts b/packages/agent-core/src/utils/abort.ts new file mode 100644 index 000000000..61e3f1685 --- /dev/null +++ b/packages/agent-core/src/utils/abort.ts @@ -0,0 +1,61 @@ +export function abortError(): Error { + const error = new Error('Aborted'); + error.name = 'AbortError'; + return error; +} + +export function abortable(promise: Promise, signal: AbortSignal): Promise { + signal.throwIfAborted(); + return new Promise((resolve, reject) => { + const onAbort = () => { + reject(abortError()); + }; + signal.addEventListener('abort', onAbort, { once: true }); + promise.then(resolve, reject).finally(() => { + signal.removeEventListener('abort', onAbort); + }); + }); +} + +export function linkAbortSignal(source: AbortSignal, target: AbortController): () => void { + const onAbort = () => { + target.abort(source.reason); + }; + if (source.aborted) { + onAbort(); + return () => {}; + } + source.addEventListener('abort', onAbort, { once: true }); + return () => { + source.removeEventListener('abort', onAbort); + }; +} + +export interface DeadlineAbortSignal { + readonly signal: AbortSignal; + readonly timedOut: () => boolean; + readonly clear: () => void; +} + +export function createDeadlineAbortSignal( + source: AbortSignal, + timeoutMs: number, +): DeadlineAbortSignal { + const controller = new AbortController(); + const unlinkAbortSignal = linkAbortSignal(source, controller); + let didTimeout = false; + let timeout: ReturnType | undefined = setTimeout(() => { + didTimeout = true; + controller.abort(abortError()); + }, timeoutMs); + + return { + signal: controller.signal, + timedOut: () => didTimeout, + clear: () => { + if (timeout !== undefined) clearTimeout(timeout); + timeout = undefined; + unlinkAbortSignal(); + }, + }; +} diff --git a/packages/agent-core/src/utils/completion-budget.ts b/packages/agent-core/src/utils/completion-budget.ts new file mode 100644 index 000000000..abcf027bb --- /dev/null +++ b/packages/agent-core/src/utils/completion-budget.ts @@ -0,0 +1,153 @@ +import type { + ChatProvider, + Message, + ModelCapability, + Tool, +} from '@moonshot-ai/kosong'; + +import { + estimateTokens, + estimateTokensForMessages, + estimateTokensForTools, +} from './tokens'; + +/** + * Desired completion-token budget for the next LLM step. + * + * The budget is a request, not a guarantee: it is clamped against the + * current input size and the model's context window before being applied + * to the provider. This avoids two failure modes for Kimi reasoning + * models: + * 1. A small cap can return HTTP 200 with empty `content` because the + * whole budget was spent on `reasoning_content`. + * 2. A large cap may exceed the remaining context window and trigger + * `Invalid request: Your request exceeded model token limit`. + */ +export interface CompletionBudget { + /** Desired completion budget when the model context window allows it. */ + readonly desired: number; + /** + * Safety margin reserved between current input and the context limit, + * to absorb tokenizer estimation error and provider-side overhead. + */ + readonly safetyMargin?: number | undefined; +} + +const MIN_FLOOR = 1; +const DEFAULT_SAFETY_MARGIN = 1024; +const DEFAULT_DESIRED_BUDGET = 32000; + +/** + * Resolve the completion budget for a turn from configuration and Kimi + * environment variables. + * + * Priority (first wins): `KIMI_MODEL_MAX_COMPLETION_TOKENS`, + * `KIMI_MODEL_MAX_TOKENS` (legacy alias), `reservedContextSize`, + * `DEFAULT_DESIRED_BUDGET` (32000, preserves pre-PR-2332 behavior). + * + * Operators can opt out of clamping entirely by setting the env var to + * `0` or a negative integer; in that case this function returns + * `undefined`, which `applyCompletionBudget` treats as a no-op. + */ +export function resolveCompletionBudget(args: { + readonly reservedContextSize?: number | undefined; + readonly env?: NodeJS.ProcessEnv | undefined; +}): CompletionBudget | undefined { + const env = args.env ?? process.env; + const fromNew = parseEnvBudget(env['KIMI_MODEL_MAX_COMPLETION_TOKENS']); + if (fromNew !== 'absent') { + return fromNew === 'disabled' ? undefined : { desired: fromNew }; + } + const fromLegacy = parseEnvBudget(env['KIMI_MODEL_MAX_TOKENS']); + if (fromLegacy !== 'absent') { + return fromLegacy === 'disabled' ? undefined : { desired: fromLegacy }; + } + if (args.reservedContextSize !== undefined && args.reservedContextSize > 0) { + return { desired: args.reservedContextSize }; + } + return { desired: DEFAULT_DESIRED_BUDGET }; +} + +type EnvBudget = number | 'disabled' | 'absent'; + +function parseEnvBudget(raw: string | undefined): EnvBudget { + if (raw === undefined || raw === '') return 'absent'; + const n = Number(raw); + if (!Number.isFinite(n) || !Number.isInteger(n)) return 'absent'; + if (n <= 0) return 'disabled'; + return n; +} + +/** + * Compute the effective `max_completion_tokens` cap for the next request. + * + * cap = clamp(desired, MIN_FLOOR, max_context_tokens - input - safetyMargin) + * + * `input` accounts for everything the provider will actually serialize: + * the conversation history, the system prompt, and the tool schemas. + * Counting only `messages` underestimates by enough to push a near-limit + * request past the model context window. + * + * When the model context size is unknown, the desired value is returned + * unchanged (floored at `MIN_FLOOR`). + * + * When the remaining window is non-positive (input already at or above + * the limit), `MIN_FLOOR` is returned — we can't honor a meaningful cap + * and the API will surface the overflow on its own. + * + * Note: the floor never exceeds `remaining`, so a near-full context + * cannot be pushed past the limit by `MIN_FLOOR` itself. + */ +export function computeCompletionBudgetCap(args: { + readonly budget: CompletionBudget; + readonly capability: ModelCapability | undefined; + readonly messages: readonly Message[]; + readonly systemPrompt?: string | undefined; + readonly tools?: readonly Tool[] | undefined; +}): number { + const desired = args.budget.desired; + const safetyMargin = args.budget.safetyMargin ?? DEFAULT_SAFETY_MARGIN; + const maxCtx = args.capability?.max_context_tokens ?? 0; + if (maxCtx <= 0) { + return Math.max(MIN_FLOOR, desired); + } + const input = + estimateTokensForMessages([...args.messages]) + + estimateTokens(args.systemPrompt ?? '') + + estimateTokensForTools(args.tools ?? []); + const remaining = maxCtx - input - safetyMargin; + if (remaining <= 0) { + return MIN_FLOOR; + } + return Math.max(MIN_FLOOR, Math.min(desired, remaining)); +} + +/** + * Apply a completion budget to a provider via its optional + * `withMaxCompletionTokens` capability. Returns the original provider + * unchanged when no budget is configured or the provider opts out. + * + * The returned provider is intentionally a shallow clone that shares the + * original's HTTP client. Callers MUST treat it as a single-step value + * and NOT persist it back to durable agent state — see the F3 discussion + * in `KimiChatProvider._clone()`. + */ +export function applyCompletionBudget(args: { + readonly provider: ChatProvider; + readonly budget: CompletionBudget | undefined; + readonly capability: ModelCapability | undefined; + readonly messages: readonly Message[]; + readonly systemPrompt?: string | undefined; + readonly tools?: readonly Tool[] | undefined; +}): ChatProvider { + if (args.budget === undefined) return args.provider; + if (args.provider.withMaxCompletionTokens === undefined) return args.provider; + const cap = computeCompletionBudgetCap({ + budget: args.budget, + capability: args.capability, + messages: args.messages, + systemPrompt: args.systemPrompt, + tools: args.tools, + }); + return args.provider.withMaxCompletionTokens(cap); +} diff --git a/packages/agent-core/src/utils/environment.ts b/packages/agent-core/src/utils/environment.ts new file mode 100644 index 000000000..a69065a1b --- /dev/null +++ b/packages/agent-core/src/utils/environment.ts @@ -0,0 +1,186 @@ +/** + * Environment — cross-platform probe of OS / shell. + * + * Detection is a pure function of injected probes (`platform` / `arch` / + * `release` / `env` / `isFile` / `findExecutable`) so the same suite runs + * identically on any host OS. `detectEnvironmentFromNode()` bundles the + * Node defaults for production callers. + * + * On Windows the probe expects Git Bash (the canonical POSIX shell that + * ships with Git for Windows). If it cannot be located the function + * throws `KimiError` with code `shell.git_bash_not_found`; the SDK layer + * can wrap that into a user-facing install hint. Set `KIMI_SHELL_PATH` + * to override. + */ + +import { constants as fsConstants } from 'node:fs'; +import { access } from 'node:fs/promises'; +import * as nodeOs from 'node:os'; + +import { ErrorCodes, KimiError } from '#/errors'; + +// `OsKind` carries 'macOS' / 'Linux' / 'Windows' for known platforms and +// falls back to the raw `process.platform` string for unknown ones (e.g. +// 'freebsd'). Typed as `string` so the union isn't inhabited-by-string. +export type OsKind = string; +export type ShellName = 'bash' | 'sh'; + +export interface Environment { + readonly osKind: OsKind; + readonly osArch: string; + readonly osVersion: string; + readonly shellName: ShellName; + readonly shellPath: string; +} + +export interface EnvironmentDeps { + // Accepts the full Node `Platform` enum plus arbitrary strings for + // forward-compatible OS kinds. + readonly platform: string; + readonly arch: string; + readonly release: string; + readonly env: Record; + readonly isFile: (path: string) => Promise; + readonly findExecutable: (name: string) => Promise; +} + +function resolveOsKind(platform: string): OsKind { + switch (platform) { + case 'darwin': + return 'macOS'; + case 'linux': + return 'Linux'; + case 'win32': + return 'Windows'; + default: + return platform; + } +} + +export async function detectEnvironment(deps: EnvironmentDeps): Promise { + const osKind = resolveOsKind(deps.platform); + const osArch = deps.arch; + const osVersion = deps.release; + + if (deps.platform === 'win32') { + const shellPath = await locateWindowsGitBash(deps); + return { osKind, osArch, osVersion, shellName: 'bash', shellPath }; + } + + const candidates: readonly string[] = ['/bin/bash', '/usr/bin/bash', '/usr/local/bin/bash']; + let found: string | undefined; + for (const p of candidates) { + if (await deps.isFile(p)) { + found = p; + break; + } + } + if (found !== undefined) { + return { osKind, osArch, osVersion, shellName: 'bash', shellPath: found }; + } + return { osKind, osArch, osVersion, shellName: 'sh', shellPath: '/bin/sh' }; +} + +async function locateWindowsGitBash(deps: EnvironmentDeps): Promise { + const checked: string[] = []; + + const override = deps.env['KIMI_SHELL_PATH']?.trim(); + if (override !== undefined && override.length > 0) { + checked.push(override); + if (await deps.isFile(override)) { + return override; + } + } + + const gitExe = await deps.findExecutable('git.exe'); + if (gitExe !== undefined) { + const inferred = inferGitBashFromGitExe(gitExe); + if (inferred !== undefined) { + checked.push(inferred); + if (await deps.isFile(inferred)) { + return inferred; + } + } + } + + const candidates: string[] = [ + 'C:\\Program Files\\Git\\bin\\bash.exe', + 'C:\\Program Files (x86)\\Git\\bin\\bash.exe', + ]; + const localAppData = deps.env['LOCALAPPDATA']?.trim(); + if (localAppData !== undefined && localAppData.length > 0) { + candidates.push(`${localAppData}\\Programs\\Git\\bin\\bash.exe`); + } + for (const candidate of candidates) { + checked.push(candidate); + if (await deps.isFile(candidate)) { + return candidate; + } + } + + throw new KimiError( + ErrorCodes.SHELL_GIT_BASH_NOT_FOUND, + `Git Bash was not found on this Windows host. Install Git for Windows from https://gitforwindows.org/ or set KIMI_SHELL_PATH to a bash.exe. Checked: ${checked.join(', ')}.`, + ); +} + +// Most Git for Windows installs put `git.exe` in `\cmd\git.exe`, +// with bash at `\bin\bash.exe`. Portable installs sometimes put +// both in `\bin\`. Walk back to the parent of `cmd` / `bin` and +// re-anchor under `bin\bash.exe`. +function inferGitBashFromGitExe(gitExe: string): string | undefined { + const sep = gitExe.includes('\\') ? '\\' : '/'; + const parts = gitExe.split(sep); + for (let i = parts.length - 2; i >= 0; i -= 1) { + const segment = parts[i]; + if (segment === 'cmd' || segment === 'bin') { + const root = parts.slice(0, i).join(sep); + return root.length === 0 ? `bin${sep}bash.exe` : `${root}${sep}bin${sep}bash.exe`; + } + } + return undefined; +} + +/** + * Production convenience — derive the deps bag from Node's ambient surface. + */ +export async function detectEnvironmentFromNode(): Promise { + const platform = process.platform; + const env = process.env as Record; + const isFile = async (path: string): Promise => { + try { + await access(path, fsConstants.F_OK); + return true; + } catch { + return false; + } + }; + return detectEnvironment({ + platform, + arch: process.arch, + release: nodeOs.release(), + env, + isFile, + findExecutable: (name: string) => findExecutableOnPath(name, env['PATH'], platform, isFile), + }); +} + +async function findExecutableOnPath( + name: string, + pathEnv: string | undefined, + platform: string, + isFile: (p: string) => Promise, +): Promise { + if (pathEnv === undefined || pathEnv.length === 0) return undefined; + const listSep = platform === 'win32' ? ';' : ':'; + const dirSep = platform === 'win32' ? '\\' : '/'; + for (const rawDir of pathEnv.split(listSep)) { + const dir = rawDir.trim(); + if (dir.length === 0) continue; + const candidate = dir.endsWith(dirSep) ? `${dir}${name}` : `${dir}${dirSep}${name}`; + if (await isFile(candidate)) { + return candidate; + } + } + return undefined; +} diff --git a/packages/agent-core/src/utils/fs.ts b/packages/agent-core/src/utils/fs.ts new file mode 100644 index 000000000..79aef2e49 --- /dev/null +++ b/packages/agent-core/src/utils/fs.ts @@ -0,0 +1,187 @@ +/** + * Low-level POSIX durability primitives. + * + * Two concerns that every durable write must handle: + * 1. file *contents* — solved by `fh.sync()` after the write + * 2. directory *entries* — solved by opening the parent directory and + * calling `fh.sync()` on the directory handle + * + * `fh.sync()` on a file does NOT guarantee that the directory entry + * pointing at that file has been committed. On POSIX a crash between + * the file-content fsync and the parent-directory fsync can leave the + * file's bytes on disk with no visible name. The primary durable path + * is POSIX; Windows is best-effort — NTFS's MoveFileEx commits the + * dirent inside the file fsync, so a separate directory fsync is a + * no-op (and EISDIR-fails on `open(dir, 'r')`). + */ +import { randomBytes } from 'node:crypto'; +import { closeSync, fsyncSync, openSync } from 'node:fs'; +import * as nodeFs from 'node:fs'; +import { open, rename, unlink } from 'node:fs/promises'; +import { dirname } from 'node:path'; + +/** + * Open a directory read-only and fsync it, then close. Used to make a + * freshly-created or renamed file's directory entry durable. + * + * Windows: noop. `open(dir, 'r')` throws EISDIR, and NTFS commits the + * dirent transaction inside the file fsync anyway — the separate dir + * fsync would buy nothing even if we could issue it. + */ +export async function syncDir(dirPath: string): Promise { + if (process.platform === 'win32') return; + const dirFh = await open(dirPath, 'r'); + try { + await dirFh.sync(); + } finally { + await dirFh.close(); + } +} +/** + * Synchronous variant of `syncDir`. Used by batched drain paths where a + * single timer fire needs to be an atomic event-loop step. Windows + * mirrors the async variant — noop. + */ +export function syncDirSync(dirPath: string): void { + if (process.platform === 'win32') return; + const fd = openSync(dirPath, 'r'); + try { + fsyncSync(fd); + } finally { + closeSync(fd); + } +} +/** + * Write `content` to `filePath` atomically and durably: + * 1. Write content to `.tmp`, fsync it, close it. + * 2. Rename `.tmp` → `filePath` (atomic on POSIX). + * 3. fsync the parent directory so the rename is durable. + * + * On any failure before the rename the `.tmp` file is removed so the + * caller's directory is not left with a half-written leftover. A + * failure *after* the rename (i.e. in the parent-directory fsync) is + * surfaced to the caller — the content is already in place, but + * durability is not guaranteed. + */ +export async function writeFileAtomicDurable( + filePath: string, + content: string | Uint8Array, +): Promise { + const tmpPath = filePath + '.tmp'; + let renamed = false; + try { + const fh = await open(tmpPath, 'w'); + try { + await fh.writeFile(content); + await fh.sync(); + } finally { + await fh.close(); + } + // Windows pre-unlink for MoveFileEx parity. + if (process.platform === 'win32') { + try { + await unlink(filePath); + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code !== 'ENOENT') throw error; + } + } + await rename(tmpPath, filePath); + renamed = true; + await syncDir(dirname(filePath)); + } finally { + if (!renamed) { + // Best-effort cleanup of the `.tmp` file if we never got to the + // rename. Swallow ENOENT because the file may not exist (open + // itself failed) or may already have been unlinked. + try { + await unlink(tmpPath); + } catch { + /* ignore */ + } + } + } +} + +/** + * atomicWrite — cross-platform atomic file replacement. + * + * Guarantees that readers never observe a half-written file: + * 1. Write content to a uniquely-named temp file in the same directory. + * 2. fsync the temp file so the bytes are durable. + * 3. rename(tmp, target) — atomic on POSIX. + * 4. On any failure before the rename, unlink the temp file (best effort). + * + * Does NOT fsync the parent directory; callers that need full POSIX + * crash durability should `await syncDir(dirname(path))` after this call. + * + * NOT suitable for append-only paths (wire.jsonl). Those use + * `JournalWriter.append()` which writes at the current file position. + */ + +/** + * fsync a file descriptor using the callback-based `fs.fsync`. We go + * through the module namespace (`nodeFs.fsync`) rather than + * `FileHandle.sync()` so vitest's `vi.spyOn(fs, 'fsync')` can + * intercept the call for fault-injection tests. + */ +function syncFd(fd: number): Promise { + return new Promise((resolve, reject) => { + nodeFs.fsync(fd, (err) => { + if (err) { + reject(err); + return; + } + resolve(); + }); + }); +} + +/** + * Atomically write `content` to `filePath`. If the target already exists + * it is replaced; if it does not exist it is created. + * + * @param filePath — absolute or relative path to the target file. + * @param content — string or binary payload to write. + * @param _syncOverride — test seam: override the fsync implementation for + * fault injection. Production callers must never supply this. + */ +export async function atomicWrite( + filePath: string, + content: string | Uint8Array, + _syncOverride?: (fd: number) => Promise, +): Promise { + const hex = randomBytes(4).toString('hex'); + const tmpPath = `${filePath}.tmp.${process.pid}.${hex}`; + let renamed = false; + try { + const fh = await open(tmpPath, 'w'); + try { + await fh.writeFile(content); + await (_syncOverride ?? syncFd)(fh.fd); + } finally { + await fh.close(); + } + // Windows `fs.rename` maps to MoveFileEx and fails with EPERM if + // the target is held by another handle. Pre-unlinking + // before the rename turns this into the POSIX-style "replace" case. + if (process.platform === 'win32') { + try { + await unlink(filePath); + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code !== 'ENOENT') throw error; + } + } + await rename(tmpPath, filePath); + renamed = true; + } finally { + if (!renamed) { + try { + await unlink(tmpPath); + } catch { + /* ignore — file may not exist if open itself failed */ + } + } + } +} diff --git a/packages/agent-core/src/utils/hero-slug.ts b/packages/agent-core/src/utils/hero-slug.ts new file mode 100644 index 000000000..e460a7806 --- /dev/null +++ b/packages/agent-core/src/utils/hero-slug.ts @@ -0,0 +1,267 @@ +/** Hero-name slug generator: produces "hero-hero-hero" with collision fallback. */ + +import { randomInt } from 'node:crypto'; + +export const HERO_NAMES = [ + // --- Marvel --- + 'iron-man', + 'spider-man', + 'captain-america', + 'thor', + 'hulk', + 'black-widow', + 'hawkeye', + 'black-panther', + 'doctor-strange', + 'scarlet-witch', + 'vision', + 'falcon', + 'war-machine', + 'ant-man', + 'wasp', + 'captain-marvel', + 'gamora', + 'star-lord', + 'groot', + 'rocket', + 'drax', + 'mantis', + 'nebula', + 'shang-chi', + 'moon-knight', + 'ms-marvel', + 'she-hulk', + 'echo', + 'wolverine', + 'cyclops', + 'storm', + 'jean-grey', + 'rogue', + 'beast', + 'nightcrawler', + 'colossus', + 'shadowcat', + 'jubilee', + 'cable', + 'deadpool', + 'bishop', + 'magik', + 'iceman', + 'archangel', + 'psylocke', + 'dazzler', + 'forge', + 'havok', + 'polaris', + 'emma-frost', + 'namor', + 'silver-surfer', + 'adam-warlock', + 'nova', + 'quasar', + 'sentry', + 'blue-marvel', + 'spectrum', + 'squirrel-girl', + 'cloak', + 'dagger', + 'punisher', + 'elektra', + 'luke-cage', + 'iron-fist', + 'jessica-jones', + 'daredevil', + 'blade', + 'ghost-rider', + 'morbius', + 'venom', + 'carnage', + 'silk', + 'spider-gwen', + 'miles-morales', + 'america-chavez', + 'kate-bishop', + 'yelena-belova', + 'white-tiger', + 'moon-girl', + 'devil-dinosaur', + 'amadeus-cho', + 'riri-williams', + 'kamala-khan', + 'sam-alexander', + 'nova-prime', + 'medusa', + 'black-bolt', + 'crystal', + 'karnak', + 'gorgon', + 'lockjaw', + 'quake', + 'mockingbird', + 'bobbi-morse', + 'maria-hill', + 'nick-fury', + 'phil-coulson', + 'winter-soldier', + 'us-agent', + 'patriot', + 'speed', + 'wiccan', + 'hulkling', + 'stature', + 'yellowjacket', + 'tigra', + 'hellcat', + 'valkyrie', + 'sif', + 'beta-ray-bill', + 'hercules', + 'wonder-man', + 'taskmaster', + 'domino', + 'cannonball', + 'sunspot', + 'wolfsbane', + 'warpath', + 'multiple-man', + 'banshee', + 'siryn', + 'monet', + 'rictor', + 'shatterstar', + 'longshot', + 'daken', + 'x-23', + 'fantomex', + // --- DC --- + 'batman', + 'superman', + 'wonder-woman', + 'flash', + 'aquaman', + 'green-lantern', + 'martian-manhunter', + 'cyborg', + 'hawkgirl', + 'green-arrow', + 'black-canary', + 'zatanna', + 'constantine', + 'shazam', + 'blue-beetle', + 'booster-gold', + 'firestorm', + 'atom', + 'hawkman', + 'plastic-man', + 'red-tornado', + 'starfire', + 'raven', + 'beast-boy', + 'robin', + 'nightwing', + 'batgirl', + 'batwoman', + 'red-hood', + 'signal', + 'orphan', + 'spoiler', + 'catwoman', + 'huntress', + 'supergirl', + 'superboy', + 'power-girl', + 'steel', + 'stargirl', + 'wildcat', + 'doctor-fate', + 'mister-terrific', + 'hourman', + 'sandman', + 'spectre', + 'phantom-stranger', + 'swamp-thing', + 'animal-man', + 'deadman', + 'vixen', + 'black-lightning', + 'static', + 'icon', + 'rocket-dc', + 'captain-atom', + 'fire', + 'ice', + 'elongated-man', + 'metamorpho', + 'black-hawk', + 'crimson-avenger', + 'doctor-mid-nite', + 'jakeem-thunder', + 'mister-miracle', + 'big-barda', + 'orion', + 'lightray', + 'forager', + 'killer-frost', + 'jessica-cruz', + 'simon-baz', + 'john-stewart', + 'guy-gardner', + 'kyle-rayner', + 'hal-jordan', + 'wally-west', + 'barry-allen', + 'jay-garrick', + 'impulse', + 'kid-flash', + 'donna-troy', + 'tempest', + 'aqualad', + 'miss-martian', + 'terra', + 'jericho', + 'ravager', + 'red-star', + 'pantha', + 'argent', + 'damage', + 'jade', + 'obsidian', + 'cyclone', + 'atom-smasher', + 'maxima', + 'starman', + 'liberty-belle', + 'dove', + 'hawk', + 'blue-devil', + 'creeper', + 'ragman', + 'thunder', +] as const satisfies readonly [string, ...string[]]; + +const MAX_ATTEMPTS = 20; + +function pickHero(): string { + return HERO_NAMES[randomInt(HERO_NAMES.length)]!; +} + +function assembleSlug(): string { + return `${pickHero()}-${pickHero()}-${pickHero()}`; +} + +export function generateHeroSlug(id: string, existing: Set): string { + let slug = ''; + let collided = true; + for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt++) { + slug = assembleSlug(); + if (!existing.has(slug)) { + collided = false; + break; + } + } + if (collided) { + slug = `${slug}-${id.slice(0, 8)}`; + } + return slug; +} diff --git a/packages/agent-core/src/utils/render-prompt.ts b/packages/agent-core/src/utils/render-prompt.ts new file mode 100644 index 000000000..579665e8c --- /dev/null +++ b/packages/agent-core/src/utils/render-prompt.ts @@ -0,0 +1,19 @@ +import nunjucks from 'nunjucks'; + +/** + * Shared prompt template renderer. + * + * All prompt templates (system prompt, tool descriptions, compaction + * instruction, ...) use nunjucks `{{ var }}` / `{% if %}` syntax and render + * through this one function. + * + * - `autoescape: false` — prompt text is not HTML; `<`, `>`, `&` must pass + * through verbatim. + * - `throwOnUndefined: true` — a missing variable is a loud error, never a + * silently leaked `{{ placeholder }}` in the text sent to the model. + */ +const env = new nunjucks.Environment(null, { autoescape: false, throwOnUndefined: true }); + +export function renderPrompt(template: string, vars: Record): string { + return env.renderString(template, vars); +} diff --git a/packages/agent-core/src/utils/tokens.ts b/packages/agent-core/src/utils/tokens.ts new file mode 100644 index 000000000..5ccef7d00 --- /dev/null +++ b/packages/agent-core/src/utils/tokens.ts @@ -0,0 +1,63 @@ +import type { ContentPart, Message, Tool } from '@moonshot-ai/kosong'; + +/** + * Estimate token count from text using a character-based heuristic. + * - ASCII (~4 chars per token) + * - CJK and other non-ASCII (~1 char per token) + * The estimate is transient — the next LLM call returns the real count + * and supersedes this value. Used to keep `tokenCountWithPending` + * monotonic between LLM round-trips without paying for a tokenizer. + */ +export function estimateTokens(text: string): number { + let asciiCount = 0; + let nonAsciiCount = 0; + for (const char of text) { + if (char.codePointAt(0)! <= 127) { + asciiCount++; + } else { + nonAsciiCount++; + } + } + return Math.ceil(asciiCount / 4) + nonAsciiCount; +} + +export function estimateTokensForMessages(messages: Message[]): number { + let total = 0; + for (const message of messages) { + total += estimateTokensForMessage(message); + } + return total; +} + +export function estimateTokensForTools(tools: readonly Tool[]): number { + let total = 0; + for (const tool of tools) { + total += estimateTokens(tool.name); + total += estimateTokens(tool.description); + total += estimateTokens(JSON.stringify(tool.parameters)); + } + return total; +} + +export function estimateTokensForMessage(message: Message): number { + let total = estimateTokens(message.role); + for (const part of message.content) { + total += estimateTokensForContentPart(part); + } + if (message.toolCalls !== undefined) { + for (const call of message.toolCalls) { + total += estimateTokens(call.function.name); + total += estimateTokens(JSON.stringify(call.function.arguments)); + } + } + return total; +} + +export function estimateTokensForContentPart(part: ContentPart): number { + if (part.type === 'text') { + return estimateTokens(part.text); + } else if (part.type === 'think') { + return estimateTokens(part.think); + } + return 0; +} diff --git a/packages/agent-core/src/utils/types.ts b/packages/agent-core/src/utils/types.ts new file mode 100644 index 000000000..45a0d1c9d --- /dev/null +++ b/packages/agent-core/src/utils/types.ts @@ -0,0 +1,13 @@ +export type Promisify = [T] extends [Promise] ? T : Promise; +export type PromisifyMethods = { + [K in keyof T]: T[K] extends (...args: infer Args) => infer Return + ? (...args: Args) => Promisify + : never; +}; + +export type Promisable = [T] extends [Promise] ? T | Awaited : T | Promise; +export type PromisableMethods = { + [K in keyof T]: T[K] extends (...args: infer Args) => infer Return + ? (...args: Args) => Promisable + : never; +}; diff --git a/packages/agent-core/src/utils/workdir-slug.ts b/packages/agent-core/src/utils/workdir-slug.ts new file mode 100644 index 000000000..5f8da29a6 --- /dev/null +++ b/packages/agent-core/src/utils/workdir-slug.ts @@ -0,0 +1,11 @@ +const MAX_WORKDIR_SLUG_LENGTH = 40; + +export function slugifyWorkDirName(name: string): string { + const slug = name + .toLowerCase() + .replaceAll(/[^a-z0-9._-]+/g, '-') + .replaceAll(/^-+|-+$/g, '') + .slice(0, MAX_WORKDIR_SLUG_LENGTH) + .replaceAll(/^-+|-+$/g, ''); + return slug === '' || slug === '.' || slug === '..' ? 'workspace' : slug; +} diff --git a/packages/agent-core/src/version.ts b/packages/agent-core/src/version.ts new file mode 100644 index 000000000..5436e6c6f --- /dev/null +++ b/packages/agent-core/src/version.ts @@ -0,0 +1,13 @@ +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; + +export function getCoreVersion(): string { + try { + const pkgUrl = new URL('../package.json', import.meta.url); + const raw = readFileSync(fileURLToPath(pkgUrl), 'utf-8'); + const pkg = JSON.parse(raw) as { version?: unknown }; + return typeof pkg.version === 'string' ? pkg.version : '0.0.0'; + } catch { + return '0.0.0'; + } +} diff --git a/packages/agent-core/test/agent/background-manager.test.ts b/packages/agent-core/test/agent/background-manager.test.ts new file mode 100644 index 000000000..882fb9016 --- /dev/null +++ b/packages/agent-core/test/agent/background-manager.test.ts @@ -0,0 +1,628 @@ +/** + * Covers: BackgroundManager (the agent-aware subclass). + * + * Confirms that BPM lifecycle transitions are translated into + * agent.emitEvent({ type: 'background.task.*' }) so SDK / TUI + * subscribers can react in real time without polling. + */ + +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { Readable } from 'node:stream'; +import type { Writable } from 'node:stream'; + +import type { KaosProcess } from '@moonshot-ai/kaos'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { BackgroundManager } from '../../src/agent/background'; +import type { AgentEvent } from '../../src/rpc/events'; +import { appendTaskOutput, writeTask } from '../../src/tools/background/persist'; + +interface FakeAgent { + emitEvent: (event: AgentEvent) => void; + emittedEvents: AgentEvent[]; + hooks?: { fireAndForgetTrigger: ReturnType }; + turn: { + hasActiveTurn: boolean; + waitForCurrentTurn: () => Promise; + steer: (...args: unknown[]) => number | null; + }; + context: { appendUserMessage: (...args: unknown[]) => void }; + records: { restoring: boolean; logRecord: (record: unknown) => void }; + telemetry: { track: ReturnType }; + background: BackgroundManager; +} + +function makeAgent(options: { hooks?: FakeAgent['hooks'] } = {}): FakeAgent { + const emitted: AgentEvent[] = []; + const agent = { + emittedEvents: emitted, + emitEvent: (event: AgentEvent) => { + emitted.push(event); + }, + hooks: options.hooks, + turn: { + hasActiveTurn: false, + waitForCurrentTurn: vi.fn(() => Promise.resolve()), + steer: vi.fn(() => 1), + }, + context: { appendUserMessage: vi.fn() }, + records: { restoring: false, logRecord: vi.fn() }, + telemetry: { track: vi.fn() }, + } as unknown as FakeAgent; + const manager = new BackgroundManager(agent as never); + agent.background = manager; + return agent; +} + +function immediateProcess(exitCode: number): KaosProcess { + return { + stdin: { write: vi.fn(), end: vi.fn() } as unknown as Writable, + stdout: Readable.from([]), + stderr: Readable.from([]), + pid: 30000 + exitCode, + exitCode, + wait: vi.fn().mockResolvedValue(exitCode) as KaosProcess['wait'], + kill: vi.fn().mockResolvedValue(undefined) as KaosProcess['kill'], + }; +} + +function pendingProcess(): KaosProcess { + let resolveWait: (code: number) => void = () => {}; + const waitPromise = new Promise((res) => { + resolveWait = res; + }); + let currentExitCode: number | null = null; + return { + stdin: { write: vi.fn(), end: vi.fn() } as unknown as Writable, + stdout: Readable.from([]), + stderr: Readable.from([]), + pid: 99999, + get exitCode(): number | null { + return currentExitCode; + }, + wait: () => waitPromise, + kill: vi.fn(async () => { + if (currentExitCode === null) { + currentExitCode = 143; + resolveWait(143); + } + }) as unknown as KaosProcess['kill'], + }; +} + +describe('BackgroundManager — RPC event emission', () => { + let agent: FakeAgent; + + beforeEach(() => { + agent = makeAgent(); + }); + + afterEach(() => { + agent.background._reset(); + }); + + it('emits background.task.started on register()', () => { + const taskId = agent.background.register(pendingProcess(), 'sleep 60', 'demo'); + + const started = agent.emittedEvents.filter((e) => e.type === 'background.task.started'); + expect(started.length).toBe(1); + expect(started[0]!.info.taskId).toBe(taskId); + expect(started[0]!.info.status).toBe('running'); + expect(agent.telemetry.track).toHaveBeenCalledWith('background_task_created', { + kind: 'bash', + }); + }); + + it('emits background.task.started on registerAgentTask()', () => { + const taskId = agent.background.registerAgentTask(new Promise(() => {}), 'agent task'); + + const started = agent.emittedEvents.filter((e) => e.type === 'background.task.started'); + expect(started.length).toBe(1); + expect(started[0]!.info.taskId).toBe(taskId); + expect(taskId).toMatch(/^agent-/); + expect(agent.telemetry.track).toHaveBeenCalledWith('background_task_created', { + kind: 'agent', + }); + }); + + it('emits background.task.updated on awaiting_approval transitions', () => { + const taskId = agent.background.register(pendingProcess(), 'sleep', 'demo'); + agent.emittedEvents.length = 0; + + agent.background.markAwaitingApproval(taskId, 'needs approval'); + agent.background.clearAwaitingApproval(taskId); + + const updated = agent.emittedEvents.filter((e) => e.type === 'background.task.updated'); + expect(updated.length).toBe(2); + expect(updated[0]!.info.status).toBe('awaiting_approval'); + expect(updated[1]!.info.status).toBe('running'); + }); + + it('emits background.task.terminated on natural exit', async () => { + agent.background.register(immediateProcess(0), 'echo', 'done'); + await new Promise((r) => setTimeout(r, 20)); + + const terminated = agent.emittedEvents.filter((e) => e.type === 'background.task.terminated'); + expect(terminated.length).toBe(1); + expect(terminated[0]!.info.status).toBe('completed'); + }); + + it('tracks successful task completion with duration and no reason', async () => { + const taskId = agent.background.register(immediateProcess(0), 'echo ok', 'done'); + agent.telemetry.track.mockClear(); + + await agent.background.waitForTerminal(taskId); + + expect(agent.telemetry.track).toHaveBeenCalledWith( + 'background_task_completed', + expect.objectContaining({ + kind: 'bash', + success: true, + duration_s: expect.any(Number), + }), + ); + expect(agent.telemetry.track.mock.calls[0]?.[1]).not.toHaveProperty('reason'); + }); + + it('tracks failed task completion with reason=error', async () => { + const taskId = agent.background.register(immediateProcess(1), 'false', 'failed'); + agent.telemetry.track.mockClear(); + + await agent.background.waitForTerminal(taskId); + + expect(agent.telemetry.track).toHaveBeenCalledWith( + 'background_task_completed', + expect.objectContaining({ + kind: 'bash', + success: false, + reason: 'error', + duration_s: expect.any(Number), + }), + ); + }); + + it('tracks timed-out agent tasks with reason=timeout', async () => { + const taskId = agent.background.registerAgentTask(new Promise(() => {}), 'slow agent', { + timeoutMs: 1, + }); + agent.telemetry.track.mockClear(); + + await agent.background.waitForTerminal(taskId); + + expect(agent.telemetry.track).toHaveBeenCalledWith( + 'background_task_completed', + expect.objectContaining({ + kind: 'agent', + success: false, + reason: 'timeout', + duration_s: expect.any(Number), + }), + ); + }); + + it('emits background.task.terminated on stop()', async () => { + const taskId = agent.background.register(pendingProcess(), 'sleep 60', 'long'); + agent.emittedEvents.length = 0; + + await agent.background.stop(taskId, 'user'); + + const terminated = agent.emittedEvents.filter((e) => e.type === 'background.task.terminated'); + expect(terminated.length).toBe(1); + expect(terminated[0]!.info.status).toBe('killed'); + }); + + it('emits background.task.terminated when a restored task is marked lost', async () => { + const sessionDir = await mkdtemp(join(tmpdir(), 'kimi-bg-agent-reconcile-')); + try { + agent.background.attachSessionDir(sessionDir); + await writeTask(sessionDir, { + task_id: 'bash-orphan00', + command: 'sleep 60', + description: 'orphan task', + pid: 99999, + started_at: 1_700_000_000, + ended_at: null, + exit_code: null, + status: 'running', + }); + agent.emittedEvents.length = 0; + + await agent.background.loadFromDisk(); + await agent.background.reconcile(); + + const terminated = agent.emittedEvents.filter( + (e) => e.type === 'background.task.terminated', + ); + expect(terminated.length).toBe(1); + expect(terminated[0]!.info.taskId).toBe('bash-orphan00'); + expect(terminated[0]!.info.status).toBe('lost'); + } finally { + await rm(sessionDir, { recursive: true, force: true }); + } + }); + + it('steers completed agent task notifications into the turn flow', async () => { + const taskId = agent.background.registerAgentTask( + Promise.resolve({ result: 'final subagent summary' }), + 'agent task', + ); + await agent.background.waitForTerminal(taskId); + + await vi.waitFor(() => { + expect(agent.turn.steer).toHaveBeenCalledTimes(1); + }); + expect(agent.turn.waitForCurrentTurn).not.toHaveBeenCalled(); + expect(agent.context.appendUserMessage).not.toHaveBeenCalled(); + + const [content, origin] = vi.mocked(agent.turn.steer).mock.calls[0]!; + expect(origin).toEqual({ + kind: 'background_task', + taskId, + status: 'completed', + notificationId: `task:${taskId}:completed`, + }); + expect((content as Array<{ text: string }>)[0]!.text).toContain( + 'Background agent completed', + ); + expect((content as Array<{ text: string }>)[0]!.text).toContain('final subagent summary'); + }); + + it('steers completed bash task notifications into the turn flow', async () => { + const taskId = agent.background.register(immediateProcess(0), 'echo ok', 'shell task'); + + await agent.background.waitForTerminal(taskId); + + await vi.waitFor(() => { + expect(agent.turn.steer).toHaveBeenCalledTimes(1); + }); + expect(agent.turn.waitForCurrentTurn).not.toHaveBeenCalled(); + expect(agent.context.appendUserMessage).not.toHaveBeenCalled(); + + const [content, origin] = vi.mocked(agent.turn.steer).mock.calls[0]!; + expect(origin).toEqual({ + kind: 'background_task', + taskId, + status: 'completed', + notificationId: `task:${taskId}:completed`, + }); + expect((content as Array<{ text: string }>)[0]!.text).toContain( + 'Background task completed', + ); + expect((content as Array<{ text: string }>)[0]!.text).toContain('shell task completed.'); + }); + + it('steers stopped bash task notifications into the turn flow', async () => { + const taskId = agent.background.register(pendingProcess(), 'sleep 60', 'long shell task'); + + await agent.background.stop(taskId); + + await vi.waitFor(() => { + expect(agent.turn.steer).toHaveBeenCalledTimes(1); + }); + const [content, origin] = vi.mocked(agent.turn.steer).mock.calls[0]!; + expect(origin).toEqual({ + kind: 'background_task', + taskId, + status: 'killed', + notificationId: `task:${taskId}:killed`, + }); + expect((content as Array<{ text: string }>)[0]!.text).toContain('Background task killed'); + expect((content as Array<{ text: string }>)[0]!.text).toContain('long shell task killed.'); + }); + + it('queues background agent notifications without waiting for an active turn', async () => { + agent.turn.hasActiveTurn = true; + const taskId = agent.background.registerAgentTask( + Promise.resolve({ result: 'active turn summary' }), + 'agent task', + ); + await agent.background.waitForTerminal(taskId); + + await vi.waitFor(() => { + expect(agent.turn.steer).toHaveBeenCalledTimes(1); + }); + expect(agent.turn.waitForCurrentTurn).not.toHaveBeenCalled(); + const [content, origin] = vi.mocked(agent.turn.steer).mock.calls[0]!; + expect(origin).toEqual({ + kind: 'background_task', + taskId, + status: 'completed', + notificationId: `task:${taskId}:completed`, + }); + expect((content as Array<{ text: string }>)[0]!.text).toContain('active turn summary'); + }); + + it('replays restored terminal agent task notifications when they were not delivered', async () => { + const sessionDir = await mkdtemp(join(tmpdir(), 'kimi-bg-agent-replay-')); + try { + agent.background.attachSessionDir(sessionDir); + await writeTask(sessionDir, { + task_id: 'agent-done0000', + command: '[agent] restored task', + description: 'restored task', + pid: 0, + started_at: 1_700_000_000, + ended_at: 1_700_000_010, + exit_code: 0, + status: 'completed', + }); + await appendTaskOutput(sessionDir, 'agent-done0000', 'restored subagent summary'); + + await agent.background.loadFromDisk(); + const result = await agent.background.reconcile(); + + expect(result.lost).toEqual([]); + await vi.waitFor(() => { + expect(agent.context.appendUserMessage).toHaveBeenCalledTimes(1); + }); + expect(agent.turn.steer).not.toHaveBeenCalled(); + const [content, origin] = vi.mocked(agent.context.appendUserMessage).mock.calls[0]!; + expect(origin).toEqual({ + kind: 'background_task', + taskId: 'agent-done0000', + status: 'completed', + notificationId: 'task:agent-done0000:completed', + }); + expect((content as Array<{ text: string }>)[0]!.text).toContain( + 'Background agent completed', + ); + expect((content as Array<{ text: string }>)[0]!.text).toContain('restored subagent summary'); + } finally { + await rm(sessionDir, { recursive: true, force: true }); + } + }); + + it('replays restored terminal bash task notifications when they were not delivered', async () => { + const sessionDir = await mkdtemp(join(tmpdir(), 'kimi-bg-bash-replay-')); + try { + agent.background.attachSessionDir(sessionDir); + await writeTask(sessionDir, { + task_id: 'bash-done0000', + command: 'echo done', + description: 'restored shell task', + pid: 12345, + started_at: 1_700_000_000, + ended_at: 1_700_000_010, + exit_code: 0, + status: 'completed', + }); + await appendTaskOutput(sessionDir, 'bash-done0000', 'restored shell output'); + + await agent.background.loadFromDisk(); + const result = await agent.background.reconcile(); + + expect(result.lost).toEqual([]); + await vi.waitFor(() => { + expect(agent.context.appendUserMessage).toHaveBeenCalledTimes(1); + }); + expect(agent.turn.steer).not.toHaveBeenCalled(); + const [content, origin] = vi.mocked(agent.context.appendUserMessage).mock.calls[0]!; + expect(origin).toEqual({ + kind: 'background_task', + taskId: 'bash-done0000', + status: 'completed', + notificationId: 'task:bash-done0000:completed', + }); + expect((content as Array<{ text: string }>)[0]!.text).toContain( + 'Background task completed', + ); + expect((content as Array<{ text: string }>)[0]!.text).toContain('restored shell output'); + } finally { + await rm(sessionDir, { recursive: true, force: true }); + } + }); + + it('reads only a bounded output tail for restored bash task notifications', async () => { + const sessionDir = await mkdtemp(join(tmpdir(), 'kimi-bg-bash-tail-')); + try { + const taskId = 'bash-large000'; + const largeOutput = `early-output-marker\n${'x'.repeat(8_000)}\nfinal output line`; + agent.background.attachSessionDir(sessionDir); + await writeTask(sessionDir, { + task_id: taskId, + command: 'generate large output', + description: 'large shell task', + pid: 12345, + started_at: 1_700_000_000, + ended_at: 1_700_000_010, + exit_code: 0, + status: 'completed', + }); + await appendTaskOutput(sessionDir, taskId, largeOutput); + const readOutputSpy = vi.spyOn(agent.background, 'readOutput'); + const snapshotSpy = vi.spyOn(agent.background, 'getOutputSnapshot'); + + await agent.background.loadFromDisk(); + await agent.background.reconcile(); + + await vi.waitFor(() => { + expect(agent.context.appendUserMessage).toHaveBeenCalledTimes(1); + }); + expect(readOutputSpy).not.toHaveBeenCalled(); + expect(snapshotSpy).toHaveBeenCalledWith(taskId, expect.any(Number)); + expect(snapshotSpy.mock.calls[0]![1]).toBeLessThan(largeOutput.length); + const [content] = vi.mocked(agent.context.appendUserMessage).mock.calls[0]!; + const text = (content as Array<{ text: string }>)[0]!.text; + expect(text).toContain('final output line'); + expect(text).not.toContain('early-output-marker'); + } finally { + await rm(sessionDir, { recursive: true, force: true }); + } + }); + + it('does not replay restored agent task notifications already marked delivered', async () => { + const sessionDir = await mkdtemp(join(tmpdir(), 'kimi-bg-agent-replay-')); + try { + const origin = { + kind: 'background_task', + taskId: 'agent-seen0000', + status: 'completed', + notificationId: 'task:agent-seen0000:completed', + } as const; + agent.background.markDeliveredNotification(origin); + agent.background.attachSessionDir(sessionDir); + await writeTask(sessionDir, { + task_id: 'agent-seen0000', + command: '[agent] already delivered', + description: 'already delivered', + pid: 0, + started_at: 1_700_000_000, + ended_at: 1_700_000_010, + exit_code: 0, + status: 'completed', + }); + await appendTaskOutput(sessionDir, 'agent-seen0000', 'already delivered summary'); + + await agent.background.loadFromDisk(); + await agent.background.reconcile(); + + expect(agent.turn.steer).not.toHaveBeenCalled(); + expect(agent.context.appendUserMessage).not.toHaveBeenCalled(); + } finally { + await rm(sessionDir, { recursive: true, force: true }); + } + }); + + it('does not double-notify newly lost restored agent tasks', async () => { + const sessionDir = await mkdtemp(join(tmpdir(), 'kimi-bg-agent-replay-')); + try { + agent.background.attachSessionDir(sessionDir); + await writeTask(sessionDir, { + task_id: 'agent-run00000', + command: '[agent] interrupted task', + description: 'interrupted task', + pid: 0, + started_at: 1_700_000_000, + ended_at: null, + exit_code: null, + status: 'running', + }); + + await agent.background.loadFromDisk(); + const result = await agent.background.reconcile(); + + expect(result.lost).toEqual(['agent-run00000']); + await vi.waitFor(() => { + expect(agent.context.appendUserMessage).toHaveBeenCalledTimes(1); + }); + expect(agent.turn.steer).not.toHaveBeenCalled(); + const [content, origin] = vi.mocked(agent.context.appendUserMessage).mock.calls[0]!; + expect(origin).toEqual({ + kind: 'background_task', + taskId: 'agent-run00000', + status: 'lost', + notificationId: 'task:agent-run00000:lost', + }); + expect((content as Array<{ text: string }>)[0]!.text).toContain('Background agent lost'); + } finally { + await rm(sessionDir, { recursive: true, force: true }); + } + }); + + it('fires a Notification hook when a background agent notification is delivered', async () => { + const fireAndForgetTrigger = vi.fn(() => Promise.resolve([])); + agent = makeAgent({ hooks: { fireAndForgetTrigger } }); + + const taskId = agent.background.registerAgentTask( + Promise.resolve({ result: 'final agent output' }), + 'inspect repository', + ); + await agent.background.wait(taskId); + + await vi.waitFor(() => { + expect(agent.turn.steer).toHaveBeenCalled(); + expect(fireAndForgetTrigger).toHaveBeenCalled(); + }); + expect(fireAndForgetTrigger).toHaveBeenCalledWith('Notification', { + matcherValue: 'task.completed', + inputData: { + sink: 'context', + notificationType: 'task.completed', + title: 'Background agent completed', + body: 'inspect repository completed.', + severity: 'info', + sourceKind: 'background_task', + sourceId: taskId, + }, + }); + }); + + it('does not let Notification hook failures interrupt background notification delivery', async () => { + const fireAndForgetTrigger = vi.fn(() => { + throw new Error('notification hook failed'); + }); + agent = makeAgent({ hooks: { fireAndForgetTrigger } }); + + const taskId = agent.background.registerAgentTask( + Promise.resolve({ result: 'final agent output' }), + 'inspect repository', + ); + await agent.background.wait(taskId); + + await vi.waitFor(() => { + expect(agent.turn.steer).toHaveBeenCalled(); + expect(fireAndForgetTrigger).toHaveBeenCalled(); + }); + expect(agent.turn.steer).toHaveBeenCalledWith( + [ + { + type: 'text', + text: expect.stringContaining(`source_id="${taskId}"`), + }, + ], + { + kind: 'background_task', + taskId, + status: 'completed', + notificationId: `task:${taskId}:completed`, + }, + ); + }); + + it('fires Notification hooks for bash background task notifications', async () => { + const fireAndForgetTrigger = vi.fn(() => Promise.resolve([])); + agent = makeAgent({ hooks: { fireAndForgetTrigger } }); + + const taskId = agent.background.register(immediateProcess(0), 'echo', 'done'); + await agent.background.waitForTerminal(taskId); + + await vi.waitFor(() => { + expect(agent.turn.steer).toHaveBeenCalled(); + expect(fireAndForgetTrigger).toHaveBeenCalled(); + }); + expect(agent.context.appendUserMessage).not.toHaveBeenCalled(); + expect(fireAndForgetTrigger).toHaveBeenCalledWith('Notification', { + matcherValue: 'task.completed', + inputData: { + sink: 'context', + notificationType: 'task.completed', + title: 'Background task completed', + body: 'done completed.', + severity: 'info', + sourceKind: 'background_task', + sourceId: taskId, + }, + }); + }); + + it('tracks stopped tasks as killed even without a stop reason', async () => { + const taskId = agent.background.register(pendingProcess(), 'sleep 60', 'long'); + agent.telemetry.track.mockClear(); + + await agent.background.stop(taskId); + + expect(agent.telemetry.track).toHaveBeenCalledWith( + 'background_task_completed', + expect.objectContaining({ + success: false, + reason: 'killed', + }), + ); + }); + + // Note: the `records.restoring` guard is enforced inside `Agent.emitEvent` + // (see agent/index.ts). BackgroundManager unconditionally forwards + // lifecycle events to the agent; suppression is the agent's job. +}); diff --git a/packages/agent-core/test/agent/basic.test.ts b/packages/agent-core/test/agent/basic.test.ts new file mode 100644 index 000000000..0a3e4da1a --- /dev/null +++ b/packages/agent-core/test/agent/basic.test.ts @@ -0,0 +1,138 @@ +import type { ToolCall } from '@moonshot-ai/kosong'; +import { expect, it } from 'vitest'; + +import { createCommandKaos, testAgent } from './harness/agent'; + +it('runs a text-only agent turn from prompt to completion', async () => { + const ctx = testAgent(); + ctx.configure(); + + ctx.mockNextResponse({ type: 'think', think: '' }, { type: 'text', text: '' }); + await ctx.rpc.prompt({ input: [{ type: 'text', text: 'Hello' }] }); + + expect(await ctx.untilTurnEnd()).toMatchInlineSnapshot(` + [wire] turn.prompt { "input": [ { "type": "text", "text": "Hello" } ], "origin": { "kind": "user" }, "time": "