diff --git a/.agents/skills/gen-changesets/SKILL.md b/.agents/skills/gen-changesets/SKILL.md index 71df61bfb..949dfa22d 100644 --- a/.agents/skills/gen-changesets/SKILL.md +++ b/.agents/skills/gen-changesets/SKILL.md @@ -14,17 +14,18 @@ All other `@moonshot-ai/*` packages are treated as internal packages, including ## 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`. +2. **List packages that changesets can release.** If a changed package is ignored in `.changeset/config.json`, do not put that ignored package in frontmatter together with a non-ignored package; changesets rejects mixed ignored/non-ignored frontmatter. +3. **Map ignored internal changes to the affected released package.** If an ignored internal package changes CLI output or behavior, list `@moonshot-ai/kimi-code` and describe the actual user-visible or release-artifact change in the changelog text. +4. **Internal package source changes that enter the CLI bundle must manually list the CLI.** `@moonshot-ai/kimi-code` inline-bundles `@moonshot-ai/*` source, but those internal packages are devDependencies from the CLI's perspective, so changesets will not automatically propagate bumps. If a change enters the CLI output, list `@moonshot-ai/kimi-code`. + - **Web app (`@moonshot-ai/kimi-web`) changes always enter the CLI bundle.** `@moonshot-ai/kimi-web` is ignored by changesets (see `.changeset/config.json`) and cannot be mixed with `@moonshot-ai/kimi-code` in one changeset frontmatter. Describe the web change in the changelog text, but list `@moonshot-ai/kimi-code` so the CLI release carries the bundled `dist-web` output. 5. **Docs-only and tests-only changes usually do not need a changeset.** README, internal docs, and `test/` changes that do not enter package output do not trigger a CLI bump. 6. `@moonshot-ai/vis` / `vis-server` / `vis-web` are ignored by changesets and should not be handled. ## Workflow -1. List the packages that were actually changed. +1. List the changed packages and check whether each one is ignored by `.changeset/config.json`. 2. Choose a bump level for each package. -3. If an internal package change enters the CLI bundle, add `@moonshot-ai/kimi-code`. +3. If an ignored internal package change enters the CLI bundle, put `@moonshot-ai/kimi-code` in frontmatter instead of mixing the ignored package into the same changeset. 4. Create a short kebab-case file under `.changeset/`. 5. Split unrelated changes into separate changesets; keep one logical change in one file. @@ -69,7 +70,6 @@ An internal package fixes a bug visible to CLI users: ```markdown --- -"@moonshot-ai/agent-core": patch "@moonshot-ai/kimi-code": patch --- @@ -80,7 +80,6 @@ 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 --- @@ -97,10 +96,46 @@ Only SDK source changed, and the CLI does not use it: Clarify session status typing for internal SDK callers. ``` +## Web app changes + +`@moonshot-ai/kimi-web` is ignored by changesets and must **never** appear in a changeset frontmatter. Because the web app is bundled into the CLI release artifact, any web change that ships must list `@moonshot-ai/kimi-code` instead and describe the actual web-facing change in the text. + +- If a PR contains both web UI changes and server API changes, split them into separate changesets so each entry has a focused description. +- Do not enumerate every micro-tweak; keep it to one sentence that captures what the web user gets. + +Web-only fix: + +```markdown +--- +"@moonshot-ai/kimi-code": patch +--- + +Fix the web chat not scrolling to the bottom after sending a message. +``` + +Web UI plus server APIs in the same PR (split into two changesets): + +```markdown +--- +"@moonshot-ai/kimi-code": minor +--- + +Add the server-hosted web UI, including chat layout and session list behaviors. +``` + +```markdown +--- +"@moonshot-ai/kimi-code": minor +--- + +Add the server REST and WebSocket APIs that power the web UI. +``` + ## 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. +- A changeset frontmatter mixes ignored internal packages with non-ignored packages. - `packages/node-sdk` was not changed, but `@moonshot-ai/kimi-code-sdk` was listed for "internal package sync". - The changelog entry is in Chinese. - The wording claims more than the diff actually did. diff --git a/.changeset/README.md b/.changeset/README.md index 9dfaa2c8e..49b0efd33 100644 --- a/.changeset/README.md +++ b/.changeset/README.md @@ -15,14 +15,22 @@ Current publishable packages: All other workspace packages are private internal packages, are not published to npm, and are excluded via `ignore` in `.changeset/config.json`: +- `@moonshot-ai/acp-adapter` - `@moonshot-ai/agent-core` +- `@moonshot-ai/kaos` - `@moonshot-ai/kimi-code-oauth` - `@moonshot-ai/kimi-telemetry` -- `@moonshot-ai/kaos` +- `@moonshot-ai/kimi-web` - `@moonshot-ai/kosong` +- `@moonshot-ai/migration-legacy` +- `@moonshot-ai/protocol` +- `@moonshot-ai/server` +- `@moonshot-ai/server-e2e` - `@moonshot-ai/vis` - `@moonshot-ai/vis-server` - `@moonshot-ai/vis-web` +- `daemon` +- `kimi-migration-legacy` 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. diff --git a/.changeset/compaction-retry-think-only.md b/.changeset/compaction-retry-think-only.md deleted file mode 100644 index 6324619c4..000000000 --- a/.changeset/compaction-retry-think-only.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@moonshot-ai/agent-core": patch ---- - -Recover from think-only/empty compaction summaries by shrinking the compacted prefix before retrying. Previously an `APIEmptyResponseError` (a response with only reasoning content, no summary text) was retried with an identical request, so the model reproduced the same empty result until the retry budget was exhausted and the run aborted. It is now handled like a truncated summary: each retry reduces the compacted prefix to free output headroom. diff --git a/.changeset/config.json b/.changeset/config.json index af6143de9..60653082f 100644 --- a/.changeset/config.json +++ b/.changeset/config.json @@ -7,9 +7,22 @@ "baseBranch": "main", "updateInternalDependencies": "patch", "ignore": [ + "@moonshot-ai/acp-adapter", + "@moonshot-ai/agent-core", + "@moonshot-ai/kaos", + "@moonshot-ai/kimi-code-oauth", + "@moonshot-ai/kimi-telemetry", + "@moonshot-ai/kimi-web", + "@moonshot-ai/kosong", + "@moonshot-ai/migration-legacy", + "@moonshot-ai/protocol", + "@moonshot-ai/server", + "@moonshot-ai/server-e2e", "@moonshot-ai/vis", "@moonshot-ai/vis-server", - "@moonshot-ai/vis-web" + "@moonshot-ai/vis-web", + "daemon", + "kimi-migration-legacy" ], "snapshot": { "useCalculatedVersion": true, diff --git a/.changeset/dispose-kaos-process-stdio.md b/.changeset/dispose-kaos-process-stdio.md deleted file mode 100644 index a519c8546..000000000 --- a/.changeset/dispose-kaos-process-stdio.md +++ /dev/null @@ -1,7 +0,0 @@ ---- -"@moonshot-ai/kaos": patch -"@moonshot-ai/agent-core": patch -"@moonshot-ai/kimi-code": patch ---- - -Release process stdio resources after managed commands finish or are stopped. diff --git a/.changeset/sdk-api-refactor.md b/.changeset/sdk-api-refactor.md new file mode 100644 index 000000000..dfa78c3d5 --- /dev/null +++ b/.changeset/sdk-api-refactor.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code-sdk": patch +--- + +Add host-side config helpers `loadRuntimeConfigSafe` and `resolveConfigPath` for inspecting config without spinning up a full KimiCore. diff --git a/.changeset/server-web-release.md b/.changeset/server-web-release.md new file mode 100644 index 000000000..540b2e420 --- /dev/null +++ b/.changeset/server-web-release.md @@ -0,0 +1,10 @@ +--- +"@moonshot-ai/kimi-code": minor +--- + +Add the server-hosted web UI and the CLI commands that power it: + +- `kimi server` to start, stop, and manage the local server. +- `kimi web` to open the server-hosted web UI in a browser. +- Server REST and WebSocket APIs for the web client. +- Web chat layout, session list, auto-scroll, and related behaviors. diff --git a/.github/workflows/_native-build.yml b/.github/workflows/_native-build.yml index bc190ed89..7d0983503 100644 --- a/.github/workflows/_native-build.yml +++ b/.github/workflows/_native-build.yml @@ -85,6 +85,13 @@ jobs: node apps/kimi-code/scripts/update-catalog.mjs --out "$CATALOG_FILE" echo "KIMI_CODE_BUILT_IN_CATALOG_FILE=$CATALOG_FILE" >> "$GITHUB_ENV" + - name: Build Kimi web assets + # The SEA blob step embeds apps/kimi-code/dist-web; build the web app + # and stage its assets before producing the native executable. + run: | + pnpm --filter @moonshot-ai/kimi-web run build + node apps/kimi-code/scripts/copy-web-assets.mjs + - name: Build native executable (release profile, macOS signed) if: runner.os == 'macOS' && inputs.sign-macos run: pnpm --filter @moonshot-ai/kimi-code run build:native:release diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c7ceac2ad..254ca51cc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -82,3 +82,9 @@ jobs: echo "Typechecking ${config}" pnpm dlx --package @typescript/native-preview@beta tsgo -p "${config}" --noEmit done + - name: Typecheck kimi-web (vue-tsc) + run: pnpm --filter @moonshot-ai/kimi-web run typecheck + - name: Typecheck vis-server + run: pnpm --filter @moonshot-ai/vis-server run typecheck + - name: Typecheck vis-web + run: pnpm --filter @moonshot-ai/vis-web run typecheck diff --git a/.github/workflows/pkg-pr-new.yml b/.github/workflows/pkg-pr-new.yml index 86de76975..fcce360b5 100644 --- a/.github/workflows/pkg-pr-new.yml +++ b/.github/workflows/pkg-pr-new.yml @@ -36,6 +36,9 @@ jobs: - name: Build package dependencies run: pnpm run build:packages + - name: Build Kimi web assets + run: pnpm --filter @moonshot-ai/kimi-web run build + - name: Generate Kimi Code built-in catalog shell: bash run: | diff --git a/.gitignore b/.gitignore index df529a2c6..33fcb0805 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ node_modules/ dist/ +dist-web/ dist-single/ dist-native/ .tmp-api-extractor/ @@ -14,3 +15,12 @@ coverage/ plugins/cdn/ superpowers .worktrees/ + +Dockerfile +!packages/daemon-e2e/Dockerfile +docker-compose.yml +.dockerignore + +docs/superpowers/ +reports/ +.superpowers/ diff --git a/AGENTS.md b/AGENTS.md index ffe93c6aa..a9f77b457 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -15,13 +15,16 @@ This is a TypeScript monorepo built for agent-assisted development. Keep the roo ## Project Map - `apps/kimi-code`: the CLI / TUI application. It consumes core capabilities through `@moonshot-ai/kimi-code-sdk` and must not depend directly on `@moonshot-ai/agent-core`. When writing or modifying its terminal UI, use the `write-tui` skill (`.agents/skills/write-tui/SKILL.md`). +- `apps/kimi-web`: the browser web UI, a peer to the TUI. Vue 3 + Vite + vue-i18n; talks to the server over REST + WebSocket under `/api/v1`. It must not depend on `@moonshot-ai/agent-core` (wire types are re-implemented locally). See `apps/kimi-web/AGENTS.md`. - `apps/vis`, `apps/vis/server`, `apps/vis/web`: visual debugging tools for sessions and replays. -- `packages/agent-core`: the unified agent engine, including Agent, Session, profile, skills, tools, plan, permission, background, records, and other core capabilities. +- `packages/agent-core`: the unified agent engine, including Agent, Session, profile, skills, tools, plan, permission, background, records, the in-process DI service layer (`src/services/`), and other core capabilities. - `packages/node-sdk`: the public TypeScript SDK and harness. - `packages/kosong`: the LLM / provider abstraction layer. - `packages/kaos`: the execution environment and file/process abstractions. - `packages/oauth`: Kimi OAuth and managed auth utilities. - `packages/telemetry`: shared client-side telemetry infrastructure. +- `packages/server`: the Kimi Code server. Hosts `agent-core` sessions and exposes them over REST + WebSocket (`/api/v1`); bootstrapped from `src/start.ts` and consumed by `apps/kimi-code`. See `packages/server/AGENTS.md`. +- `packages/server-e2e`: live e2e tests and scenarios against a running server (`KIMI_SERVER_URL`, default `http://127.0.0.1:58627`). See `packages/server-e2e/AGENTS.md`. ## Environment Requirements @@ -32,9 +35,11 @@ This is a TypeScript monorepo built for agent-assisted development. Keep the roo ## Monorepo Workspace Maintenance - `pnpm-workspace.yaml` is the source of truth for workspace membership, but `flake.nix` also contains **hardcoded** `workspacePaths` and `workspaceNames` lists. -- **Whenever you add or remove a workspace package, you MUST update both `pnpm-workspace.yaml` and `flake.nix`.** +- **Whenever you add or remove a workspace package, you MUST update both `pnpm-workspace.yaml` and `flake.nix` — for every package, including leaf / test / e2e packages that nothing depends on.** + - `pnpm-workspace.yaml` uses globs (`packages/*`, `apps/*`), so most packages land there automatically; `flake.nix` is fully manual and is where omissions happen. - Missing a path in `flake.nix`'s `workspacePaths` will silently drop files from the Nix build's `src` fileset. - Missing a name in `flake.nix`'s `workspaceNames` will break `pnpmConfigHook` because dependencies for that workspace will not be fetched. +- The automated "Check flake.nix workspace sync" (`scripts/check-nix-workspace.mjs`) only validates the transitive dependency **closure of `@moonshot-ai/kimi-code`**. A leaf package outside that closure (e.g. an e2e package nobody imports) slips through even when it is missing from `flake.nix`. A green check is therefore NOT proof that `flake.nix` is fully in sync — keep it updated by hand on every add/remove, do not rely on the check to catch omissions. ## General Coding Rules diff --git a/apps/kimi-code/package.json b/apps/kimi-code/package.json index e6654f7a3..0305be0e5 100644 --- a/apps/kimi-code/package.json +++ b/apps/kimi-code/package.json @@ -27,6 +27,7 @@ }, "files": [ "dist", + "dist-web", "scripts/postinstall.mjs", "scripts/postinstall", "README.md" @@ -34,6 +35,8 @@ "type": "module", "imports": { "#/tui/theme": "./src/tui/theme/index.ts", + "#/cli/sub/server": "./src/cli/sub/server/index.ts", + "#/cli/sub/server/*": "./src/cli/sub/server/*.ts", "#/*": [ "./src/*.ts", "./src/*/index.ts", @@ -45,8 +48,8 @@ "provenance": true }, "scripts": { + "build": "pnpm -C ../kimi-web run build && tsdown && node scripts/copy-web-assets.mjs", "prebuild": "node scripts/build-vis-asset.mjs", - "build": "tsdown", "catalog:update": "node scripts/update-catalog.mjs --out dist/built-in-catalog.json", "smoke": "node scripts/smoke.mjs", "build:native:js": "node scripts/native/01-bundle.mjs", @@ -58,6 +61,8 @@ "test:native:smoke": "node scripts/native/smoke.mjs", "dev": "node scripts/dev.mjs", "dev:cli-only": "tsx --import ../../build/register-raw-text-loader.mjs ./src/main.ts", + "dev:server": "tsx --tsconfig ./tsconfig.dev.json --import ../../build/register-raw-text-loader.mjs ./src/main.ts server run --foreground", + "dev:server:restart": "node scripts/dev-server-restart.mjs", "dev:plugin-marketplace": "node scripts/dev-plugin-marketplace-server.mjs", "build:plugin-marketplace": "node scripts/build-plugin-marketplace-cdn.mjs", "dev:prod": "node dist/main.mjs", @@ -70,7 +75,16 @@ }, "optionalDependencies": { "@mariozechner/clipboard": "^0.3.2", - "koffi": "^2.16.0" + "chalk": "^5.4.1", + "cli-highlight": "^2.1.11", + "commander": "^13.1.0", + "koffi": "^2.16.0", + "node-pty": "^1.1.0", + "pathe": "^2.0.3", + "pino-pretty": "^13.0.0", + "semver": "^7.7.4", + "smol-toml": "^1.6.1", + "zod": "^4.3.6" }, "devDependencies": { "@earendil-works/pi-tui": "^0.74.0", @@ -78,7 +92,9 @@ "@moonshot-ai/kimi-code-oauth": "workspace:^", "@moonshot-ai/kimi-code-sdk": "workspace:^", "@moonshot-ai/kimi-telemetry": "workspace:^", + "@moonshot-ai/kimi-web": "workspace:^", "@moonshot-ai/migration-legacy": "workspace:^", + "@moonshot-ai/server": "workspace:^", "@moonshot-ai/vis-server": "workspace:^", "@moonshot-ai/vis-web": "workspace:*", "@types/semver": "^7.7.0", diff --git a/apps/kimi-code/scripts/copy-web-assets.mjs b/apps/kimi-code/scripts/copy-web-assets.mjs new file mode 100644 index 000000000..d82f40de0 --- /dev/null +++ b/apps/kimi-code/scripts/copy-web-assets.mjs @@ -0,0 +1,27 @@ +import { cp, rm, stat } from 'node:fs/promises'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const appRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..'); +const repoRoot = resolve(appRoot, '../..'); +const source = resolve(repoRoot, 'apps/kimi-web/dist'); +const target = resolve(appRoot, 'dist-web'); + +async function assertBuiltWeb() { + try { + const info = await stat(resolve(source, 'index.html')); + if (!info.isFile()) { + throw new Error('index.html is not a file'); + } + } catch { + throw new Error( + `Kimi web build output was not found at ${source}. Run \`pnpm --filter @moonshot-ai/kimi-web run build\` first.`, + ); + } +} + +await assertBuiltWeb(); +await rm(target, { recursive: true, force: true }); +await cp(source, target, { recursive: true }); + +console.log(`Copied Kimi web assets to ${target}`); diff --git a/apps/kimi-code/scripts/dev-server-restart.mjs b/apps/kimi-code/scripts/dev-server-restart.mjs new file mode 100644 index 000000000..8169925cf --- /dev/null +++ b/apps/kimi-code/scripts/dev-server-restart.mjs @@ -0,0 +1,127 @@ +#!/usr/bin/env node +// Press-Enter-to-restart wrapper for the local server. No file watcher. +// +// Spawns `tsx ./src/main.ts server run …extraArgs` once, then on each newline +// read from stdin SIGTERMs the child and respawns after it has cleanly exited. +// SIGTERM triggers the server's own `shutdown()` handler +// (apps/kimi-code/src/cli/sub/server/run.ts) which releases the port lock and +// closes WS conns before exit, so a fresh start can re-acquire 58627 without a +// stale-lock fight. +// +// CLI args after `--` (or any extras) are passed straight through, so: +// pnpm dev:server:restart -- --host 0.0.0.0 --port 58627 --log-level debug +// is equivalent to `pnpm dev:server` with that arg list, but with the restart +// loop on top. + +import { spawn } from 'node:child_process'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url)); +const APP_ROOT = resolve(SCRIPT_DIR, '..'); + +const tsxBin = process.platform === 'win32' ? 'tsx.cmd' : 'tsx'; + +const cliArgs = process.argv.slice(2); +if (cliArgs[0] === '--') cliArgs.shift(); + +const tsxArgs = [ + '--tsconfig', + './tsconfig.dev.json', + '--import', + '../../build/register-raw-text-loader.mjs', + './src/main.ts', + 'server', + 'run', + ...cliArgs, +]; + +let child = null; +let restarting = false; +let shuttingDown = false; +let killTimer = null; + +function start() { + console.error('[dev:server:restart] starting server…'); + child = spawn(tsxBin, tsxArgs, { + cwd: APP_ROOT, + env: process.env, + // Server does not read stdin; keep ours free for the Enter trigger. + stdio: ['ignore', 'inherit', 'inherit'], + }); + + child.on('error', (err) => { + console.error(`[dev:server:restart] spawn error: ${err.message}`); + }); + + child.on('exit', (code, signal) => { + if (killTimer !== null) { + clearTimeout(killTimer); + killTimer = null; + } + const prev = child; + child = null; + if (shuttingDown) { + process.exit(code ?? 0); + return; + } + if (restarting) { + restarting = false; + start(); + return; + } + // Server died on its own (port conflict, runtime error, etc.). Stay alive + // so the user can fix the issue and press Enter to retry. + const tag = signal !== null ? `signal=${signal}` : `code=${code}`; + console.error( + `[dev:server:restart] server exited (${tag}). Press Enter to restart, Ctrl+C to quit.`, + ); + void prev; // silence unused warning + }); +} + +function restart() { + if (shuttingDown) return; + if (child === null) { + // Previous run already exited; just spin up a new one. + start(); + return; + } + if (restarting) return; // debounce — multiple Enters during shutdown collapse + restarting = true; + console.error('[dev:server:restart] restarting…'); + child.kill('SIGTERM'); + // Safety net: if the child ignores SIGTERM, force-kill after 5s so the + // restart loop doesn't wedge. + killTimer = setTimeout(() => { + if (child !== null && child.exitCode === null && child.signalCode === null) { + console.error('[dev:server:restart] SIGTERM timed out, sending SIGKILL'); + child.kill('SIGKILL'); + } + }, 5000); +} + +process.stdin.setEncoding('utf8'); +process.stdin.on('data', (chunk) => { + // Any newline (Enter on most terminals) triggers a restart. Empty Enter is + // the canonical signal; typing `r` works too. + if (chunk.includes('\n') || chunk.includes('\r')) { + restart(); + } +}); + +const onShutdownSignal = (signal) => { + if (shuttingDown) return; + shuttingDown = true; + if (child !== null) { + child.kill(signal); + // Give the server a moment to flush logs / release the lock. + setTimeout(() => process.exit(0), 1000).unref(); + } else { + process.exit(0); + } +}; +process.on('SIGINT', () => onShutdownSignal('SIGINT')); +process.on('SIGTERM', () => onShutdownSignal('SIGTERM')); + +start(); diff --git a/apps/kimi-code/scripts/dev.mjs b/apps/kimi-code/scripts/dev.mjs index 3cc2d0a58..1e9ca8c3f 100644 --- a/apps/kimi-code/scripts/dev.mjs +++ b/apps/kimi-code/scripts/dev.mjs @@ -42,7 +42,18 @@ const cliArgs = process.argv.slice(2); if (cliArgs[0] === '--') cliArgs.shift(); const child = spawn( process.execPath, - [tsxCli, '--import', '../../build/register-raw-text-loader.mjs', './src/main.ts', ...cliArgs], + [ + tsxCli, + // Use the dev tsconfig whose `include` covers packages/*/src, so tsx's + // esbuild transform sees `experimentalDecorators: true` for DI parameter + // decorators in agent-core. Mirrors `dev:server` in package.json. + '--tsconfig', + './tsconfig.dev.json', + '--import', + '../../build/register-raw-text-loader.mjs', + './src/main.ts', + ...cliArgs, + ], { cwd: APP_ROOT, env, diff --git a/apps/kimi-code/scripts/native/02-sea-blob.mjs b/apps/kimi-code/scripts/native/02-sea-blob.mjs index 8bac05a84..434a7861c 100644 --- a/apps/kimi-code/scripts/native/02-sea-blob.mjs +++ b/apps/kimi-code/scripts/native/02-sea-blob.mjs @@ -16,6 +16,7 @@ import { nativeSeaConfigPath, targetTriple, } from './paths.mjs'; +import { collectWebAssets, webAssetManifestKey } from './web-assets.mjs'; async function ensureBundleExists() { try { @@ -31,13 +32,19 @@ async function writeSeaConfig(target) { appRoot, target, }); + const web = await collectWebAssets({ appRoot, target }); const manifestPath = resolve(nativeManifestDir(target), 'manifest.json'); + const webManifestPath = resolve(nativeIntermediatesDir(), 'web-assets', target, 'manifest.json'); await mkdir(dirname(manifestPath), { recursive: true }); + await mkdir(dirname(webManifestPath), { recursive: true }); await writeFile(manifestPath, manifestJson); + await writeFile(webManifestPath, web.manifestJson); const seaAssets = { [nativeAssetManifestKey(target)]: manifestPath, + [webAssetManifestKey(target)]: webManifestPath, ...assets, + ...web.assets, }; const config = { main: nativeJsBundlePath(), @@ -55,6 +62,9 @@ async function writeSeaConfig(target) { for (const line of nativeAssetSummary(manifest)) { console.log(`- ${line}`); } + console.log( + `Collected web assets for ${web.manifest.target}: ${web.manifest.files.length} files`, + ); } export async function runSeaBlobStep() { diff --git a/apps/kimi-code/scripts/native/check-bundle.mjs b/apps/kimi-code/scripts/native/check-bundle.mjs index a0479f209..1521d6716 100644 --- a/apps/kimi-code/scripts/native/check-bundle.mjs +++ b/apps/kimi-code/scripts/native/check-bundle.mjs @@ -18,6 +18,8 @@ const optionalRuntimeRequires = new Set([ 'canvas', 'chokidar', 'cpu-features', + 'fast-json-stringify/lib/serializer', + 'fast-json-stringify/lib/validator', 'utf-8-validate', ]); const optionalRelativeRuntimeRequires = new Set(['./crypto/build/Release/sshcrypto.node']); diff --git a/apps/kimi-code/scripts/native/manifest.mjs b/apps/kimi-code/scripts/native/manifest.mjs index 910738943..30d5e9da3 100644 --- a/apps/kimi-code/scripts/native/manifest.mjs +++ b/apps/kimi-code/scripts/native/manifest.mjs @@ -1,4 +1,5 @@ export const NATIVE_ASSET_MANIFEST_VERSION = 1; +export const WEB_ASSET_MANIFEST_VERSION = 1; export function buildManifestKey(target) { return `native/${target}/manifest.json`; @@ -11,3 +12,11 @@ export function isManifestVersionSupported(version) { export function buildAssetKey(target, packageRoot, relativePath) { return `native/${target}/${packageRoot}/${relativePath}`; } + +export function buildWebManifestKey(target) { + return `web/${target}/manifest.json`; +} + +export function buildWebAssetKey(target, relativePath) { + return `web/${target}/dist-web/${relativePath}`; +} diff --git a/apps/kimi-code/scripts/native/web-assets.mjs b/apps/kimi-code/scripts/native/web-assets.mjs new file mode 100644 index 000000000..8f8a893c5 --- /dev/null +++ b/apps/kimi-code/scripts/native/web-assets.mjs @@ -0,0 +1,118 @@ +import { createHash } from 'node:crypto'; +import { existsSync } from 'node:fs'; +import { readdir, readFile, stat } from 'node:fs/promises'; +import { join, relative, resolve } from 'node:path'; + +import { + WEB_ASSET_MANIFEST_VERSION, + buildWebAssetKey, + buildWebManifestKey, +} from './manifest.mjs'; + +export { WEB_ASSET_MANIFEST_VERSION }; + +const WEB_ASSETS_DIR = 'dist-web'; + +function toPosixPath(path) { + return path.split('\\').join('/'); +} + +function sha256(bytes) { + return createHash('sha256').update(bytes).digest('hex'); +} + +async function listFiles(root) { + const files = []; + + async function walk(dir) { + const entries = await readdir(dir, { withFileTypes: true }); + for (const entry of entries) { + const path = join(dir, entry.name); + if (entry.isDirectory()) { + await walk(path); + continue; + } + if (entry.isFile()) { + files.push(path); + } + } + } + + await walk(root); + return files; +} + +async function assertBuiltAssetRoot({ assetRoot, requiredFile, message }) { + const requiredPath = join(assetRoot, requiredFile); + try { + const info = await stat(requiredPath); + if (!info.isFile()) { + throw new Error(`${requiredFile} is not a file`); + } + } catch { + throw new Error(message); + } +} + +export function webAssetManifestKey(target) { + return buildWebManifestKey(target); +} + +export function webAssetKey(target, relativePath) { + return buildWebAssetKey(target, relativePath); +} + +async function collectAssetRoot({ + appRoot, + target, + root, + requiredFile, + missingMessage, + assetKey, +}) { + const assetRoot = resolve(appRoot, ...root.split('/')); + await assertBuiltAssetRoot({ assetRoot, requiredFile, message: missingMessage }); + + const files = (await listFiles(assetRoot)).sort((a, b) => a.localeCompare(b)); + const manifestFiles = []; + const assets = {}; + + for (const file of files) { + if (!existsSync(file)) continue; + const bytes = await readFile(file); + const relativePath = toPosixPath(relative(assetRoot, file)); + const key = assetKey(target, relativePath); + manifestFiles.push({ + assetKey: key, + relativePath, + sha256: sha256(bytes), + }); + assets[key] = file; + } + + const manifest = { + version: WEB_ASSET_MANIFEST_VERSION, + target, + root, + files: manifestFiles, + }; + + return { + manifest, + manifestJson: `${JSON.stringify(manifest, null, 2)}\n`, + assets, + }; +} + +export async function collectWebAssets({ appRoot, target }) { + const buildCommand = + 'pnpm --filter @moonshot-ai/kimi-web run build && pnpm --filter @moonshot-ai/kimi-code run build'; + return collectAssetRoot({ + appRoot, + target, + root: WEB_ASSETS_DIR, + requiredFile: 'index.html', + missingMessage: `Kimi web build output was not found at ${resolve(appRoot, WEB_ASSETS_DIR)}. Run \`${buildCommand}\` before building native SEA assets. App root: ${appRoot}`, + assetKey: webAssetKey, + }); +} diff --git a/apps/kimi-code/scripts/smoke.mjs b/apps/kimi-code/scripts/smoke.mjs index 9c64dd80d..b4a5afa09 100644 --- a/apps/kimi-code/scripts/smoke.mjs +++ b/apps/kimi-code/scripts/smoke.mjs @@ -7,6 +7,7 @@ import { promisify } from 'node:util'; const execFileAsync = promisify(execFile); const appRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..'); const bundlePath = resolve(appRoot, 'dist', 'main.mjs'); +const webIndexPath = resolve(appRoot, 'dist-web', 'index.html'); const packageJson = JSON.parse(await readFile(resolve(appRoot, 'package.json'), 'utf-8')); const expectedVersion = packageJson.version; @@ -23,6 +24,14 @@ async function ensureBundleExists() { } } +async function ensureRuntimeAssetsExist() { + try { + await stat(webIndexPath); + } catch { + fail(`Runtime asset not found at ${webIndexPath}. Run \`pnpm build\` first.`); + } +} + async function runBundle(args) { try { const { stdout, stderr } = await execFileAsync(process.execPath, [bundlePath, ...args], { @@ -45,6 +54,7 @@ function assertIncludes(output, expected, command) { } await ensureBundleExists(); +await ensureRuntimeAssetsExist(); const versionOutput = await runBundle(['--version']); assertIncludes(versionOutput, expectedVersion, '--version'); @@ -55,4 +65,7 @@ assertIncludes(helpOutput, 'Usage: kimi', '--help'); const exportHelpOutput = await runBundle(['export', '--help']); assertIncludes(exportHelpOutput, 'Usage: kimi export', 'export --help'); +const webHelpOutput = await runBundle(['web', '--help']); +assertIncludes(webHelpOutput, 'Usage: kimi web', 'web --help'); + console.log(`Bundle smoke passed: ${bundlePath}`); diff --git a/apps/kimi-code/src/cli/commands.ts b/apps/kimi-code/src/cli/commands.ts index bdc886c4d..d55ca3675 100644 --- a/apps/kimi-code/src/cli/commands.ts +++ b/apps/kimi-code/src/cli/commands.ts @@ -8,6 +8,7 @@ import { registerDoctorCommand } from './sub/doctor'; import { registerExportCommand } from './sub/export'; import { registerLoginCommand } from './sub/login'; import { registerProviderCommand } from './sub/provider'; +import { registerServerCommand } from './sub/server'; import { registerVisCommand } from './sub/vis'; export type MainCommandHandler = (opts: CLIOptions) => void; @@ -79,6 +80,7 @@ export function createProgram( registerExportCommand(program); registerProviderCommand(program); registerAcpCommand(program); + registerServerCommand(program); registerLoginCommand(program); registerDoctorCommand(program); registerVisCommand(program); diff --git a/apps/kimi-code/src/cli/sub/server/daemon.ts b/apps/kimi-code/src/cli/sub/server/daemon.ts new file mode 100644 index 000000000..3e72730c8 --- /dev/null +++ b/apps/kimi-code/src/cli/sub/server/daemon.ts @@ -0,0 +1,234 @@ +/** + * `kimi web` daemon orchestration — parent (spawner) side. + * + * Ensures a single background server daemon exists for this device, then + * returns its origin so the caller can open the web UI. The flow: + * + * 1. Read `~/.kimi-code/server/lock`. If it names a *live* daemon, reuse it + * (wait for it to be healthy) — never spawn a second one. + * 2. Otherwise pick a free port (preferred port when available, else an + * OS-assigned one) and spawn `kimi server run --daemon` as a detached + * child whose stdio is redirected to the server log. + * 3. Poll the lock until *some* live daemon (ours, or a concurrent racer's + * that won the lock) is healthy, then return its origin. + * + * The child side (`startServerDaemon`) lives in `./run.ts` next to the + * foreground runner so it can share the same bootstrap helpers. + */ + +import { spawn } from 'node:child_process'; +import { closeSync, mkdirSync, openSync } from 'node:fs'; +import { createServer } from 'node:net'; +import { dirname, isAbsolute, join, resolve } from 'node:path'; + +import { DEFAULT_LOCK_DIR, getLiveLock, type LockContents } from '@moonshot-ai/server'; + +import { + DEFAULT_SERVER_HOST, + DEFAULT_SERVER_PORT, + isServerHealthy, + serverOrigin, + waitForServerHealthy, +} from './shared'; + +const SERVER_LOG_FILENAME = 'server.log'; + +/** How long to wait for an already-running daemon to answer `/healthz`. */ +const REUSE_HEALTH_TIMEOUT_MS = 15_000; +/** How long to wait for a freshly-spawned daemon to come up. */ +const SPAWN_TIMEOUT_MS = 20_000; +/** Poll cadence while waiting for the daemon to appear in the lock + healthz. */ +const POLL_INTERVAL_MS = 200; +/** Default log level for a daemon spawned without an explicit `--log-level`. */ +const DEFAULT_DAEMON_LOG_LEVEL = 'info'; + +export interface EnsureDaemonOptions { + /** Preferred port; on conflict a free port is chosen automatically. */ + port?: number; + /** Pino log level for the spawned daemon (defaults to `info`). */ + logLevel?: string; + /** Mount `/api/v1/debug/*` routes on the spawned daemon. */ + debugEndpoints?: boolean; + /** Idle-shutdown grace in ms for the spawned daemon (daemon mode only). */ + idleGraceMs?: number; +} + +export interface EnsureDaemonResult { + readonly origin: string; +} + +/** Path of the daemon log file (shared with the OS-service log location). */ +export function daemonLogPath(): string { + return join(DEFAULT_LOCK_DIR, SERVER_LOG_FILENAME); +} + +export function lockConnectHost(lock: LockContents): string { + const host = lock.host ?? DEFAULT_SERVER_HOST; + return host === '0.0.0.0' ? DEFAULT_SERVER_HOST : host; +} + +/** True when `host:port` is currently free to bind (nothing listening). */ +function canBind(host: string, port: number): Promise { + return new Promise((resolvePromise) => { + const probe = createServer(); + probe.once('error', () => resolvePromise(false)); + probe.listen({ host, port }, () => { + probe.close(() => resolvePromise(true)); + }); + }); +} + +/** Ask the OS for an ephemeral free port on `host`. */ +function getFreePort(host: string): Promise { + return new Promise((resolvePromise, reject) => { + const probe = createServer(); + probe.once('error', reject); + probe.listen({ host, port: 0 }, () => { + const address = probe.address(); + if (address === null || typeof address === 'string') { + probe.close(() => reject(new Error('failed to allocate a free port'))); + return; + } + const { port } = address; + probe.close(() => resolvePromise(port)); + }); + }); +} + +/** + * How many consecutive `preferred + n` ports to probe before giving up and + * asking the OS for any free port. Mirrors `PORT_RETRY_LIMIT` in the server's + * own bind retry so the spawner and the daemon agree on the policy. + */ +export const DAEMON_PORT_SCAN_LIMIT = 100; + +/** + * Pick a port for a new daemon: prefer `preferred` when it is free, otherwise + * walk `preferred + 1`, `+ 2`, … upward and take the first free one. Only when + * the whole scan window is saturated do we fall back to an OS-assigned free + * port. + * + * Reusing an already-live daemon is handled by `ensureDaemon` before this runs, + * so a busy port here is held by a third-party process — bumping by one (rather + * than jumping to a random ephemeral port) keeps the URL predictable, matching + * the server's own "port busy ⇒ +1" bind retry. + */ +export async function resolveDaemonPort( + host: string = DEFAULT_SERVER_HOST, + preferred: number = DEFAULT_SERVER_PORT, +): Promise { + for ( + let candidate = preferred; + candidate < preferred + DAEMON_PORT_SCAN_LIMIT && candidate <= 65535; + candidate++ + ) { + if (await canBind(host, candidate)) return candidate; + } + return getFreePort(host); +} + +/** + * Absolute path to the CLI entry that should be re-execed to run the daemon. + * Mirrors `resolveSupervisorProgram` in `packages/server/src/svc/program.ts`: + * when the CLI is a compiled single binary, `argv[1]` is literally `server` + * and we must fall back to `process.execPath`. + */ +function resolveDaemonProgram( + argv: readonly string[] = process.argv, + cwd: string = process.cwd(), + execPath: string = process.execPath, +): string { + const candidate = argv[1] === 'server' ? execPath : (argv[1] ?? execPath); + return isAbsolute(candidate) ? candidate : resolve(cwd, candidate); +} + +interface SpawnDaemonChildOptions { + port: number; + logLevel: string; + debugEndpoints?: boolean; + idleGraceMs?: number; +} + +function spawnDaemonChild(options: SpawnDaemonChildOptions): void { + const program = resolveDaemonProgram(); + const logPath = daemonLogPath(); + mkdirSync(dirname(logPath), { recursive: true }); + const args = [ + 'server', + 'run', + '--daemon', + '--port', + String(options.port), + '--log-level', + options.logLevel, + ]; + if (options.debugEndpoints === true) { + args.push('--debug-endpoints'); + } + if (options.idleGraceMs !== undefined) { + args.push('--idle-grace-ms', String(options.idleGraceMs)); + } + const logFd = openSync(logPath, 'a'); + try { + const child = spawn(program, args, { detached: true, stdio: ['ignore', logFd, logFd] }); + child.unref(); + } finally { + // `spawn` dups the fd into the child; the parent must not keep it open. + closeSync(logFd); + } +} + +function sleep(ms: number): Promise { + return new Promise((resolvePromise) => { + setTimeout(resolvePromise, ms); + }); +} + +/** + * Ensure a daemon is running and return its origin. Non-blocking for the + * caller beyond the short health wait — the server itself keeps running in a + * detached process after this returns. + */ +export async function ensureDaemon(options: EnsureDaemonOptions = {}): Promise { + const preferred = options.port ?? DEFAULT_SERVER_PORT; + const logLevel = options.logLevel ?? DEFAULT_DAEMON_LOG_LEVEL; + + // 1. Reuse an already-live daemon if one holds the lock. + const existing = getLiveLock(); + if (existing) { + const origin = serverOrigin(lockConnectHost(existing), existing.port); + if (await waitForServerHealthy(origin, REUSE_HEALTH_TIMEOUT_MS)) { + return { origin }; + } + // Live pid but not responding (wedged or mid-boot failure). Fall through + // and spawn: if it is truly wedged our child loses the lock race and we + // reconnect below; if it died, stale takeover lets our child claim it. + } + + // 2. No reusable daemon — pick a free port and spawn one detached. + const port = await resolveDaemonPort(DEFAULT_SERVER_HOST, preferred); + spawnDaemonChild({ + port, + logLevel, + debugEndpoints: options.debugEndpoints, + idleGraceMs: options.idleGraceMs, + }); + + // 3. Wait until some live daemon (ours, or a racer that won the lock) is up. + const deadline = Date.now() + SPAWN_TIMEOUT_MS; + while (Date.now() < deadline) { + const live = getLiveLock(); + if (live) { + const origin = serverOrigin(lockConnectHost(live), live.port); + if (await isServerHealthy(origin, 500)) { + return { origin }; + } + } + await sleep(POLL_INTERVAL_MS); + } + + throw new Error( + `Kimi server daemon failed to start within ${String(SPAWN_TIMEOUT_MS)}ms. ` + + `Check the log for details: ${daemonLogPath()}`, + ); +} diff --git a/apps/kimi-code/src/cli/sub/server/index.ts b/apps/kimi-code/src/cli/sub/server/index.ts new file mode 100644 index 000000000..c17a61d18 --- /dev/null +++ b/apps/kimi-code/src/cli/sub/server/index.ts @@ -0,0 +1,46 @@ +/** + * `kimi server` parent command. Mounts: + * - `server run` (background daemon by default; `--foreground` to attach; the + * detached daemon child runs the same command with `--daemon`) + * + * The OS service-manager subcommands (`install/uninstall/start/stop/restart/ + * status`) are temporarily NOT registered — see the commented + * `addLifecycleCommands(server)` below. Their implementation is preserved in + * `./lifecycle.ts` + `packages/server/src/svc/*` for later re-exposure. + * + * The top-level `kimi web` alias is registered separately via + * `registerWebAliasCommand` so it stays at the program root. + */ + +import type { Command } from 'commander'; + +import { registerPsCommand } from './ps'; +import { registerKillCommand } from './kill'; +import { buildRunCommand } from './run'; +import { registerWebAliasCommand } from './web-alias'; + +export function registerServerCommand(program: Command): void { + const server = program + .command('server') + .description('Run the local Kimi server (REST + WebSocket + web UI).'); + + buildRunCommand( + server.command('run').description('Start the Kimi server (background daemon; use --foreground to attach).'), + { defaultOpen: false }, + ); + + registerPsCommand(server); + + registerKillCommand(server); + + // OS service-manager commands (`install/uninstall/start/stop/restart/status`) + // are temporarily hidden — the product now favors the on-demand background + // daemon (`kimi web`) over service-ization. The implementation still lives in + // `./lifecycle.ts` + `packages/server/src/svc/*`; re-import + // `addLifecycleCommands` and call it here to re-expose. + // addLifecycleCommands(server); + + registerWebAliasCommand(program); +} + +export { registerWebAliasCommand }; diff --git a/apps/kimi-code/src/cli/sub/server/kill.ts b/apps/kimi-code/src/cli/sub/server/kill.ts new file mode 100644 index 000000000..7275991e5 --- /dev/null +++ b/apps/kimi-code/src/cli/sub/server/kill.ts @@ -0,0 +1,155 @@ +/** + * `kimi server kill` — terminate the running server. + * + * Combines two independent mechanisms so the server dies even if one path + * fails: + * + * 1. API path — `POST /api/v1/shutdown` for a graceful, in-process shutdown + * (best-effort; older builds or a wedged server may not answer). + * 2. PID path — signal the pid recorded in the lock (SIGTERM → wait → + * SIGKILL). SIGKILL / TerminateProcess is the hard guarantee: + * it cannot be caught or ignored. + * + * The only honest failure mode is insufficient permissions (a process owned by + * another user), which surfaces as an error rather than a silent miss. + */ + +import type { Command } from 'commander'; + +import { getLiveLock, type LockContents } from '@moonshot-ai/server'; + +import { lockConnectHost } from './daemon'; +import { serverOrigin } from './shared'; + +/** How long to wait for the graceful API shutdown request. */ +const API_TIMEOUT_MS = 2000; +/** Grace period after SIGTERM before escalating to SIGKILL. */ +const TERM_GRACE_MS = 3000; +/** Grace period after SIGKILL before giving up. */ +const KILL_GRACE_MS = 2000; +/** Poll cadence while waiting for the pid to exit. */ +const POLL_INTERVAL_MS = 100; + +export interface KillCommandDeps { + getLiveLock(): LockContents | undefined; + requestShutdown(origin: string): Promise; + signalPid(pid: number, signal: NodeJS.Signals): boolean; + pidAlive(pid: number): boolean; + sleep(ms: number): Promise; + stdout: Pick; + now(): number; +} + +export function registerKillCommand(server: Command): void { + server + .command('kill') + .description('Stop the running Kimi server (graceful API + forced PID kill).') + .action(async () => { + try { + await handleKillCommand(DEFAULT_KILL_DEPS); + } catch (error) { + process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`); + process.exit(1); + } + }); +} + +export async function handleKillCommand(deps: KillCommandDeps): Promise { + const lock = deps.getLiveLock(); + if (!lock) { + deps.stdout.write('No running Kimi server.\n'); + return; + } + + const { pid } = lock; + const origin = serverOrigin(lockConnectHost(lock), lock.port); + + // 1. API path — best-effort graceful shutdown. Ignore every outcome: the + // server may be an older build without the route, already wedged, or may + // drop the connection as it exits. + await deps.requestShutdown(origin).catch(() => {}); + + // 2. PID path — SIGTERM, wait, then SIGKILL. + deps.signalPid(pid, 'SIGTERM'); + + if (await waitForExit(pid, TERM_GRACE_MS, deps)) { + deps.stdout.write(`Kimi server (pid ${String(pid)}) stopped.\n`); + return; + } + + deps.signalPid(pid, 'SIGKILL'); + + if (await waitForExit(pid, KILL_GRACE_MS, deps)) { + deps.stdout.write(`Kimi server (pid ${String(pid)}) killed.\n`); + return; + } + + throw new Error( + `Failed to stop Kimi server (pid ${String(pid)}); insufficient permissions?`, + ); +} + +async function waitForExit( + pid: number, + timeoutMs: number, + deps: Pick, +): Promise { + const deadline = deps.now() + timeoutMs; + do { + if (!deps.pidAlive(pid)) return true; + await deps.sleep(POLL_INTERVAL_MS); + } while (deps.now() < deadline); + return !deps.pidAlive(pid); +} + +/** `process.kill(pid, 0)` probe — true if the pid exists, false on ESRCH. */ +export function pidAlive(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code === 'ESRCH') return false; + // EPERM = process exists but we can't signal it. Treat as alive. + return true; + } +} + +/** Send `signal` to `pid`. Returns false if the signal could not be sent. */ +export function signalPid(pid: number, signal: NodeJS.Signals): boolean { + try { + process.kill(pid, signal); + return true; + } catch { + return false; + } +} + +/** POST the shutdown endpoint; resolves once the request completes or times out. */ +export async function requestShutdownViaApi(origin: string): Promise { + const controller = new AbortController(); + const timeout = setTimeout(() => { + controller.abort(); + }, API_TIMEOUT_MS); + try { + await fetch(`${origin}/api/v1/shutdown`, { + method: 'POST', + signal: controller.signal, + }); + } finally { + clearTimeout(timeout); + } +} + +const DEFAULT_KILL_DEPS: KillCommandDeps = { + getLiveLock, + requestShutdown: requestShutdownViaApi, + signalPid, + pidAlive, + sleep: (ms) => + new Promise((resolve) => { + setTimeout(resolve, ms); + }), + stdout: process.stdout, + now: () => Date.now(), +}; diff --git a/apps/kimi-code/src/cli/sub/server/lifecycle.ts b/apps/kimi-code/src/cli/sub/server/lifecycle.ts new file mode 100644 index 000000000..eb1349c99 --- /dev/null +++ b/apps/kimi-code/src/cli/sub/server/lifecycle.ts @@ -0,0 +1,253 @@ +/** + * `kimi server install/uninstall/start/stop/restart/status`. + * + * Phase 2 lands the CLI shape; the lifecycle calls into the platform service + * manager from `@moonshot-ai/server`, which is filled in by Phase 3+. + * + * The Commander wiring here mirrors `addGatewayServiceCommands` from + * `../openclaw/src/cli/daemon-cli/register-service-commands.ts:58`. + */ + +import type { Command } from 'commander'; + +import { + ServiceUnavailableError, + ServiceUnsupportedError, + resolveServiceManager, + type InstallArgs, + type ServiceManager, + type ServiceStatus, +} from '@moonshot-ai/server'; + +import { openUrl as defaultOpenUrl } from '#/utils/open-url'; + +import { + DEFAULT_LOG_LEVEL, + DEFAULT_SERVER_HOST, + DEFAULT_SERVER_PORT, + parseLogLevel, + parsePort, + serverOrigin, + VALID_LOG_LEVELS, +} from './shared'; + +export interface InstallCliOptions { + port?: string; + logLevel?: string; + force?: boolean; + open?: boolean; + json?: boolean; +} + +export interface JsonCliOptions { + json?: boolean; +} + +export interface LifecycleCommandDeps { + resolveManager(): ServiceManager; + openUrl(url: string): void; + stdout: Pick; + stderr: Pick; +} + +const DEFAULT_DEPS: LifecycleCommandDeps = { + resolveManager: resolveServiceManager, + openUrl: defaultOpenUrl, + stdout: process.stdout, + stderr: process.stderr, +}; + +/** Mount install/uninstall/start/stop/restart/status under a parent command. */ +export function addLifecycleCommands(parent: Command, deps: LifecycleCommandDeps = DEFAULT_DEPS): void { + parent + .command('install') + .description('Install the Kimi server as an OS-managed service (launchd/systemd/schtasks).') + .option('--port ', `Bind port (default ${DEFAULT_SERVER_PORT})`, String(DEFAULT_SERVER_PORT)) + .option( + '--log-level ', + `Log level: ${VALID_LOG_LEVELS.join('|')} (default ${DEFAULT_LOG_LEVEL})`, + DEFAULT_LOG_LEVEL, + ) + .option('--force', 'Reinstall and overwrite if already installed', false) + .option('--no-open', 'Do not open the web UI after install.', true) + .option('--json', 'Output JSON', false) + .action(async (opts: InstallCliOptions) => { + await runLifecycle(deps, opts.json === true, async (mgr) => { + const args: InstallArgs = { + host: DEFAULT_SERVER_HOST, + port: parsePort(opts.port, '--port', DEFAULT_SERVER_PORT), + logLevel: parseLogLevel(opts.logLevel), + force: opts.force === true, + }; + const result = await mgr.install(args); + const status = await readStatus(mgr); + const enriched = withStatusDetails({ + ok: true, + action: 'install', + status: result.status, + plistPath: result.plistPath, + unitPath: result.unitPath, + taskName: result.taskName, + message: result.message, + }, status, args); + if (opts.json !== true && opts.open !== false && enriched.running === true && typeof enriched.url === 'string') { + deps.openUrl(enriched.url); + } + return enriched; + }); + }); + + parent + .command('uninstall') + .description('Uninstall the Kimi server service.') + .option('--json', 'Output JSON', false) + .action(async (opts: JsonCliOptions) => { + await runLifecycle(deps, opts.json === true, async (mgr) => { + const result = await mgr.uninstall(); + return { ok: result.ok, action: 'uninstall', message: result.message }; + }); + }); + + parent + .command('start') + .description('Start the Kimi server service.') + .option('--json', 'Output JSON', false) + .action(async (opts: JsonCliOptions) => { + await runLifecycle(deps, opts.json === true, async (mgr) => { + const result = await mgr.start(); + const status = await readStatus(mgr); + return withStatusDetails({ ok: result.ok, action: 'start', message: result.message }, status); + }); + }); + + parent + .command('stop') + .description('Stop the Kimi server service.') + .option('--json', 'Output JSON', false) + .action(async (opts: JsonCliOptions) => { + await runLifecycle(deps, opts.json === true, async (mgr) => { + const result = await mgr.stop(); + return { ok: result.ok, action: 'stop', message: result.message }; + }); + }); + + parent + .command('restart') + .description('Restart the Kimi server service.') + .option('--json', 'Output JSON', false) + .action(async (opts: JsonCliOptions) => { + await runLifecycle(deps, opts.json === true, async (mgr) => { + const result = await mgr.restart(); + const status = await readStatus(mgr); + return withStatusDetails({ ok: result.ok, action: 'restart', message: result.message }, status); + }); + }); + + parent + .command('status') + .description('Show Kimi server service status and connectivity.') + .option('--json', 'Output JSON', false) + .action(async (opts: JsonCliOptions) => { + await runLifecycle(deps, opts.json === true, async (mgr) => { + const status: ServiceStatus = await mgr.status(); + return withStatusDetails({ ok: true, action: 'status', ...status }, status); + }); + }); +} + +async function runLifecycle( + deps: LifecycleCommandDeps, + json: boolean, + body: (mgr: ServiceManager) => Promise>, +): Promise { + try { + const mgr = deps.resolveManager(); + const result = await body(mgr); + if (json) { + deps.stdout.write(`${JSON.stringify(result)}\n`); + return; + } + deps.stdout.write(formatHuman(result)); + } catch (error) { + if (error instanceof ServiceUnavailableError || error instanceof ServiceUnsupportedError) { + const payload = { + ok: false, + action: error instanceof ServiceUnavailableError ? 'unavailable' : 'unsupported', + platform: error.platform, + message: error.message, + }; + if (json) { + deps.stdout.write(`${JSON.stringify(payload)}\n`); + } else { + deps.stderr.write(`${error.message}\n`); + } + process.exit(2); + return; + } + if (json) { + deps.stdout.write( + `${JSON.stringify({ ok: false, message: error instanceof Error ? error.message : String(error) })}\n`, + ); + } else { + deps.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`); + } + process.exit(1); + } +} + +function formatHuman(result: Record): string { + const rawAction = result['action']; + const action = typeof rawAction === 'string' ? rawAction : 'action'; + const rawMessage = result['message']; + const message = typeof rawMessage === 'string' ? `: ${rawMessage}` : ''; + const lines = [`${action}${message}`]; + + const url = result['url']; + if (typeof url === 'string') lines.push(`URL: ${url}`); + + const running = result['running']; + if (typeof running === 'boolean') lines.push(`Status: ${running ? 'running' : 'not running'}`); + + const logPath = result['logPath']; + if (typeof logPath === 'string') lines.push(`Log: ${logPath}`); + + const notes = result['notes']; + if (Array.isArray(notes)) { + for (const note of notes) { + if (typeof note === 'string' && note.length > 0) lines.push(`Note: ${note}`); + } + } + + return `${lines.join('\n')}\n`; +} + +async function readStatus(mgr: ServiceManager): Promise { + try { + return await mgr.status(); + } catch { + return undefined; + } +} + +function withStatusDetails( + result: Record, + status: ServiceStatus | undefined, + fallback?: { host: string; port: number }, +): Record & { url?: string; running?: boolean } { + const host = status?.host ?? fallback?.host; + const port = status?.port ?? fallback?.port; + const url = host !== undefined && port !== undefined ? formatServiceUrl(host, port) : undefined; + return { + ...result, + url, + running: status?.running, + host, + port, + logPath: status?.logPath, + notes: status?.notes, + }; +} + +function formatServiceUrl(host: string, port: number): string { + return serverOrigin(host === '0.0.0.0' ? DEFAULT_SERVER_HOST : host, port); +} diff --git a/apps/kimi-code/src/cli/sub/server/ps.ts b/apps/kimi-code/src/cli/sub/server/ps.ts new file mode 100644 index 000000000..7db750545 --- /dev/null +++ b/apps/kimi-code/src/cli/sub/server/ps.ts @@ -0,0 +1,141 @@ +/** + * `kimi server ps` — list clients currently connected to the running server. + * + * Talks to the running server over HTTP (`GET /api/v1/connections`) using the + * single-instance lock (`~/.kimi-code/server/lock`) to discover its origin — + * the same way `kimi web` locates the daemon. + */ + +import chalk from 'chalk'; +import type { Command } from 'commander'; + +import { getLiveLock } from '@moonshot-ai/server'; + +import { lockConnectHost } from './daemon'; +import { isServerHealthy, serverOrigin } from './shared'; + +/** Wire shape of a single connection returned by `GET /api/v1/connections`. */ +interface ConnectionInfo { + id: string; + connected_at: string; + remote_address: string | null; + user_agent: string | null; + has_client_hello: boolean; + subscriptions: string[]; +} + +interface ConnectionsEnvelope { + code: number; + msg: string; + data?: { connections?: ConnectionInfo[] }; +} + +const HEALTH_TIMEOUT_MS = 1500; +const FETCH_TIMEOUT_MS = 5000; +const USER_AGENT_MAX_WIDTH = 40; + +export function registerPsCommand(server: Command): void { + server + .command('ps') + .description('List clients currently connected to the running Kimi server.') + .option('--json', 'Print the raw connection list as JSON.') + .action(async (opts: { json?: boolean }) => { + try { + await handlePsCommand(opts); + } catch (error) { + process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`); + process.exit(1); + } + }); +} + +async function handlePsCommand(opts: { json?: boolean }): Promise { + const lock = getLiveLock(); + if (!lock) { + throw new Error( + 'No running Kimi server. Start one with `kimi server run` or `kimi web`.', + ); + } + + const origin = serverOrigin(lockConnectHost(lock), lock.port); + if (!(await isServerHealthy(origin, HEALTH_TIMEOUT_MS))) { + throw new Error(`Kimi server at ${origin} is not responding.`); + } + + const connections = await fetchConnections(origin); + + if (opts.json) { + process.stdout.write(`${JSON.stringify(connections, null, 2)}\n`); + return; + } + process.stdout.write(formatTable(connections)); +} + +async function fetchConnections(origin: string): Promise { + const controller = new AbortController(); + const timeout = setTimeout(() => { + controller.abort(); + }, FETCH_TIMEOUT_MS); + try { + const res = await fetch(`${origin}/api/v1/connections`, { + signal: controller.signal, + }); + if (!res.ok) { + throw new Error(`Failed to list clients: HTTP ${String(res.status)} from ${origin}.`); + } + const body = (await res.json()) as ConnectionsEnvelope; + if (body.code !== 0) { + throw new Error(`Failed to list clients: ${body.msg}`); + } + return body.data?.connections ?? []; + } catch (error) { + if (error instanceof Error && error.name === 'AbortError') { + throw new Error(`Timed out listing clients from ${origin}.`); + } + throw error; + } finally { + clearTimeout(timeout); + } +} + +function formatTable(connections: ConnectionInfo[]): string { + if (connections.length === 0) { + return 'No active clients.\n'; + } + + const header = ['ID', 'CONNECTED', 'REMOTE', 'USER_AGENT', 'SESSIONS', 'HELLO']; + const rows = connections.map((c) => [ + c.id, + formatAge(c.connected_at), + c.remote_address ?? '-', + truncate(c.user_agent ?? '-', USER_AGENT_MAX_WIDTH), + String(c.subscriptions.length), + c.has_client_hello ? 'yes' : 'no', + ]); + + const widths = header.map((h, i) => Math.max(h.length, ...rows.map((r) => r[i]!.length))); + const formatRow = (cells: string[]): string => + cells.map((cell, i) => cell + ' '.repeat(Math.max(0, widths[i]! - cell.length))).join(' '); + + const lines = [chalk.bold(formatRow(header)), ...rows.map(formatRow)]; + return `${lines.join('\n')}\n`; +} + +function formatAge(iso: string): string { + const ms = Date.now() - Date.parse(iso); + if (!Number.isFinite(ms) || ms < 0) return '-'; + const seconds = Math.floor(ms / 1000); + if (seconds < 60) return `${String(seconds)}s`; + const minutes = Math.floor(seconds / 60); + if (minutes < 60) return `${String(minutes)}m`; + const hours = Math.floor(minutes / 60); + if (hours < 24) return `${String(hours)}h`; + const days = Math.floor(hours / 24); + return `${String(days)}d`; +} + +function truncate(value: string, max: number): string { + if (value.length <= max) return value; + if (max <= 1) return value.slice(0, max); + return `${value.slice(0, max - 1)}…`; +} diff --git a/apps/kimi-code/src/cli/sub/server/run.ts b/apps/kimi-code/src/cli/sub/server/run.ts new file mode 100644 index 000000000..c556f9f5a --- /dev/null +++ b/apps/kimi-code/src/cli/sub/server/run.ts @@ -0,0 +1,387 @@ +/** + * `kimi server run` — starts the local server. + * + * By default this ensures a single background daemon is running (spawning a + * detached `kimi server run --daemon` child when needed) and returns once it is + * healthy. Pass `--foreground` to run the server in-process and keep this + * terminal attached until SIGINT/SIGTERM. OS-managed background operation + * (launchd / systemd / schtasks) lives in `kimi server install` + `kimi server start`. + * + * `kimi web` is an alias of this command with `--open` defaulted to `true`, + * registered in `./web-alias.ts`. + */ + +import { join } from 'node:path'; + +import { truncateToWidth, visibleWidth } from '@earendil-works/pi-tui'; +import { shutdownTelemetry, track } from '@moonshot-ai/kimi-telemetry'; +import { startServer, type RunningServer } from '@moonshot-ai/server'; +import chalk from 'chalk'; +import { Option, type Command } from 'commander'; + +import { CLI_SHUTDOWN_TIMEOUT_MS, WEB_UI_MODE } from '#/constant/app'; +import { getNativeWebAssetsDir } from '#/native/web-assets'; +import { darkColors } from '#/tui/theme/colors'; +import { openUrl as defaultOpenUrl } from '#/utils/open-url'; + +import { initializeServerTelemetry } from '../../telemetry'; +import { createKimiCodeHostIdentity, getHostPackageRoot, getVersion } from '../../version'; +import { ensureDaemon } from './daemon'; +import { + DEFAULT_FOREGROUND_LOG_LEVEL, + DEFAULT_SERVER_PORT, + parseServerOptions, + VALID_LOG_LEVELS, + type ParsedServerOptions, + type ServerCliOptions, +} from './shared'; + +const WEB_ASSETS_DIR = 'dist-web'; +const READY_PANEL_WIDTH = 72; + +export interface RunCliOptions extends ServerCliOptions { + open?: boolean; + /** Run the server in-process instead of spawning a background daemon. */ + foreground?: boolean; +} + +export interface StartForegroundHooks { + /** Fires once the server is listening, before the foreground runner blocks. */ + onReady?: (origin: string) => void; +} + +export interface RunCommandDeps { + startServerBackground(options: ParsedServerOptions): Promise<{ origin: string }>; + /** Foreground runner; defaults to the real in-process runner when omitted. */ + startServerForeground?: ( + options: ParsedServerOptions, + hooks?: StartForegroundHooks, + ) => Promise; + openUrl(url: string): void; + stdout: Pick; + stderr: Pick; +} + +/** Build the `run` subcommand, mounted under a parent (`server` or top-level). */ +export function buildRunCommand(cmd: Command, options: { defaultOpen: boolean }): Command { + return cmd + .option( + '--port ', + `Bind port (default ${DEFAULT_SERVER_PORT})`, + String(DEFAULT_SERVER_PORT), + ) + .option( + '--log-level ', + `Server log level: ${VALID_LOG_LEVELS.join('|')}. Omit to keep logs off.`, + ) + .option( + '--debug-endpoints', + 'Mount /api/v1/debug/* routes for test introspection. OFF by default; production callers leave this unset.', + false, + ) + .option( + '--foreground', + 'Run the server in the foreground and keep this terminal attached until SIGINT/SIGTERM (do not daemonize).', + false, + ) + .option( + options.defaultOpen ? '--no-open' : '--open', + options.defaultOpen + ? 'Do not open the web UI in the default browser.' + : 'Open the web UI in the default browser once the server is healthy.', + options.defaultOpen, + ) + .addOption( + new Option('--daemon', 'Run as an idle-exiting background daemon (internal).').hideHelp(), + ) + .addOption( + new Option( + '--idle-grace-ms ', + 'Idle-shutdown grace in ms (daemon mode, internal).', + ).hideHelp(), + ) + .action(async (opts: RunCliOptions) => { + try { + await handleRunCommand(opts); + } catch (error) { + process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`); + process.exit(1); + } + }); +} + +export async function handleRunCommand( + opts: RunCliOptions, + deps: RunCommandDeps = DEFAULT_RUN_COMMAND_DEPS, +): Promise { + const parsed = parseServerOptions(opts); + if (parsed.daemon) { + await startServerDaemon(parsed); + return; + } + const startedAt = Date.now(); + if (opts.foreground === true) { + const run = deps.startServerForeground ?? startServerForeground; + await run(parsed, { + onReady: (origin) => { + const readyMs = Date.now() - startedAt; + deps.stdout.write( + parsed.logLevel === DEFAULT_FOREGROUND_LOG_LEVEL + ? formatReadyBanner(origin, readyMs) + : `Kimi server: ${origin}\n`, + ); + if (opts.open === true) { + deps.openUrl(origin); + } + }, + }); + return; + } + const { origin } = await deps.startServerBackground(parsed); + const readyMs = Date.now() - startedAt; + deps.stdout.write( + parsed.logLevel === DEFAULT_FOREGROUND_LOG_LEVEL + ? formatReadyBanner(origin, readyMs) + : `Kimi server: ${origin}\n`, + ); + if (opts.open === true) { + deps.openUrl(origin); + } +} + +/** + * `kimi server run` (non-daemon) — ensures a background daemon is running + * (spawning a detached `kimi server run --daemon` child if needed), then + * returns its origin so the caller can print the ready banner and exit. The + * server keeps running in the background after this returns. + */ +export async function startServerBackground( + options: ParsedServerOptions, +): Promise<{ origin: string }> { + const { origin } = await ensureDaemon({ + port: options.port, + logLevel: options.logLevel, + debugEndpoints: options.debugEndpoints, + idleGraceMs: options.idleGraceMs, + }); + return { origin }; +} + +/** + * `kimi server run --daemon` — runs the local server as a background daemon. + * + * Spawned as a detached child by {@link startServerBackground}. The process is + * expected to be detached (no controlling terminal) and self-terminates after + * the last web client disconnects and a grace period elapses. The grace timer + * is driven by the WS connection count reported through `wsGatewayOptions`. + * Resolves only via `process.exit`. + */ +export async function startServerDaemon(options: ParsedServerOptions): Promise { + return runServerInProcess(options, { daemon: true }); +} + +/** + * `kimi server run --foreground` — runs the local server in-process, attached + * to the current terminal. Resolves only via `process.exit` (SIGINT/SIGTERM). + */ +export async function startServerForeground( + options: ParsedServerOptions, + hooks: StartForegroundHooks = {}, +): Promise { + return runServerInProcess(options, { daemon: false }, hooks.onReady); +} + +/** + * Start the server in the current process and block until shutdown. Shared by + * the detached daemon (`daemon: true`, with idle-exit) and the foreground + * runner (`daemon: false`). `onReady` fires once the server is listening. + */ +async function runServerInProcess( + options: ParsedServerOptions, + mode: { daemon: boolean }, + onReady?: (origin: string) => void, +): Promise { + const version = getVersion(); + const telemetry = initializeServerTelemetry({ version }); + + let running: RunningServer | undefined; + let stopping = false; + + const idle = mode.daemon + ? createIdleShutdownHandler({ + graceMs: options.idleGraceMs, + onIdle: () => { + void shutdown('idle'); + }, + }) + : undefined; + + async function shutdown(reason: string): Promise { + if (stopping) return; + stopping = true; + idle?.cancel(); + running?.logger.info({ reason }, 'server shutting down'); + try { + await running?.close(); + await shutdownTelemetry({ timeoutMs: CLI_SHUTDOWN_TIMEOUT_MS }); + } catch (error) { + running?.logger.error( + { err: error instanceof Error ? error : new Error(String(error)) }, + 'server shutdown error', + ); + } + process.exit(0); + } + + running = await startServer({ + host: options.host, + port: options.port, + logLevel: options.logLevel, + debugEndpoints: options.debugEndpoints, + webAssetsDir: serverWebAssetsDir(), + coreProcessOptions: { + identity: createKimiCodeHostIdentity(version), + telemetry, + }, + wsGatewayOptions: { + telemetry, + onConnectionCountChange: idle + ? (size) => { + idle.onConnectionCountChange(size); + } + : undefined, + }, + }); + + track('server_started', { ui_mode: WEB_UI_MODE, daemon: mode.daemon }); + + process.once('SIGINT', () => { + void shutdown('SIGINT'); + }); + process.once('SIGTERM', () => { + void shutdown('SIGTERM'); + }); + + const readyFields = mode.daemon + ? { address: running.address, idleGraceMs: options.idleGraceMs } + : { address: running.address }; + running.logger.info(readyFields, mode.daemon ? 'daemon ready' : 'server ready'); + + onReady?.(running.address); + + return new Promise(() => { + // Keeps the event loop alive; the process ends via shutdown()/process.exit. + }); +} + +/** + * Pure idle-shutdown state machine, exported for tests. + * + * Watches the live WS connection count and fires `onIdle` exactly once, after + * the count has dropped back to zero for `graceMs` ms *and* at least one + * client had connected since startup. A reconnect before the grace elapses + * cancels the pending exit. The initial "no clients yet" state never arms the + * timer (so a freshly-spawned daemon is not killed before anyone connects). + */ +export function createIdleShutdownHandler(opts: { graceMs: number; onIdle: () => void }): { + onConnectionCountChange(size: number): void; + cancel(): void; +} { + let timer: NodeJS.Timeout | undefined; + let seenClient = false; + + const cancel = (): void => { + if (timer !== undefined) { + clearTimeout(timer); + timer = undefined; + } + }; + + return { + onConnectionCountChange(size: number): void { + if (size > 0) { + seenClient = true; + cancel(); + return; + } + if (seenClient) { + cancel(); + timer = setTimeout(opts.onIdle, opts.graceMs); + } + }, + cancel, + }; +} + +function serverWebAssetsDir(): string { + return resolveServerWebAssetsDir(); +} + +export function resolveServerWebAssetsDir( + nativeWebAssetsDir: string | null = getNativeWebAssetsDir(), +): string { + return nativeWebAssetsDir ?? join(getHostPackageRoot(), WEB_ASSETS_DIR); +} + +function formatReadyBanner(origin: string, readyMs: number): string { + const primary = (text: string): string => chalk.hex(darkColors.primary)(text); + const title = (text: string): string => chalk.bold.hex(darkColors.primary)(text); + const dim = (text: string): string => chalk.hex(darkColors.textDim)(text); + const muted = (text: string): string => chalk.hex(darkColors.textMuted)(text); + const label = (text: string): string => chalk.bold.hex(darkColors.textDim)(text); + const url = chalk.hex(darkColors.accent)(displayOrigin(origin)); + const width = READY_PANEL_WIDTH; + const innerWidth = width - 4; + const pad = ' '; + + const logo = ['▐█▛█▛█▌', '▐█████▌'] as const; + const logoWidth = Math.max(...logo.map((row) => visibleWidth(row))); + const gap = ' '; + const textWidth = innerWidth - logoWidth - gap.length; + const headerLines = [ + primary(logo[0].padEnd(logoWidth)) + + gap + + truncateToWidth(title('Kimi server ready'), textWidth, '…'), + primary(logo[1].padEnd(logoWidth)) + + gap + + truncateToWidth(dim('Local web UI is available from this machine.'), textWidth, '…'), + ]; + const infoLines = [ + label('URL: ') + url, + label('Network: ') + muted('local only'), + label('Logs: ') + muted('off') + dim(' use --log-level info to enable'), + label('Stop: ') + muted('kimi server kill'), + label('Ready: ') + muted(`${String(Math.max(0, readyMs))} ms`), + label('Version: ') + muted(getVersion()), + ]; + const contentLines = [...headerLines, '', ...infoLines]; + + const lines = [ + '', + primary('╭' + '─'.repeat(width - 2) + '╮'), + primary('│') + ' '.repeat(width - 2) + primary('│'), + ]; + + for (const content of contentLines) { + const truncated = truncateToWidth(content, innerWidth, '…'); + const rightPad = Math.max(0, innerWidth - visibleWidth(truncated)); + 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.join('\n'); +} + +function displayOrigin(origin: string): string { + return origin.endsWith('/') ? origin : `${origin}/`; +} + +const DEFAULT_RUN_COMMAND_DEPS: RunCommandDeps = { + startServerBackground, + startServerForeground, + openUrl: defaultOpenUrl, + stdout: process.stdout, + stderr: process.stderr, +}; diff --git a/apps/kimi-code/src/cli/sub/server/shared.ts b/apps/kimi-code/src/cli/sub/server/shared.ts new file mode 100644 index 000000000..b0550c65b --- /dev/null +++ b/apps/kimi-code/src/cli/sub/server/shared.ts @@ -0,0 +1,176 @@ +/** + * Shared helpers for `kimi server …` subcommands. + * + * Owns the default host/port, option parsers, and health/readiness probes that + * `run`, `web`, and `status` all use. + */ + +import type { ServerLogLevel } from '@moonshot-ai/server'; + +export const DEFAULT_SERVER_HOST = '127.0.0.1'; +export const DEFAULT_SERVER_PORT = 58627; +export const DEFAULT_SERVER_ORIGIN = serverOrigin(DEFAULT_SERVER_HOST, DEFAULT_SERVER_PORT); + +export const DEFAULT_LOG_LEVEL: ServerLogLevel = 'info'; +export const DEFAULT_FOREGROUND_LOG_LEVEL: ServerLogLevel = 'silent'; + +/** + * Default idle-shutdown grace for the background daemon: once the last web + * client disconnects, the daemon waits this long before exiting. Overridable + * via the internal `--idle-grace-ms` flag (used by tests). + */ +export const DEFAULT_IDLE_GRACE_MS = 60_000; + +export const VALID_LOG_LEVELS: readonly ServerLogLevel[] = [ + 'fatal', + 'error', + 'warn', + 'info', + 'debug', + 'trace', + 'silent', +]; + +export interface ParsedServerOptions { + host: string; + port: number; + logLevel: ServerLogLevel; + debugEndpoints: boolean; + /** Internal: run as an idle-exiting background daemon instead of foreground. */ + daemon: boolean; + /** Internal: idle-shutdown grace in ms (daemon mode only). */ + idleGraceMs: number; +} + +export interface ServerCliOptions { + host?: string; + port?: string; + logLevel?: string; + debugEndpoints?: boolean; + /** Internal flag set by the daemon spawner (`kimi web`). */ + daemon?: boolean; + /** Internal flag set by the daemon spawner / tests. */ + idleGraceMs?: string; +} + +export function parseServerOptions(opts: ServerCliOptions): ParsedServerOptions { + return { + host: opts.host ?? DEFAULT_SERVER_HOST, + port: parsePort(opts.port, '--port', DEFAULT_SERVER_PORT), + logLevel: parseLogLevel(opts.logLevel ?? DEFAULT_FOREGROUND_LOG_LEVEL), + debugEndpoints: opts.debugEndpoints === true, + daemon: opts.daemon === true, + idleGraceMs: parseIdleGraceMs(opts.idleGraceMs), + }; +} + +function parseIdleGraceMs(raw: string | undefined): number { + if (raw === undefined) return DEFAULT_IDLE_GRACE_MS; + const n = Number.parseInt(raw, 10); + if (!Number.isFinite(n) || n < 0) { + throw new Error(`error: invalid --idle-grace-ms value: ${raw}`); + } + return n; +} + +export function parsePort(raw: string | undefined, label: string, fallback: number): number { + if (raw === undefined) return fallback; + const n = Number.parseInt(raw, 10); + if (!Number.isFinite(n) || n < 0 || n > 65535) { + throw new Error(`error: invalid ${label} value: ${raw}`); + } + return n; +} + +export function parseLogLevel(raw: string | undefined): ServerLogLevel { + if (raw === undefined) return DEFAULT_LOG_LEVEL; + if ((VALID_LOG_LEVELS as readonly string[]).includes(raw)) { + return raw as ServerLogLevel; + } + throw new Error( + `error: invalid --log-level value: ${raw} (allowed: ${VALID_LOG_LEVELS.join(', ')})`, + ); +} + +export function serverOrigin(host: string, port: number): string { + return `http://${host}:${port}`; +} + +/** Strip `/api/v1` and trailing slashes so user-supplied origins are uniform. */ +export function normalizeServerOrigin(value: string): string { + const url = new URL(value); + url.pathname = url.pathname.replace(/\/api\/v1\/?$/, '').replace(/\/$/, ''); + url.search = ''; + url.hash = ''; + return url.toString().replace(/\/$/, ''); +} + +/** Single probe of `/api/v1/healthz`. Returns true if the response envelope reports `code: 0`. */ +export async function isServerHealthy(origin: string, timeoutMs: number): Promise { + const controller = new AbortController(); + const timeout = setTimeout(() => { + controller.abort(); + }, timeoutMs); + try { + const response = await fetch(`${origin}/api/v1/healthz`, { + signal: controller.signal, + }); + if (!response.ok) return false; + const body = (await response.json()) as { code?: unknown }; + return body.code === 0; + } catch { + return false; + } finally { + clearTimeout(timeout); + } +} + +/** Poll `/api/v1/healthz` until it reports healthy or `timeoutMs` elapses. */ +export async function waitForServerHealthy(origin: string, timeoutMs: number): Promise { + const deadline = Date.now() + timeoutMs; + do { + if (await isServerHealthy(origin, 500)) { + return true; + } + await new Promise((resolve) => { + setTimeout(resolve, 200); + }); + } while (Date.now() < deadline); + return false; +} + +/** + * Probe `/` and confirm the bundled web UI is being served. + * + * A different build that runs on the same port serves its own bundle — opening + * a browser at that origin lands on stale code. Catching that here lets the + * caller surface a clear "stop the running server" message instead of silently + * handing the user the wrong UI. + */ +export async function ensureServerWebReady(origin: string): Promise { + const controller = new AbortController(); + const timeout = setTimeout(() => { + controller.abort(); + }, 3000); + try { + const response = await fetch(`${origin}/`, { + headers: { accept: 'text/html' }, + signal: controller.signal, + }); + if (!response.ok) { + throw new Error(`HTTP ${response.status}`); + } + const body = await response.text(); + if (!body.includes('
(await auth.getCachedAccessToken(KIMI_CODE_PROVIDER_NAME)) ?? null, + }); + + return { + track, + withContext: withTelemetryContext, + setContext: setTelemetryContext, + }; +} + +function readServerTelemetryConfig( + configPath: string, +): Pick { + try { + const { config, fileError } = loadRuntimeConfigSafe(configPath); + // A broken config fails the server on its own inside KimiCore; for + // telemetry just degrade to "enabled, no model" so we never block startup. + if (fileError !== undefined) return {}; + return config; + } catch { + return {}; + } +} diff --git a/apps/kimi-code/src/constant/app.ts b/apps/kimi-code/src/constant/app.ts index ec66a4a2f..d6cbede4b 100644 --- a/apps/kimi-code/src/constant/app.ts +++ b/apps/kimi-code/src/constant/app.ts @@ -7,6 +7,9 @@ export const PROCESS_NAME = 'kimi-code'; // Used in telemetry app names and HTTP User-Agent headers. export const CLI_USER_AGENT_PRODUCT = 'kimi-code-cli'; export const CLI_UI_MODE = 'shell'; +// Telemetry ui_mode for the `kimi web` / `kimi server run` host. Same product +// as the CLI (CLI_USER_AGENT_PRODUCT); the surface is distinguished by ui_mode. +export const WEB_UI_MODE = 'web'; // Give telemetry a short flush window without making CLI exit feel stuck. export const CLI_SHUTDOWN_TIMEOUT_MS = 3000; diff --git a/apps/kimi-code/src/native/web-assets.ts b/apps/kimi-code/src/native/web-assets.ts new file mode 100644 index 000000000..6fb0845e5 --- /dev/null +++ b/apps/kimi-code/src/native/web-assets.ts @@ -0,0 +1,183 @@ +import { createHash } from 'node:crypto'; +import { mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; + +import { KIMI_BUILD_INFO } from '#/cli/build-info'; +import { + getNativeCacheBase, + getSeaAssetSource, + type NativeAssetSource, +} from './native-assets'; +import { + WEB_ASSET_MANIFEST_VERSION as MANIFEST_VERSION, + buildWebManifestKey, +} from '../../scripts/native/manifest.mjs'; + +export const WEB_ASSET_MANIFEST_VERSION = MANIFEST_VERSION; + +export interface WebAssetFile { + readonly assetKey: string; + readonly relativePath: string; + readonly sha256: string; +} + +export interface WebAssetManifest { + readonly version: typeof WEB_ASSET_MANIFEST_VERSION; + readonly target: string; + readonly root: 'dist-web'; + readonly files: readonly WebAssetFile[]; +} + +export type WebAssetSource = NativeAssetSource; + +export interface WebAssetOptions { + readonly source?: WebAssetSource | null; + readonly manifest?: WebAssetManifest | null; + readonly cacheBase?: string; + readonly env?: NodeJS.ProcessEnv; + readonly platform?: NodeJS.Platform; + readonly homeDir?: string; + readonly version?: string; +} + +type RawWebAssetManifest = Omit & { + readonly version: number; + readonly root: string; +}; + +function currentTarget(): string { + return KIMI_BUILD_INFO.buildTarget ?? `${process.platform}-${process.arch}`; +} + +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 sanitizeSegment(value: string): string { + const sanitized = value.replaceAll(/[^a-zA-Z0-9._-]/g, '_'); + return sanitized.length > 0 ? sanitized : 'unknown'; +} + +function readFileSha256(path: string): string | null { + try { + return sha256(readFileSync(path)); + } catch { + return null; + } +} + +function ensureFile(path: string, bytes: Buffer, expectedSha256: string): void { + if (readFileSha256(path) === expectedSha256) return; + + mkdirSync(dirname(path), { recursive: true }); + const tempPath = `${path}.${process.pid}.${Date.now()}.tmp`; + writeFileSync(tempPath, bytes, { 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 assertSafeRelativePath(relativePath: string): void { + if ( + relativePath.length === 0 || + relativePath.startsWith('/') || + relativePath.includes('\\') || + relativePath.split('/').includes('..') || + /^[A-Za-z]:/.test(relativePath) + ) { + throw new Error(`Invalid web asset relative path: ${relativePath}`); + } +} + +export function webAssetManifestKey(target: string = currentTarget()): string { + return buildWebManifestKey(target); +} + +export function getEmbeddedWebAssetManifest( + source: WebAssetSource | null = getSeaAssetSource(), + target = currentTarget(), +): WebAssetManifest | null { + if (source === null) return null; + const key = webAssetManifestKey(target); + if (!source.getAssetKeys().includes(key)) return null; + const raw = source.getRawAsset(key); + const manifest = JSON.parse(toBuffer(raw).toString('utf-8')) as RawWebAssetManifest; + if (manifest.version !== WEB_ASSET_MANIFEST_VERSION) { + throw new Error(`Unsupported web asset manifest version: ${manifest.version}`); + } + if (manifest.target !== target) { + throw new Error(`Web asset manifest target mismatch: ${manifest.target} !== ${target}`); + } + if (manifest.root !== 'dist-web') { + throw new Error(`Unsupported web asset root: ${manifest.root}`); + } + return manifest as WebAssetManifest; +} + +export function getWebAssetCacheRoot( + manifest: WebAssetManifest, + options: WebAssetOptions = {}, +): string { + const version = sanitizeSegment(options.version ?? KIMI_BUILD_INFO.version ?? 'dev'); + const manifestHash = sha256(JSON.stringify(manifest)); + return join( + getNativeCacheBase({ + cacheBase: options.cacheBase, + env: options.env, + platform: options.platform, + homeDir: options.homeDir, + }), + 'web', + version, + sanitizeSegment(manifest.target), + manifestHash, + manifest.root, + ); +} + +export function getNativeWebAssetsDir(options: WebAssetOptions = {}): string | null { + const source = options.source ?? getSeaAssetSource(); + if (source === null) return null; + + const manifest = options.manifest ?? getEmbeddedWebAssetManifest(source, currentTarget()); + if (manifest === null) return null; + + const cacheRoot = getWebAssetCacheRoot(manifest, options); + for (const file of manifest.files) { + assertSafeRelativePath(file.relativePath); + const bytes = toBuffer(source.getRawAsset(file.assetKey)); + const actualSha256 = sha256(bytes); + if (actualSha256 !== file.sha256) { + throw new Error( + `Web asset checksum mismatch for ${file.assetKey}: ${actualSha256} !== ${file.sha256}`, + ); + } + ensureFile(join(cacheRoot, file.relativePath), bytes, file.sha256); + } + return cacheRoot; +} diff --git a/apps/kimi-code/src/tui/components/chrome/todo-panel.ts b/apps/kimi-code/src/tui/components/chrome/todo-panel.ts index 9e02e2fbf..e52d77768 100644 --- a/apps/kimi-code/src/tui/components/chrome/todo-panel.ts +++ b/apps/kimi-code/src/tui/components/chrome/todo-panel.ts @@ -124,7 +124,7 @@ export class TodoPanelComponent implements Component { const { rows, hidden } = selectVisibleTodos(this.todos); const lines: string[] = [ chalk.hex(c.border)('─'.repeat(width)), - chalk.hex(c.primary).bold(' Todo'), + chalk.hex(c.primary).bold(' Todo'), ]; for (const todo of rows) { lines.push(renderRow(todo, c)); diff --git a/apps/kimi-code/test/cli/options.test.ts b/apps/kimi-code/test/cli/options.test.ts index f4fb7d7e9..a021d2bbe 100644 --- a/apps/kimi-code/test/cli/options.test.ts +++ b/apps/kimi-code/test/cli/options.test.ts @@ -345,6 +345,8 @@ describe('CLI options parsing', () => { 'export', 'provider', 'acp', + 'server', + 'web', 'login', 'doctor', 'vis', diff --git a/apps/kimi-code/test/cli/server/server.test.ts b/apps/kimi-code/test/cli/server/server.test.ts new file mode 100644 index 000000000..e2a8aab12 --- /dev/null +++ b/apps/kimi-code/test/cli/server/server.test.ts @@ -0,0 +1,824 @@ +/** + * Tests for `kimi server run` and `kimi web` Commander wiring. + * + * These tests don't actually start the server — they verify the parsed shape + * (option flags, --open default) and that the `web` alias defers to the same + * underlying handler with `defaultOpen` flipped to true. + * + * Foreground startup behavior is exercised end-to-end in `server-e2e/`. + */ + +import { readFileSync } from 'node:fs'; +import { createServer, type Server } from 'node:net'; + +import chalk, { Chalk } from 'chalk'; +import { Command } from 'commander'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { registerServerCommand } from '#/cli/sub/server'; +import { addLifecycleCommands } from '#/cli/sub/server/lifecycle'; +import type { KillCommandDeps } from '#/cli/sub/server/kill'; +import { darkColors } from '#/tui/theme/colors'; + +function stripAnsi(text: string): string { + return text.replaceAll(/\u001B\[[0-9;]*m/g, ''); +} + +function makeProgram(): Command { + // `commander` exitOverride avoids killing the test runner when --help/error fires. + const program = new Command('kimi').exitOverride(); + registerServerCommand(program); + return program; +} + +describe('kimi server', () => { + it('declares pino-pretty as a CLI runtime dependency', () => { + const packageJson = JSON.parse( + readFileSync(new URL('../../../package.json', import.meta.url), 'utf-8'), + ) as { optionalDependencies?: Record }; + + expect(packageJson.optionalDependencies).toHaveProperty('pino-pretty'); + }); + + it('registers the expected `server` subcommands while lifecycle commands are hidden', () => { + const program = makeProgram(); + const server = program.commands.find((c) => c.name() === 'server'); + expect(server).toBeDefined(); + const subs = server?.commands.map((c) => c.name()).toSorted(); + expect(subs).toEqual(['kill', 'ps', 'run']); + }); + + it('`server run` exposes local-only foreground options', () => { + const program = makeProgram(); + const run = program.commands + .find((c) => c.name() === 'server') + ?.commands.find((c) => c.name() === 'run'); + expect(run).toBeDefined(); + const longs = run!.options.map((o) => o.long).filter(Boolean); + expect(longs).not.toContain('--host'); + expect(longs).toContain('--port'); + expect(longs).toContain('--log-level'); + expect(longs).toContain('--debug-endpoints'); + expect(longs).toContain('--foreground'); + // run defaults to NOT opening the browser → option is the positive --open + expect(longs).toContain('--open'); + }); + + it('`server install` exposes local-only service options', () => { + // Lifecycle commands are no longer registered via `registerServerCommand`, + // but the builder still lives in `./lifecycle` — exercise it directly. + const server = new Command('server'); + addLifecycleCommands(server); + const install = server.commands.find((c) => c.name() === 'install'); + expect(install).toBeDefined(); + const longs = install!.options.map((o) => o.long).filter(Boolean); + expect(longs).not.toContain('--host'); + expect(longs).toContain('--port'); + expect(longs).toContain('--log-level'); + expect(longs).toContain('--force'); + expect(longs).toContain('--no-open'); + expect(longs).toContain('--json'); + }); + + it('the top-level `kimi web` alias is registered and defaults to opening the browser', () => { + const program = makeProgram(); + const web = program.commands.find((c) => c.name() === 'web'); + expect(web).toBeDefined(); + const longs = web!.options.map((o) => o.long).filter(Boolean); + // web defaults to opening → the option is the negative form --no-open + expect(longs).toContain('--no-open'); + expect(longs).not.toContain('--host'); + expect(longs).toContain('--port'); + }); +}); + +describe('`kimi server` lifecycle exits with ESERVICE_UNSUPPORTED on unsupported platforms', () => { + it('the dispatcher returns a friendly error manager for unknown platforms', async () => { + // darwin / linux / win32 have real backends (launchd / systemd / schtasks). + // The remaining platforms fall through to the stub that throws + // `ServiceUnsupportedError` — pin that contract so a future addition + // (freebsd, etc.) needs a deliberate decision instead of silently working. + const { resolveServiceManager, ServiceUnsupportedError } = await import('@moonshot-ai/server'); + const mgr = resolveServiceManager('freebsd'); + await expect( + mgr.install({ host: '127.0.0.1', port: 58627, logLevel: 'info' }), + ).rejects.toBeInstanceOf(ServiceUnsupportedError); + await expect(mgr.status()).rejects.toBeInstanceOf(ServiceUnsupportedError); + }); +}); + +describe('`kimi server` lifecycle handles unavailable service managers', () => { + it('prints a friendly JSON error and exits 2', async () => { + const { ServiceUnavailableError } = await import('@moonshot-ai/server'); + const program = new Command('kimi').exitOverride(); + const server = program.command('server'); + let stdout = ''; + let stderr = ''; + const exit = vi.spyOn(process, 'exit').mockImplementation(((code?: number | string | null) => { + throw new Error(`process.exit(${String(code)})`); + }) as typeof process.exit); + + addLifecycleCommands(server, { + resolveManager: () => ({ + install: async () => { + throw new ServiceUnavailableError( + 'linux', + 'systemd --user is not available in this environment.', + ); + }, + uninstall: async () => ({ ok: true, message: 'unused' }), + start: async () => ({ ok: true, message: 'unused' }), + stop: async () => ({ ok: true, message: 'unused' }), + restart: async () => ({ ok: true, message: 'unused' }), + status: async () => ({ platform: 'linux', installed: false, running: false }), + }), + openUrl: vi.fn(), + stdout: { + write(chunk: string | Uint8Array) { + stdout += String(chunk); + return true; + }, + }, + stderr: { + write(chunk: string | Uint8Array) { + stderr += String(chunk); + return true; + }, + }, + }); + + await expect( + program.parseAsync(['node', 'kimi', 'server', 'install', '--json']), + ).rejects.toThrow('process.exit(2)'); + + exit.mockRestore(); + expect(stderr).toBe(''); + expect(JSON.parse(stdout)).toMatchObject({ + ok: false, + action: 'unavailable', + platform: 'linux', + message: expect.stringContaining('server run --port '), + }); + }); +}); + +describe('`kimi server` lifecycle output', () => { + it('install passes --force/--port, prints the URL, and opens it when running', async () => { + const program = new Command('kimi').exitOverride(); + const server = program.command('server'); + let stdout = ''; + let stderr = ''; + let installArgs: unknown; + const openUrl = vi.fn(); + + addLifecycleCommands(server, { + resolveManager: () => ({ + install: async (args) => { + installArgs = args; + return { + status: 'replaced', + message: 'Kimi server LaunchAgent replaced at /tmp/kimi.plist (port 9999).', + plistPath: '/tmp/kimi.plist', + }; + }, + uninstall: async () => ({ ok: true, message: 'unused' }), + start: async () => ({ ok: true, message: 'unused' }), + stop: async () => ({ ok: true, message: 'unused' }), + restart: async () => ({ ok: true, message: 'unused' }), + status: async () => ({ + platform: 'darwin', + installed: true, + running: true, + host: '127.0.0.1', + port: 9999, + logPath: '/tmp/server.log', + label: 'ai.moonshot.kimi-server', + }), + }), + openUrl, + stdout: { + write(chunk: string | Uint8Array) { + stdout += String(chunk); + return true; + }, + }, + stderr: { + write(chunk: string | Uint8Array) { + stderr += String(chunk); + return true; + }, + }, + }); + + await program.parseAsync([ + 'node', + 'kimi', + 'server', + 'install', + '--force', + '--port', + '9999', + ]); + + expect(stderr).toBe(''); + expect(installArgs).toMatchObject({ port: 9999, force: true }); + expect(stdout).toContain('URL: http://127.0.0.1:9999'); + expect(stdout).toContain('Status: running'); + expect(stdout).toContain('Log: /tmp/server.log'); + expect(openUrl).toHaveBeenCalledWith('http://127.0.0.1:9999'); + }); + + it('start prints URL and diagnostics when launchd did not keep the service running', async () => { + const program = new Command('kimi').exitOverride(); + const server = program.command('server'); + let stdout = ''; + const openUrl = vi.fn(); + + addLifecycleCommands(server, { + resolveManager: () => ({ + install: async () => ({ status: 'installed', message: 'unused' }), + uninstall: async () => ({ ok: true, message: 'unused' }), + start: async () => ({ ok: true, message: 'Kimi server started (ai.moonshot.kimi-server).' }), + stop: async () => ({ ok: true, message: 'unused' }), + restart: async () => ({ ok: true, message: 'unused' }), + status: async () => ({ + platform: 'darwin', + installed: true, + running: false, + host: '127.0.0.1', + port: 58627, + logPath: '/tmp/server.log', + label: 'ai.moonshot.kimi-server', + notes: ['launchd state: spawn scheduled', 'last exit code: 78 EX_CONFIG'], + }), + }), + openUrl, + stdout: { + write(chunk: string | Uint8Array) { + stdout += String(chunk); + return true; + }, + }, + stderr: { + write() { + return true; + }, + }, + }); + + await program.parseAsync(['node', 'kimi', 'server', 'start']); + + expect(stdout).toContain('URL: http://127.0.0.1:58627'); + expect(stdout).toContain('Status: not running'); + expect(stdout).toContain('launchd state: spawn scheduled'); + expect(stdout).toContain('last exit code: 78 EX_CONFIG'); + expect(openUrl).not.toHaveBeenCalled(); + }); +}); + +describe('`kimi server run` background start', () => { + it('defaults the daemon log level to silent', async () => { + const { handleRunCommand } = await import('#/cli/sub/server/run'); + let parsed: unknown; + + await handleRunCommand( + { port: '58627' }, + { + startServerBackground: async (options) => { + parsed = options; + return { origin: 'http://127.0.0.1:58627' }; + }, + openUrl: vi.fn(), + stdout: { + write() { + return true; + }, + }, + stderr: { + write() { + return true; + }, + }, + }, + ); + + expect(parsed).toMatchObject({ logLevel: 'silent' }); + }); + + it('passes --log-level through to the background daemon', async () => { + const { handleRunCommand } = await import('#/cli/sub/server/run'); + let parsed: unknown; + + await handleRunCommand( + { port: '58627', logLevel: 'debug' }, + { + startServerBackground: async (options) => { + parsed = options; + return { origin: 'http://127.0.0.1:58627' }; + }, + openUrl: vi.fn(), + stdout: { + write() { + return true; + }, + }, + stderr: { + write() { + return true; + }, + }, + }, + ); + + expect(parsed).toMatchObject({ logLevel: 'debug' }); + }); + + it('prints a TUI-style ready panel once the daemon is up', async () => { + const { handleRunCommand } = await import('#/cli/sub/server/run'); + let stdout = ''; + + await handleRunCommand( + { port: '58627' }, + { + startServerBackground: async () => ({ origin: 'http://127.0.0.1:58627' }), + openUrl: vi.fn(), + stdout: { + write(chunk: string | Uint8Array) { + stdout += String(chunk); + return true; + }, + }, + stderr: { + write() { + return true; + }, + }, + }, + ); + + const plain = stripAnsi(stdout); + expect(plain).toContain('╭'); + expect(plain).toContain('╰'); + expect(plain).toContain('▐█▛█▛█▌'); + expect(plain).toContain('▐█████▌'); + expect(plain).toContain('Kimi server ready'); + expect(plain).toContain('URL:'); + expect(plain).toContain('http://127.0.0.1:58627/'); + expect(plain).toContain('Network:'); + expect(plain).toContain('local only'); + expect(plain).toContain('Logs:'); + expect(plain).toContain('off'); + expect(plain).toContain('Stop:'); + expect(plain).toContain('kimi server kill'); + expect(plain).not.toContain('➜'); + expect(plain).not.toContain('Kimi server:'); + }); + + it('uses the TUI dark palette for the ready banner', async () => { + const { handleRunCommand } = await import('#/cli/sub/server/run'); + let stdout = ''; + const previousChalkLevel = chalk.level; + chalk.level = 3; + + try { + await handleRunCommand( + { port: '58627' }, + { + startServerBackground: async () => ({ origin: 'http://127.0.0.1:58627' }), + openUrl: vi.fn(), + stdout: { + write(chunk: string | Uint8Array) { + stdout += String(chunk); + return true; + }, + }, + stderr: { + write() { + return true; + }, + }, + }, + ); + } finally { + chalk.level = previousChalkLevel; + } + + const color = new Chalk({ level: 3 }); + expect(stdout).toContain(color.hex(darkColors.primary)('▐█▛█▛█▌')); + expect(stdout).toContain(color.bold.hex(darkColors.primary)('Kimi server ready')); + expect(stdout).toContain(color.hex(darkColors.accent)('http://127.0.0.1:58627/')); + expect(stdout).toContain(color.bold.hex(darkColors.textDim)('URL: ')); + expect(stdout).toContain(color.hex(darkColors.textMuted)('local only')); + }); +}); + +describe('`kimi server run --foreground`', () => { + it('runs the server in-process instead of spawning a background daemon', async () => { + const { handleRunCommand } = await import('#/cli/sub/server/run'); + let foregroundOptions: unknown; + let backgroundCalled = false; + + await handleRunCommand( + { port: '58627', foreground: true }, + { + startServerBackground: async () => { + backgroundCalled = true; + return { origin: 'http://127.0.0.1:58627' }; + }, + startServerForeground: async (options) => { + foregroundOptions = options; + return undefined as unknown as never; + }, + openUrl: vi.fn(), + stdout: { + write() { + return true; + }, + }, + stderr: { + write() { + return true; + }, + }, + }, + ); + + expect(backgroundCalled).toBe(false); + expect(foregroundOptions).toMatchObject({ port: 58627, logLevel: 'silent' }); + }); + + it('prints the ready banner and opens the browser once listening', async () => { + const { handleRunCommand } = await import('#/cli/sub/server/run'); + let stdout = ''; + const openUrl = vi.fn(); + + await handleRunCommand( + { port: '58627', foreground: true, open: true }, + { + startServerBackground: async () => ({ origin: 'http://127.0.0.1:58627' }), + startServerForeground: async (options, hooks) => { + void options; + hooks?.onReady?.('http://127.0.0.1:58627'); + return undefined as unknown as never; + }, + openUrl, + stdout: { + write(chunk: string | Uint8Array) { + stdout += String(chunk); + return true; + }, + }, + stderr: { + write() { + return true; + }, + }, + }, + ); + + const plain = stripAnsi(stdout); + expect(plain).toContain('Kimi server ready'); + expect(plain).toContain('http://127.0.0.1:58627/'); + expect(openUrl).toHaveBeenCalledWith('http://127.0.0.1:58627'); + }); +}); + +describe('`kimi server` does not register a legacy `daemon` command', () => { + it('hard-deletes the old name', () => { + const program = makeProgram(); + const daemon = program.commands.find((c) => c.name() === 'daemon'); + expect(daemon).toBeUndefined(); + }); +}); + +describe('shared parsers stay strict', () => { + it('rejects out-of-range --port', async () => { + const { parsePort } = await import('#/cli/sub/server/shared'); + expect(() => parsePort('99999', '--port', 58627)).toThrow(/invalid --port/); + expect(() => parsePort('-1', '--port', 58627)).toThrow(/invalid --port/); + expect(parsePort(undefined, '--port', 58627)).toBe(58627); + expect(parsePort('8080', '--port', 58627)).toBe(8080); + }); + + it('rejects unknown --log-level values', async () => { + const { parseLogLevel } = await import('#/cli/sub/server/shared'); + expect(() => parseLogLevel('shout')).toThrow(/invalid --log-level/); + expect(parseLogLevel(undefined)).toBe('info'); + expect(parseLogLevel('debug')).toBe('debug'); + }); +}); + +describe('server web asset directory resolution', () => { + it('uses extracted SEA web assets when available', async () => { + const { resolveServerWebAssetsDir } = await import('#/cli/sub/server/run'); + expect(resolveServerWebAssetsDir('/cache/kimi/dist-web')).toBe('/cache/kimi/dist-web'); + }); + + it('falls back to package dist-web outside SEA mode', async () => { + const { resolveServerWebAssetsDir } = await import('#/cli/sub/server/run'); + expect(resolveServerWebAssetsDir(null)).toMatch(/[/\\]dist-web$/); + }); +}); + +function listenOnce(host: string, port: number): Promise { + return new Promise((resolve, reject) => { + const server = createServer(); + server.once('error', reject); + server.listen({ host, port }, () => resolve(server)); + }); +} + +function closeServer(server: Server): Promise { + return new Promise((resolve) => server.close(() => resolve())); +} + +async function allocateFreePort(host = '127.0.0.1'): Promise { + const server = await listenOnce(host, 0); + const address = server.address(); + const port = typeof address === 'object' && address !== null ? address.port : 0; + await closeServer(server); + return port; +} + +/** + * Find the start of a run of `count` consecutive free ports + * (`start`, `start + 1`, …, `start + count - 1` all bindable). + */ +async function allocateAdjacentFreeRun(count: number, host = '127.0.0.1'): Promise { + for (let i = 0; i < 50; i++) { + const start = await allocateFreePort(host); + if (start <= 0 || start + count - 1 > 65535) continue; + const held: Server[] = []; + let ok = true; + for (let offset = 1; offset < count; offset++) { + const probe = await listenOnce(host, start + offset).catch(() => null); + if (probe === null) { + ok = false; + break; + } + held.push(probe); + } + for (const server of held) await closeServer(server); + if (ok) return start; + } + throw new Error('could not allocate a run of adjacent free ports'); +} + +describe('resolveDaemonPort', () => { + it('returns the preferred port when it is free', async () => { + const { resolveDaemonPort } = await import('#/cli/sub/server/daemon'); + const free = await allocateFreePort(); + await expect(resolveDaemonPort('127.0.0.1', free)).resolves.toBe(free); + }); + + it('falls back to a different free port when the preferred port is busy', async () => { + const { resolveDaemonPort } = await import('#/cli/sub/server/daemon'); + const busy = await allocateFreePort(); + const holder = await listenOnce('127.0.0.1', busy); + try { + const port = await resolveDaemonPort('127.0.0.1', busy); + expect(port).not.toBe(busy); + expect(port).toBeGreaterThan(0); + } finally { + await closeServer(holder); + } + }); + + it('walks to preferred+1 when only the preferred port is busy', async () => { + const { resolveDaemonPort } = await import('#/cli/sub/server/daemon'); + const start = await allocateAdjacentFreeRun(2); + const holder = await listenOnce('127.0.0.1', start); + try { + const port = await resolveDaemonPort('127.0.0.1', start); + expect(port).toBe(start + 1); + } finally { + await closeServer(holder); + } + }); + + it('skips past a run of busy ports to the first free one', async () => { + const { resolveDaemonPort } = await import('#/cli/sub/server/daemon'); + const start = await allocateAdjacentFreeRun(3); + // Hold both `start` and `start+1`; the resolver should land on `start+2`. + const holderA = await listenOnce('127.0.0.1', start); + const holderB = await listenOnce('127.0.0.1', start + 1); + try { + const port = await resolveDaemonPort('127.0.0.1', start); + expect(port).toBe(start + 2); + } finally { + await closeServer(holderA); + await closeServer(holderB); + } + }); +}); + +describe('createIdleShutdownHandler', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + afterEach(() => { + vi.useRealTimers(); + }); + + it('does not arm before any client connects', async () => { + const { createIdleShutdownHandler } = await import('#/cli/sub/server/run'); + const onIdle = vi.fn(); + const handler = createIdleShutdownHandler({ graceMs: 1000, onIdle }); + handler.onConnectionCountChange(0); + vi.advanceTimersByTime(2000); + expect(onIdle).not.toHaveBeenCalled(); + }); + + it('fires onIdle after the grace once the last client leaves', async () => { + const { createIdleShutdownHandler } = await import('#/cli/sub/server/run'); + const onIdle = vi.fn(); + const handler = createIdleShutdownHandler({ graceMs: 1000, onIdle }); + handler.onConnectionCountChange(1); + handler.onConnectionCountChange(0); + vi.advanceTimersByTime(999); + expect(onIdle).not.toHaveBeenCalled(); + vi.advanceTimersByTime(1); + expect(onIdle).toHaveBeenCalledTimes(1); + }); + + it('cancels a pending exit when a client reconnects during the grace', async () => { + const { createIdleShutdownHandler } = await import('#/cli/sub/server/run'); + const onIdle = vi.fn(); + const handler = createIdleShutdownHandler({ graceMs: 1000, onIdle }); + handler.onConnectionCountChange(1); + handler.onConnectionCountChange(0); + vi.advanceTimersByTime(500); + handler.onConnectionCountChange(1); // reconnect + vi.advanceTimersByTime(2000); + expect(onIdle).not.toHaveBeenCalled(); + }); + + it('only the final drop to zero arms the timer with multiple clients', async () => { + const { createIdleShutdownHandler } = await import('#/cli/sub/server/run'); + const onIdle = vi.fn(); + const handler = createIdleShutdownHandler({ graceMs: 500, onIdle }); + handler.onConnectionCountChange(1); + handler.onConnectionCountChange(2); + handler.onConnectionCountChange(1); // still one connected + vi.advanceTimersByTime(1000); + expect(onIdle).not.toHaveBeenCalled(); + handler.onConnectionCountChange(0); // now none + vi.advanceTimersByTime(500); + expect(onIdle).toHaveBeenCalledTimes(1); + }); +}); + +describe('kimi web (shares `server run` call stack)', () => { + it('prints the ready banner and opens the browser by default', async () => { + const { handleRunCommand } = await import('#/cli/sub/server/run'); + let stdout = ''; + const openUrl = vi.fn(); + + await handleRunCommand( + { port: '58627', open: true }, + { + startServerBackground: async () => ({ origin: 'http://127.0.0.1:58627' }), + openUrl, + stdout: { + write(chunk: string | Uint8Array) { + stdout += String(chunk); + return true; + }, + }, + stderr: { + write() { + return true; + }, + }, + }, + ); + + expect(stripAnsi(stdout)).toContain('Kimi server ready'); + expect(openUrl).toHaveBeenCalledWith('http://127.0.0.1:58627'); + }); + + it('does not open the browser when open is false', async () => { + const { handleRunCommand } = await import('#/cli/sub/server/run'); + const openUrl = vi.fn(); + await handleRunCommand( + { port: '58627' }, + { + startServerBackground: async () => ({ origin: 'http://127.0.0.1:9000' }), + openUrl, + stdout: { write: () => true }, + stderr: { write: () => true }, + }, + ); + expect(openUrl).not.toHaveBeenCalled(); + }); + + it('rejects an invalid --log-level before touching the daemon', async () => { + const { handleRunCommand } = await import('#/cli/sub/server/run'); + const startServerBackground = vi.fn(); + await expect( + handleRunCommand( + { logLevel: 'shout' }, + { + startServerBackground, + openUrl: vi.fn(), + stdout: { write: () => true }, + stderr: { write: () => true }, + }, + ), + ).rejects.toThrow(/invalid --log-level/); + expect(startServerBackground).not.toHaveBeenCalled(); + }); +}); + +function makeKillDeps(overrides: Partial = {}): { + deps: KillCommandDeps; + writes: string[]; + signals: Array<{ pid: number; signal: NodeJS.Signals }>; + state: { shutdownCalls: number }; + clock: { t: number }; +} { + const writes: string[] = []; + const signals: Array<{ pid: number; signal: NodeJS.Signals }> = []; + const state = { shutdownCalls: 0 }; + const clock = { t: 0 }; + const deps: KillCommandDeps = { + getLiveLock: () => undefined, + requestShutdown: async () => { + state.shutdownCalls += 1; + }, + signalPid: (pid, signal) => { + signals.push({ pid, signal }); + return true; + }, + pidAlive: () => false, + sleep: async (ms) => { + clock.t += ms; + }, + stdout: { + write(chunk: string | Uint8Array) { + writes.push(String(chunk)); + return true; + }, + }, + now: () => clock.t, + ...overrides, + }; + return { deps, writes, signals, state, clock }; +} + +describe('`kimi server kill`', () => { + const liveLock = { pid: 1234, started_at: '2026-06-17T00:00:00.000Z', port: 58627 }; + + it('prints "No running Kimi server." and sends no signal when no live lock exists', async () => { + const { handleKillCommand } = await import('#/cli/sub/server/kill'); + const { deps, writes, signals } = makeKillDeps({ getLiveLock: () => undefined }); + + await handleKillCommand(deps); + + expect(writes.join('')).toContain('No running Kimi server.'); + expect(signals).toEqual([]); + }); + + it('attempts the API shutdown, then stops after SIGTERM when the pid exits promptly', async () => { + const { handleKillCommand } = await import('#/cli/sub/server/kill'); + const { deps, writes, signals, state, clock } = makeKillDeps({ + getLiveLock: () => liveLock, + pidAlive: () => clock.t < 50, + }); + + await handleKillCommand(deps); + + expect(state.shutdownCalls).toBe(1); + expect(signals).toEqual([{ pid: 1234, signal: 'SIGTERM' }]); + expect(writes.join('')).toContain('pid 1234'); + expect(writes.join('')).toContain('stopped.'); + }); + + it('escalates to SIGKILL when the pid survives SIGTERM', async () => { + const { handleKillCommand } = await import('#/cli/sub/server/kill'); + const { deps, writes, signals, clock } = makeKillDeps({ + getLiveLock: () => ({ ...liveLock, pid: 5678 }), + // Survives the 3s SIGTERM grace, dies during the 2s SIGKILL grace. + pidAlive: () => clock.t < 3100, + }); + + await handleKillCommand(deps); + + expect(signals.map((s) => s.signal)).toEqual(['SIGTERM', 'SIGKILL']); + expect(writes.join('')).toContain('pid 5678'); + expect(writes.join('')).toContain('killed.'); + }); + + it('throws a permissions error when the pid survives SIGKILL', async () => { + const { handleKillCommand } = await import('#/cli/sub/server/kill'); + const { deps } = makeKillDeps({ + getLiveLock: () => ({ ...liveLock, pid: 9999 }), + pidAlive: () => true, + }); + + await expect(handleKillCommand(deps)).rejects.toThrow(/insufficient permissions/); + }); +}); + +// Silence vi import for cases where the file is built before tests reference vi. +void vi; diff --git a/apps/kimi-code/test/cli/telemetry.test.ts b/apps/kimi-code/test/cli/telemetry.test.ts new file mode 100644 index 000000000..8b0655862 --- /dev/null +++ b/apps/kimi-code/test/cli/telemetry.test.ts @@ -0,0 +1,111 @@ +/** + * Tests for the CLI telemetry bootstrap helpers, focusing on the + * `kimi web` / `kimi server run` host wiring added in `cli/telemetry.ts`. + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const mocks = vi.hoisted(() => ({ + initializeTelemetry: vi.fn(), + createKimiDeviceId: vi.fn(() => 'device-123'), + resolveKimiHome: vi.fn(() => '/home/.kimi-code'), + resolveConfigPath: vi.fn(() => '/home/.kimi-code/config.toml'), + loadRuntimeConfigSafe: vi.fn( + (): { + config: { defaultModel?: string; telemetry?: boolean }; + fileError: Error | undefined; + } => ({ + config: { defaultModel: 'kimi-k2', telemetry: true }, + fileError: undefined, + }), + ), + getCachedAccessToken: vi.fn(async () => 'tok'), +})); + +vi.mock('@moonshot-ai/kimi-telemetry', () => ({ + initializeTelemetry: mocks.initializeTelemetry, + setTelemetryContext: vi.fn(), + track: vi.fn(), + withTelemetryContext: vi.fn(), +})); + +vi.mock('@moonshot-ai/kimi-code-oauth', () => ({ + createKimiDeviceId: mocks.createKimiDeviceId, + KIMI_CODE_PROVIDER_NAME: 'managed:kimi-code', +})); + +vi.mock('@moonshot-ai/kimi-code-sdk', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + resolveKimiHome: mocks.resolveKimiHome, + resolveConfigPath: mocks.resolveConfigPath, + loadRuntimeConfigSafe: mocks.loadRuntimeConfigSafe, + KimiAuthFacade: vi.fn(function () { + return { getCachedAccessToken: mocks.getCachedAccessToken }; + }), + }; +}); + +describe('initializeServerTelemetry', () => { + beforeEach(() => { + mocks.initializeTelemetry.mockClear(); + mocks.loadRuntimeConfigSafe.mockClear(); + mocks.loadRuntimeConfigSafe.mockReturnValue({ + config: { defaultModel: 'kimi-k2', telemetry: true }, + fileError: undefined, + }); + }); + + it('configures the sink with ui_mode="web" and the CLI product identity', async () => { + const { initializeServerTelemetry } = await import('#/cli/telemetry'); + const client = initializeServerTelemetry({ version: '1.2.3' }); + + expect(mocks.initializeTelemetry).toHaveBeenCalledWith( + expect.objectContaining({ + appName: 'kimi-code-cli', + version: '1.2.3', + uiMode: 'web', + model: 'kimi-k2', + enabled: true, + deviceId: 'device-123', + homeDir: '/home/.kimi-code', + }), + ); + // The returned client wraps the module functions so core + the host share + // the same underlying client. + expect(client).toEqual( + expect.objectContaining({ + track: expect.any(Function), + withContext: expect.any(Function), + setContext: expect.any(Function), + }), + ); + }); + + it('disables telemetry when config.toml sets telemetry = false', async () => { + mocks.loadRuntimeConfigSafe.mockReturnValue({ + config: { defaultModel: 'kimi-k2', telemetry: false }, + fileError: undefined, + }); + const { initializeServerTelemetry } = await import('#/cli/telemetry'); + initializeServerTelemetry({ version: '1.2.3' }); + + expect(mocks.initializeTelemetry).toHaveBeenCalledWith( + expect.objectContaining({ enabled: false }), + ); + }); + + it('degrades to enabled with no model when config is unreadable', async () => { + mocks.loadRuntimeConfigSafe.mockReturnValue({ + config: {}, + fileError: new Error('bad toml'), + }); + const { initializeServerTelemetry } = await import('#/cli/telemetry'); + initializeServerTelemetry({ version: '1.2.3' }); + + expect(mocks.initializeTelemetry).toHaveBeenCalledWith( + expect.objectContaining({ enabled: true, model: undefined }), + ); + }); +}); diff --git a/apps/kimi-code/test/native/web-assets.test.ts b/apps/kimi-code/test/native/web-assets.test.ts new file mode 100644 index 000000000..abe3801fe --- /dev/null +++ b/apps/kimi-code/test/native/web-assets.test.ts @@ -0,0 +1,113 @@ +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 { + getNativeWebAssetsDir, + getWebAssetCacheRoot, + WEB_ASSET_MANIFEST_VERSION, + type WebAssetManifest, + type WebAssetSource, +} from '#/native/web-assets'; + +function sha256(bytes: Buffer | string): string { + return createHash('sha256').update(bytes).digest('hex'); +} + +function fakeWebAssets(files: Record): { + manifest: WebAssetManifest; + source: WebAssetSource; +} { + const manifest: WebAssetManifest = { + version: WEB_ASSET_MANIFEST_VERSION, + target: 'test-target', + root: 'dist-web', + files: Object.entries(files).map(([relativePath, content]) => ({ + assetKey: `web/test-target/dist-web/${relativePath}`, + relativePath, + sha256: sha256(content), + })), + }; + const assets = new Map([ + ['web/test-target/manifest.json', Buffer.from(JSON.stringify(manifest))], + ...Object.entries(files).map(([relativePath, content]) => [ + `web/test-target/dist-web/${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('web assets', () => { + it('extracts embedded web assets into a dist-web cache directory', () => { + const dir = mkdtempSync(join(tmpdir(), 'kimi-web-assets-runtime-')); + try { + const { manifest, source } = fakeWebAssets({ + 'index.html': '
\n', + 'assets/app.js': 'console.log("ok");\n', + }); + + const webDir = getNativeWebAssetsDir({ + cacheBase: dir, + manifest, + source, + version: 'test', + }); + + expect(webDir).toBe(getWebAssetCacheRoot(manifest, { cacheBase: dir, version: 'test' })); + expect(readFileSync(join(webDir ?? '', 'index.html'), 'utf-8')).toBe('
\n'); + expect(readFileSync(join(webDir ?? '', 'assets', 'app.js'), 'utf-8')).toBe( + 'console.log("ok");\n', + ); + expect(existsSync(join(dir, 'web', 'test', 'test-target'))).toBe(true); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it('repairs corrupted extracted files on the next lookup', () => { + const dir = mkdtempSync(join(tmpdir(), 'kimi-web-assets-repair-')); + try { + const { manifest, source } = fakeWebAssets({ + 'index.html': '', + }); + + const webDir = getNativeWebAssetsDir({ + cacheBase: dir, + manifest, + source, + version: 'test', + }); + writeFileSync(join(webDir ?? '', 'index.html'), 'broken'); + + const repairedDir = getNativeWebAssetsDir({ + cacheBase: dir, + manifest, + source, + version: 'test', + }); + + expect(repairedDir).toBe(webDir); + expect(readFileSync(join(repairedDir ?? '', 'index.html'), 'utf-8')).toBe(''); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it('returns null when no SEA web asset source is available', () => { + expect(getNativeWebAssetsDir({ source: null })).toBeNull(); + }); +}); diff --git a/apps/kimi-code/test/scripts/native/web-assets.test.ts b/apps/kimi-code/test/scripts/native/web-assets.test.ts new file mode 100644 index 000000000..2b5bfa920 --- /dev/null +++ b/apps/kimi-code/test/scripts/native/web-assets.test.ts @@ -0,0 +1,89 @@ +import { createHash } from 'node:crypto'; +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { describe, expect, it } from 'vitest'; + +import { + collectWebAssets, + webAssetManifestKey, + WEB_ASSET_MANIFEST_VERSION, +} from '../../../scripts/native/web-assets.mjs'; + +function sha256(bytes: Buffer | string): string { + return createHash('sha256').update(bytes).digest('hex'); +} + +describe('collectWebAssets', () => { + it('collects dist-web files into deterministic SEA asset keys', async () => { + const appRoot = mkdtempSync(join(tmpdir(), 'kimi-web-assets-build-')); + try { + mkdirSync(join(appRoot, 'dist-web', 'assets'), { recursive: true }); + writeFileSync(join(appRoot, 'dist-web', 'index.html'), '
\n'); + writeFileSync(join(appRoot, 'dist-web', 'assets', 'app.js'), 'console.log("ok");\n'); + + const { manifest, manifestJson, assets } = await collectWebAssets({ + appRoot, + target: 'test-target', + }); + + expect(webAssetManifestKey('test-target')).toBe('web/test-target/manifest.json'); + expect(manifest).toEqual({ + version: WEB_ASSET_MANIFEST_VERSION, + target: 'test-target', + root: 'dist-web', + files: [ + { + assetKey: 'web/test-target/dist-web/assets/app.js', + relativePath: 'assets/app.js', + sha256: sha256('console.log("ok");\n'), + }, + { + assetKey: 'web/test-target/dist-web/index.html', + relativePath: 'index.html', + sha256: sha256('
\n'), + }, + ], + }); + expect(JSON.parse(manifestJson) as unknown).toEqual(manifest); + expect(assets).toEqual({ + 'web/test-target/dist-web/assets/app.js': join(appRoot, 'dist-web', 'assets', 'app.js'), + 'web/test-target/dist-web/index.html': join(appRoot, 'dist-web', 'index.html'), + }); + } finally { + rmSync(appRoot, { recursive: true, force: true }); + } + }); + + it('fails clearly when dist-web has not been built', async () => { + const appRoot = mkdtempSync(join(tmpdir(), 'kimi-web-assets-missing-')); + try { + await expect(collectWebAssets({ appRoot, target: 'test-target' })).rejects.toThrow( + /Kimi web build output was not found/, + ); + } finally { + rmSync(appRoot, { recursive: true, force: true }); + } + }); + + it('keeps manifest JSON parseable and stable', async () => { + const appRoot = mkdtempSync(join(tmpdir(), 'kimi-web-assets-json-')); + try { + mkdirSync(join(appRoot, 'dist-web'), { recursive: true }); + writeFileSync(join(appRoot, 'dist-web', 'index.html'), ''); + + const { manifestJson } = await collectWebAssets({ appRoot, target: 'test-target' }); + + expect(readFileSync(join(appRoot, 'dist-web', 'index.html'), 'utf-8')).toBe(''); + expect(manifestJson.endsWith('\n')).toBe(true); + expect(JSON.parse(manifestJson)).toMatchObject({ + version: WEB_ASSET_MANIFEST_VERSION, + target: 'test-target', + root: 'dist-web', + }); + } finally { + rmSync(appRoot, { recursive: true, force: true }); + } + }); +}); diff --git a/apps/kimi-code/test/tui/kimi-tui-startup.test.ts b/apps/kimi-code/test/tui/kimi-tui-startup.test.ts index 8bc6f6fa5..696024480 100644 --- a/apps/kimi-code/test/tui/kimi-tui-startup.test.ts +++ b/apps/kimi-code/test/tui/kimi-tui-startup.test.ts @@ -1102,7 +1102,27 @@ describe('KimiTUI startup', () => { expect(write).toHaveBeenCalledWith(DISABLE_TERMINAL_THEME_REPORTING); }); - it('starts TUI without a session when fresh startup needs OAuth login', async () => { + it("only shows provider refresh status for added models", async () => { + const harness = makeHarness(); + const driver = makeDriver(harness, makeStartupInput()); + const showStatus = vi.spyOn(driver as any, "showStatus").mockImplementation(() => {}); + vi.spyOn((driver as any).authFlow, "refreshProviderModels").mockResolvedValue({ + changed: [ + { providerId: "new-models", providerName: "New Models", added: 2, removed: 0 }, + { providerId: "removed-models", providerName: "Removed Models", added: 0, removed: 3 }, + { providerId: "metadata-only", providerName: "Metadata Only", added: 0, removed: 0 }, + ], + unchanged: [], + failed: [], + }); + + await (driver as any).refreshProviderModelsInBackground(); + + expect(showStatus).toHaveBeenCalledTimes(1); + expect(showStatus).toHaveBeenCalledWith("New Models · +2 models."); + }); + + it("starts TUI without a session when fresh startup needs OAuth login", async () => { const harness = makeHarness(makeSession(), { createSession: vi.fn(async () => { throw loginRequiredError(); diff --git a/apps/kimi-code/tsconfig.dev.json b/apps/kimi-code/tsconfig.dev.json new file mode 100644 index 000000000..9e4df279a --- /dev/null +++ b/apps/kimi-code/tsconfig.dev.json @@ -0,0 +1,14 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "experimentalDecorators": true + }, + "include": [ + "src", + "test", + "../../packages/*/src/**/*.ts", + "../../packages/*/src/**/*.tsx", + "../../packages/*/test/**/*.ts", + "../../packages/agent-core/src/prompt-modules.d.ts" + ] +} diff --git a/apps/kimi-code/tsconfig.json b/apps/kimi-code/tsconfig.json index ea6828176..10388dd08 100644 --- a/apps/kimi-code/tsconfig.json +++ b/apps/kimi-code/tsconfig.json @@ -2,6 +2,7 @@ "extends": "../../tsconfig.json", "compilerOptions": { "allowJs": true, + "experimentalDecorators": true, "paths": { "@/*": ["./src/*"] } diff --git a/apps/kimi-code/tsdown.config.ts b/apps/kimi-code/tsdown.config.ts index 858aeeb48..de7110cdf 100644 --- a/apps/kimi-code/tsdown.config.ts +++ b/apps/kimi-code/tsdown.config.ts @@ -31,7 +31,14 @@ export default defineConfig({ [BUILT_IN_CATALOG_DEFINE]: builtInCatalogDefine(), }, deps: { - onlyBundle: false, + alwaysBundle: [/^@moonshot-ai\//], + // node-pty is a native addon: its `pty.node` binary cannot be bundled and + // must resolve from node_modules at runtime. Keep it external (even though + // its importer @moonshot-ai/agent-core is force-bundled above) and declare it + // as a runtime dependency of this package so npm/npx installs it with its + // prebuilt binary. Bundling it leaves the binary unresolvable → the terminal + // PTY fails with "Failed to load native module: pty.node". + neverBundle: ['node-pty'], }, outputOptions: { codeSplitting: false, diff --git a/apps/kimi-web/AGENTS.md b/apps/kimi-web/AGENTS.md new file mode 100644 index 000000000..cc1396d99 --- /dev/null +++ b/apps/kimi-web/AGENTS.md @@ -0,0 +1,51 @@ +# kimi-web Agent Guide + +Package-local rules for `apps/kimi-web` (`@moonshot-ai/kimi-web`). + +## What it is + +The browser web UI for Kimi Code — a peer to the TUI in `apps/kimi-code`. It talks to the local server over REST + WebSocket under `/api/v1`. Stack: Vue 3 + Vite 6 + TypeScript (strict) + Tailwind v4 + vue-i18n v11. There is no client router and no Pinia; state lives in composables/refs and provide/inject. + +## Layout (`src/`) + +- `main.ts` — bootstrap (creates the app, installs i18n, mounts `#app`). `App.vue` — root component, holds most app state. +- `api/` — server client. `index.ts` exposes the `getKimiWebApi()` singleton; `config.ts` builds REST/WS URLs; `daemon/` holds the wire client (`http.ts`, `ws.ts`, `wire.ts`, `mappers.ts`, `agentEventProjector.ts`, `eventReducer.ts`). +- `components/` — ~50 flat SFCs, no subdirectories. +- `composables/` — reusable state logic, `useX` naming (`useKimiWebClient`, `useIsDark`, `usePaneLayout`, …). +- `lib/` — pure helpers (`parseDiff`, `slashCommands`, `sessionRoute`, `toolMeta`, …). +- `i18n/` — vue-i18n setup plus locale namespaces. +- `debug/` — `DebugPanel.vue` and `trace.ts` for client error/trace capture. + +## Vue conventions (normative) + +- SFCs use **` + Kimi Code Web + + +
+ + + diff --git a/apps/kimi-web/package.json b/apps/kimi-web/package.json new file mode 100644 index 000000000..c74ea61ee --- /dev/null +++ b/apps/kimi-web/package.json @@ -0,0 +1,37 @@ +{ + "name": "@moonshot-ai/kimi-web", + "version": "0.1.1", + "private": true, + "license": "MIT", + "type": "module", + "scripts": { + "dev": "vite", + "dev:stub": "node dev/stub-daemon.mjs", + "build": "vite build", + "typecheck": "vue-tsc --noEmit", + "test": "vitest run" + }, + "dependencies": { + "@fontsource-variable/inter": "^5.2.8", + "@fontsource-variable/jetbrains-mono": "^5.2.8", + "@xterm/addon-fit": "^0.11.0", + "@xterm/xterm": "^6.0.0", + "markstream-vue": "1.0.1-beta.5", + "shiki": "^4.2.0", + "stream-markdown": "^0.0.15", + "vue": "^3.5.35", + "vue-i18n": "^11.4.5" + }, + "devDependencies": { + "@tailwindcss/vite": "^4.1.4", + "@vitejs/plugin-vue": "^5.2.4", + "@vue/test-utils": "^2.4.6", + "jsdom": "^25.0.1", + "tailwindcss": "^4.1.4", + "typescript": "6.0.2", + "vite": "^6.3.3", + "vitest": "4.1.4", + "vue-tsc": "~3.2.0", + "ws": "^8.18.0" + } +} diff --git a/apps/kimi-web/public/favicon.ico b/apps/kimi-web/public/favicon.ico new file mode 100644 index 000000000..9b4870b72 Binary files /dev/null and b/apps/kimi-web/public/favicon.ico differ diff --git a/apps/kimi-web/src/App.vue b/apps/kimi-web/src/App.vue new file mode 100644 index 000000000..85b0a95ce --- /dev/null +++ b/apps/kimi-web/src/App.vue @@ -0,0 +1,1437 @@ + + + + + + + + diff --git a/apps/kimi-web/src/api/config.ts b/apps/kimi-web/src/api/config.ts new file mode 100644 index 000000000..1051613b1 --- /dev/null +++ b/apps/kimi-web/src/api/config.ts @@ -0,0 +1,100 @@ +// apps/kimi-web/src/api/config.ts +// Reads Vite env, builds REST/WS URLs, manages stable clientId. + +const CLIENT_ID_KEY = 'kimi-web.client-id'; +const WEB_CLIENT_NAME = 'kimi-code-web'; +const WEB_CLIENT_UI_MODE = 'web'; + +export interface KimiApiConfig { + serverHttpUrl: string; + clientId: string; + clientName: string; + clientVersion: string; + clientUiMode: string; +} + +export function readKimiApiConfig(): KimiApiConfig { + return { + serverHttpUrl: normalizeServerOrigin(import.meta.env.VITE_KIMI_SERVER_HTTP_URL), + clientId: getClientId(), + clientName: WEB_CLIENT_NAME, + clientVersion: webClientVersion(), + clientUiMode: WEB_CLIENT_UI_MODE, + }; +} + +// Default to SAME-ORIGIN so we never depend on CORS: +// - dev: the SPA is served by Vite; the Vite dev proxy forwards /v1, /healthz +// and /v1/ws to the server (see vite.config.ts), so the browser only ever +// talks to its own origin. +// - prod: `kimi web` serves this built SPA from the server itself, so the +// server's origin already is the API origin. +// Set VITE_KIMI_SERVER_HTTP_URL to connect directly to an absolute server +// origin instead (that path does require the server to send CORS headers). +function defaultServerOrigin(): string { + if (typeof window !== 'undefined' && window.location?.origin) { + return window.location.origin; + } + return 'http://127.0.0.1:58627'; +} + +export function normalizeServerOrigin(value: string | undefined): string { + const raw = value && value.trim() ? value : defaultServerOrigin(); + const url = new URL(raw); + url.pathname = url.pathname.replace(/\/v1\/?$/, '').replace(/\/$/, ''); + url.search = ''; + url.hash = ''; + return url.toString().replace(/\/$/, ''); +} + +/** Strip the scheme for a compact display origin: `http://127.0.0.1:58627` → `127.0.0.1:58627`. */ +function shortOrigin(origin: string): string { + return origin.replace(/^https?:\/\//, '').replace(/\/$/, ''); +} + +/** + * Address of the REAL server the client is connected to, shown in the status bar. + * Always the actual server — never the dev-proxy URL — since that's the thing + * worth knowing at a glance. Cases: + * - VITE_KIMI_SERVER_HTTP_URL set → that absolute server origin (direct mode). + * - dev (same-origin proxy) → the proxy's upstream target (the real server). + * - prod (server serves the SPA) → the page origin (it IS the server). + */ +export function serverEndpointLabel(): string { + const direct = import.meta.env.VITE_KIMI_SERVER_HTTP_URL; + if (direct && direct.trim()) return shortOrigin(normalizeServerOrigin(direct)); + + const proxy = + typeof __KIMI_DEV_PROXY_TARGET__ !== 'undefined' ? __KIMI_DEV_PROXY_TARGET__ : ''; + if (import.meta.env.DEV && proxy) return shortOrigin(proxy); + + const origin = + typeof window !== 'undefined' && window.location?.origin ? window.location.origin : ''; + return shortOrigin(origin); +} + +// The real server serves everything (incl. healthz + ws) under the /api/v1 prefix. +export function buildRestUrl(origin: string, path: string): string { + return `${origin}/api/v1${path.startsWith('/') ? path : `/${path}`}`; +} + +export function buildWsUrl(origin: string, clientId: string): string { + const url = new URL(`${origin}/api/v1/ws`); + url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:'; + url.searchParams.set('client_id', clientId); + return url.toString(); +} + +function getClientId(): string { + const stored = globalThis.localStorage?.getItem(CLIENT_ID_KEY); + if (stored) return stored; + const generated = `web_${globalThis.crypto?.randomUUID?.() || Math.random().toString(36).slice(2)}`; + globalThis.localStorage?.setItem(CLIENT_ID_KEY, generated); + return generated; +} + +function webClientVersion(): string { + return typeof __KIMI_WEB_VERSION__ === 'string' && __KIMI_WEB_VERSION__.trim() + ? __KIMI_WEB_VERSION__ + : '0.0.0-dev'; +} diff --git a/apps/kimi-web/src/api/daemon/agentEventProjector.ts b/apps/kimi-web/src/api/daemon/agentEventProjector.ts new file mode 100644 index 000000000..496a39355 --- /dev/null +++ b/apps/kimi-web/src/api/daemon/agentEventProjector.ts @@ -0,0 +1,1355 @@ +// apps/kimi-web/src/api/daemon/agentEventProjector.ts +// +// Client-side projector: raw agent-core WS events → AppEvent[] +// +// The real daemon pushes raw agent-core events (NOT the projected "event.*" +// protocol events). This projector translates them into the same AppEvent union +// that the existing reducer (eventReducer.ts) consumes. +// +// Ported from the daemon-side reference implementation: +// apps/kimi-daemon/src/session/event-projector.ts +// apps/kimi-daemon/src/session/message-log.ts +// apps/kimi-daemon/src/session/usage-tracker.ts +// +// Usage: +// const projector = createAgentProjector(); +// const appEvents = projector.project(rawType, payload, sessionId); +// // call reset() when re-subscribing / resyncing a session + +import type { + AppEvent, + AppGoal, + AppInFlightTurn, + AppMessage, + AppMessageContent, + AppSessionUsage, + AppTask, +} from '../types'; +import { i18n } from '../../i18n'; +import { toAppMessageContent } from './mappers'; +import type { WireMessageContent } from './wire'; + +// Subagent turns share the parent session id: their turn / step / delta / tool +// frames stream over the SAME session channel, each tagged with the subagent's +// own agentId (the main agent's is 'main'). They must NOT be folded into the +// parent transcript — doing so created empty "skeleton" assistant bubbles (a +// subagent turn.step.started opens a parent assistant message that never gets +// the main agent's text) and fragmented snippets (subagent deltas appended to +// the parent). The subagent's progress is surfaced separately via the +// subagent.* → task → AgentCard path. This mirrors the server's +// InFlightTurnTracker, which likewise tracks only main-agent activity. +const MAIN_AGENT_ID = 'main'; +const MAIN_AGENT_TRANSCRIPT_FRAMES = new Set([ + 'turn.started', + 'turn.step.started', + 'turn.step.completed', + 'turn.step.retrying', + 'turn.step.interrupted', + 'turn.ended', + 'thinking.delta', + 'assistant.delta', + 'tool.use', + 'tool.call.started', + 'tool.call.delta', + 'tool.progress', + 'tool.result', + 'agent.status.updated', + 'prompt.completed', +]); + +// --------------------------------------------------------------------------- +// Internal helpers +// --------------------------------------------------------------------------- + +function ulid(prefix = 'msg_'): string { + const t = Date.now().toString(36).padStart(10, '0'); + const r = Math.random().toString(36).slice(2, 12).padEnd(10, '0'); + return `${prefix}${t}${r}`; +} + +/** Normalise the raw token usage shape emitted by agent-core. */ +function normalizeUsage(raw: unknown): { + input: number; + output: number; + cacheRead: number; + cacheCreate: number; +} { + if (!raw || typeof raw !== 'object') { + return { input: 0, output: 0, cacheRead: 0, cacheCreate: 0 }; + } + const u = raw as Record; + return { + input: u['inputOther'] ?? u['input_tokens'] ?? 0, + output: u['output'] ?? u['output_tokens'] ?? 0, + cacheRead: u['inputCacheRead'] ?? u['cache_read_input_tokens'] ?? 0, + cacheCreate: u['inputCacheCreation'] ?? u['cache_creation_input_tokens'] ?? 0, + }; +} + +// --------------------------------------------------------------------------- +// Per-session projector state +// --------------------------------------------------------------------------- + +interface SessionState { + // Turn ID → promptId binding + turnPromptId: Map; + currentPromptId: string | undefined; + + // Assistant message tracking + currentAssistantMsgId: string | undefined; + + // Per-turn accumulated stream lengths — aligned against the wire `offset` + // on volatile delta frames (v2 sync protocol) to skip duplicates and + // detect gaps after a snapshot seed. + turnTextLen: number; + turnThinkLen: number; + + // Tool timing + toolStartTimes: Map; + + // Usage accumulator + totalInput: number; + totalOutput: number; + totalCacheRead: number; + totalCacheCreate: number; + contextTokens: number; + contextLimit: number; + turnCount: number; + model: string; + + // In-memory message log (mirrors daemon message-log.ts) + messages: AppMessage[]; + + // Subagent lifecycle deltas after spawned only carry subagentId. Keep the + // spawned metadata here so later updates can replace the full AppTask. + subagentMeta: Map; +} + +function createSessionState(): SessionState { + return { + turnPromptId: new Map(), + currentPromptId: undefined, + currentAssistantMsgId: undefined, + turnTextLen: 0, + turnThinkLen: 0, + toolStartTimes: new Map(), + totalInput: 0, + totalOutput: 0, + totalCacheRead: 0, + totalCacheCreate: 0, + contextTokens: 0, + contextLimit: 0, + turnCount: 0, + model: '', + messages: [], + subagentMeta: new Map(), + }; +} + +function stringField(source: Record, key: string): string | undefined { + const value = source[key]; + return typeof value === 'string' ? value : undefined; +} + +function numberField(source: Record, key: string): number | undefined { + const value = source[key]; + return typeof value === 'number' && Number.isFinite(value) ? value : undefined; +} + +function nullableNumberField(source: Record, key: string): number | null { + const value = source[key]; + return typeof value === 'number' && Number.isFinite(value) ? value : null; +} + +function mapGoalSnapshot(snapshot: unknown): AppGoal | null { + if (!snapshot || typeof snapshot !== 'object') return null; + const s = snapshot as Record; + const budgetRaw = s['budget']; + const budget = budgetRaw && typeof budgetRaw === 'object' ? budgetRaw as Record : {}; + const status = stringField(s, 'status'); + if (status !== 'active' && status !== 'paused' && status !== 'blocked' && status !== 'complete') return null; + const goalId = stringField(s, 'goalId') ?? stringField(s, 'goal_id') ?? 'goal'; + const objective = stringField(s, 'objective') ?? ''; + return { + goalId, + objective, + completionCriterion: stringField(s, 'completionCriterion') ?? stringField(s, 'completion_criterion'), + status, + turnsUsed: numberField(s, 'turnsUsed') ?? numberField(s, 'turns_used') ?? 0, + tokensUsed: numberField(s, 'tokensUsed') ?? numberField(s, 'tokens_used') ?? 0, + wallClockMs: numberField(s, 'wallClockMs') ?? numberField(s, 'wall_clock_ms') ?? 0, + terminalReason: stringField(s, 'terminalReason') ?? stringField(s, 'terminal_reason'), + budget: { + tokenBudget: nullableNumberField(budget, 'tokenBudget') ?? nullableNumberField(budget, 'token_budget'), + remainingTokens: nullableNumberField(budget, 'remainingTokens') ?? nullableNumberField(budget, 'remaining_tokens'), + turnBudget: nullableNumberField(budget, 'turnBudget') ?? nullableNumberField(budget, 'turn_budget'), + remainingTurns: nullableNumberField(budget, 'remainingTurns') ?? nullableNumberField(budget, 'remaining_turns'), + wallClockBudgetMs: nullableNumberField(budget, 'wallClockBudgetMs') ?? nullableNumberField(budget, 'wall_clock_budget_ms'), + remainingWallClockMs: nullableNumberField(budget, 'remainingWallClockMs') ?? nullableNumberField(budget, 'remaining_wall_clock_ms'), + overBudget: budget['overBudget'] === true || budget['over_budget'] === true, + }, + }; +} + +function patchSubagent( + state: SessionState, + sessionId: string, + subagentId: unknown, + patch: Partial, +): AppTask | null { + if (typeof subagentId !== 'string' || subagentId.length === 0) return null; + const prev = state.subagentMeta.get(subagentId) ?? { + id: subagentId, + sessionId, + kind: 'subagent', + description: 'Sub Agent', + status: 'running', + createdAt: new Date().toISOString(), + subagentPhase: 'queued', + } satisfies AppTask; + const next: AppTask = { ...prev, ...patch, id: subagentId, sessionId, kind: 'subagent' }; + state.subagentMeta.set(subagentId, next); + return next; +} + +function shortJson(value: unknown): string { + if (value === undefined || value === null) return ''; + try { + const text = typeof value === 'string' ? value : JSON.stringify(value); + return text.length > 120 ? `${text.slice(0, 117)}...` : text; + } catch { + return ''; + } +} + +function subagentProgressText(rawType: string, payload: Record): string | null { + if (rawType === 'turn.step.started') return 'Started a step'; + if (rawType === 'tool.use' || rawType === 'tool.call.started') { + const name = stringField(payload, 'name') ?? stringField(payload, 'toolName') ?? 'tool'; + const args = shortJson(payload['args'] ?? payload['input']); + return args ? `Calling ${name}: ${args}` : `Calling ${name}`; + } + if (rawType === 'tool.progress') { + const update = payload['update']; + if (update && typeof update === 'object') { + const text = stringField(update as Record, 'text'); + if (text) return text; + const message = stringField(update as Record, 'message'); + if (message) return message; + } + const message = stringField(payload, 'message'); + if (message) return message; + } + if (rawType === 'tool.result') { + const name = stringField(payload, 'name') ?? stringField(payload, 'toolName') ?? stringField(payload, 'toolCallId') ?? 'tool'; + return `Finished ${name}`; + } + return null; +} + +function projectSubagentProgress( + state: SessionState, + sessionId: string, + subagentId: string, + rawType: string, + payload: Record, + sideChannelAgents: ReadonlySet, +): AppEvent[] { + // Side-channel agents (e.g. BTW side chat) stream their own transcript via + // agentDelta events; don't pollute the main task output with generic step + // placeholders like "Started a step". + if (sideChannelAgents.has(subagentId) && rawType === 'turn.step.started') return []; + const text = subagentProgressText(rawType, payload); + if (text === null || text.length === 0) return []; + const previous = state.subagentMeta.get(subagentId); + const task = patchSubagent(state, sessionId, subagentId, { + status: 'running', + subagentPhase: 'working', + startedAt: previous?.startedAt ?? new Date().toISOString(), + }); + const out: AppEvent[] = []; + if (task) out.push({ type: 'taskCreated', sessionId, task }); + out.push({ type: 'taskProgress', sessionId, taskId: subagentId, outputChunk: text, stream: 'stdout' }); + return out; +} + +// --------------------------------------------------------------------------- +// Message-log helpers (inlined; mirrors message-log.ts) +// --------------------------------------------------------------------------- + +/** + * Decouple an emitted message from the projector's internal log. The reducer + * stores emitted messages by reference; the projector keeps mutating its own + * copy in place (`slot.text += delta`), so sharing the content objects makes + * the reducer's delta-append run on already-appended text — the first streamed + * chunk of every text/thinking block rendered twice. + */ +function cloneMessage(msg: AppMessage): AppMessage { + return { ...msg, content: msg.content.map((c) => ({ ...c })) }; +} + +function startAssistantMessage(state: SessionState, sessionId: string, promptId: string): AppMessage { + const msg: AppMessage = { + id: ulid('msg_'), + sessionId, + role: 'assistant', + content: [], + createdAt: new Date().toISOString(), + promptId, + }; + state.messages.push(msg); + return msg; +} + +function startUserMessage( + state: SessionState, + sessionId: string, + promptId: string, + userMessageId: string, + content: AppMessageContent[], + createdAt: string, +): AppMessage { + const msg: AppMessage = { + id: userMessageId, + sessionId, + role: 'user', + content, + createdAt, + promptId, + }; + state.messages.push(msg); + return msg; +} + +function toAppPromptContent(raw: unknown): AppMessageContent[] { + if (!Array.isArray(raw)) return []; + return raw.map((part) => toAppMessageContent(part as WireMessageContent)); +} + +/** + * Append a streamed text/thinking delta in stream order: continue the LAST + * content part when it has the same type, otherwise open a NEW part at the + * end. Returns the content index written (-1 if the message is unknown) so + * the emitted assistantDelta targets the same slot in the reducer. + * + * No per-type fixed slots: a step that goes think → text → think again gets + * three parts in call order instead of all thinking collapsing into one slot. + */ +function appendAssistantDelta( + state: SessionState, + messageId: string, + kind: 'text' | 'thinking', + delta: string, +): number { + const msg = state.messages.find((m) => m.id === messageId); + if (!msg) return -1; + const last = msg.content.at(-1); + if (last && last.type === kind) { + if (kind === 'text') (last as { type: 'text'; text: string }).text += delta; + else (last as { type: 'thinking'; thinking: string }).thinking += delta; + return msg.content.length - 1; + } + msg.content.push(kind === 'text' ? { type: 'text', text: delta } : { type: 'thinking', thinking: delta }); + return msg.content.length - 1; +} + +function appendToolUse( + state: SessionState, + messageId: string, + toolCallId: string, + toolName: string, + input: unknown, + outputLines?: string[], +): void { + const msg = state.messages.find((m) => m.id === messageId); + if (!msg) return; + msg.content.push({ type: 'toolUse', toolCallId, toolName, input, outputLines }); +} + +function toolProgressOutput(payload: Record): { outputChunk: string; stream: 'stdout' | 'stderr' } | null { + const update = payload['update']; + const updateRecord = update && typeof update === 'object' ? update as Record : null; + const streamRaw = updateRecord?.['stream'] ?? updateRecord?.['kind'] ?? payload['stream']; + const stream = streamRaw === 'stderr' ? 'stderr' : 'stdout'; + const chunk = + (typeof updateRecord?.['text'] === 'string' && updateRecord['text']) || + (typeof updateRecord?.['message'] === 'string' && updateRecord['message']) || + (typeof payload['chunk'] === 'string' && payload['chunk']) || + (typeof payload['output'] === 'string' && payload['output']) || + (typeof payload['message'] === 'string' && payload['message']) || + ''; + return chunk.length > 0 ? { outputChunk: chunk, stream } : null; +} + +function finishAssistantMessage(state: SessionState, messageId: string): void { + const msg = state.messages.find((m) => m.id === messageId); + // We record nothing extra here — status is implicit in the downstream reducer + void msg; +} + +function appendToolResultMessage( + state: SessionState, + sessionId: string, + toolCallId: string, + output: unknown, + isError: boolean, + promptId: string, +): AppMessage { + const msg: AppMessage = { + id: ulid('msg_'), + sessionId, + role: 'tool', + content: [{ type: 'toolResult', toolCallId, output, isError }], + createdAt: new Date().toISOString(), + promptId, + }; + state.messages.push(msg); + return msg; +} + +function getMsgById(state: SessionState, messageId: string): AppMessage | undefined { + return state.messages.find((m) => m.id === messageId); +} + +// --------------------------------------------------------------------------- +// Usage snapshot builder +// --------------------------------------------------------------------------- + +function buildUsageSnapshot(state: SessionState): AppSessionUsage { + return { + inputTokens: state.totalInput, + outputTokens: state.totalOutput, + cacheReadTokens: state.totalCacheRead, + cacheCreationTokens: state.totalCacheCreate, + totalCostUsd: 0, + contextTokens: state.contextTokens, + contextLimit: state.contextLimit, + turnCount: state.turnCount, + }; +} + +// --------------------------------------------------------------------------- +// AgentProjector +// --------------------------------------------------------------------------- + +export interface ProjectMeta { + /** + * Wire-level pre-append stream offset on volatile text-delta frames (v2 + * sync protocol). Used to skip duplicate deltas and detect gaps after a + * snapshot seed. + */ + offset?: number; +} + +export interface AgentProjector { + /** Project a single raw agent-core event into zero or more AppEvents. Never throws. */ + project(rawType: string, payload: unknown, sessionId: string, meta?: ProjectMeta): AppEvent[]; + /** + * Bind an externally-known promptId to the next turn.startd for this session. + * Call this right after submitPrompt() returns, before the first turn.started arrives. + */ + bindNextPromptId(sessionId: string, promptId: string): void; + /** + * Seed mid-turn state from a session snapshot's `in_flight_turn` (v2 sync): + * resets per-session state, builds the partially-streamed assistant message + * (thinking + text + running tool_use parts), and returns the AppEvents + * (sessionStatusChanged + messageCreated) to apply to the reducer. Live + * deltas continue appending; their wire `offset` aligns against the seeded + * text so the overlap window around snapshot/subscribe is exact. + */ + seedInFlight(sessionId: string, turn: AppInFlightTurn): AppEvent[]; + /** Reset all per-session state (call on re-subscribe / resync). */ + reset(sessionId: string): void; + /** + * Mark an agent id as a side-channel (e.g. BTW side chat) rather than a + * background subagent. Its text/thinking deltas and turn boundary are then + * emitted as agent-scoped events instead of being dropped. + */ + markSideChannelAgent(agentId: string): void; +} + +export function createAgentProjector(): AgentProjector { + const sessions = new Map(); + const sideChannelAgents = new Set(); + + function getOrCreate(sessionId: string): SessionState { + let s = sessions.get(sessionId); + if (!s) { + s = createSessionState(); + sessions.set(sessionId, s); + } + return s; + } + + function reset(sessionId: string): void { + sessions.set(sessionId, createSessionState()); + } + + function markSideChannelAgent(agentId: string): void { + sideChannelAgents.add(agentId); + } + + function bindNextPromptId(sessionId: string, promptId: string): void { + const s = getOrCreate(sessionId); + s.currentPromptId = promptId; + } + + function seedInFlight(sessionId: string, turn: AppInFlightTurn): AppEvent[] { + reset(sessionId); + const s = getOrCreate(sessionId); + + const promptId = turn.promptId ?? ulid('pr_'); + s.currentPromptId = promptId; + s.turnPromptId.set(turn.turnId, promptId); + + const msg = startAssistantMessage(s, sessionId, promptId); + if (turn.thinkingText.length > 0) { + msg.content.push({ type: 'thinking', thinking: turn.thinkingText }); + } + if (turn.assistantText.length > 0) { + msg.content.push({ type: 'text', text: turn.assistantText }); + } + for (const tool of turn.runningTools) { + const outputLines = + typeof tool.lastProgress?.text === 'string' && tool.lastProgress.text.length > 0 + ? [tool.lastProgress.text] + : undefined; + msg.content.push({ + type: 'toolUse', + toolCallId: tool.toolCallId, + toolName: tool.name, + input: tool.args ?? {}, + outputLines, + }); + s.toolStartTimes.set(tool.toolCallId, Date.now()); + } + s.currentAssistantMsgId = msg.id; + s.turnTextLen = turn.assistantText.length; + s.turnThinkLen = turn.thinkingText.length; + + return [ + { + type: 'sessionStatusChanged', + sessionId, + status: 'running', + previousStatus: 'idle', + currentPromptId: promptId, + }, + { type: 'messageCreated', message: cloneMessage(msg) }, + ]; + } + + function project( + rawType: string, + payload: unknown, + sessionId: string, + meta?: ProjectMeta, + ): AppEvent[] { + try { + return _project(rawType, payload, sessionId, meta); + } catch (error) { + // Defensive: log but never crash the caller + console.error('[agentProjector] Error projecting event:', rawType, error instanceof Error ? error.message : error); + return []; + } + } + + /** + * Align a live text-delta against the per-turn accumulated length using the + * wire `offset`. Returns 'skip' for duplicates (offset behind local state), + * 'gap' when deltas were missed (offset ahead — trigger a re-snapshot), and + * 'append' otherwise. + */ + function alignDelta(localLen: number, offset: number | undefined): 'append' | 'skip' | 'gap' { + if (offset === undefined) return 'append'; + if (offset < localLen) return 'skip'; + if (offset > localLen) return 'gap'; + return 'append'; + } + + function _project( + rawType: string, + payload: unknown, + sessionId: string, + meta?: ProjectMeta, + ): AppEvent[] { + const s = getOrCreate(sessionId); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const p = payload as any; + const out: AppEvent[] = []; + + // Drop subagent-scoped transcript frames (see MAIN_AGENT_TRANSCRIPT_FRAMES). + // A subagent carries its own agentId; only the main agent's stream builds the + // visible transcript. Lifecycle frames (subagent.*, goal.*, background.*) are + // intentionally NOT in the set — they describe the subagent for the task view + // and must always be projected. + const frameAgentId: unknown = p?.agentId; + if (typeof frameAgentId === 'string' && frameAgentId !== MAIN_AGENT_ID) { + const isSideChannel = sideChannelAgents.has(frameAgentId); + // Side-channel agents (e.g. BTW side chat) stream text/thinking deltas and + // a turn boundary over the parent session channel. Route them to the web + // layer as agent-scoped events instead of dropping them or folding them + // into the parent transcript. + if (isSideChannel && (rawType === 'thinking.delta' || rawType === 'assistant.delta')) { + const deltaText: string = p?.delta ?? ''; + if (!deltaText) return []; + return [ + { + type: 'agentDelta' as const, + sessionId, + agentId: frameAgentId, + delta: { [rawType === 'thinking.delta' ? ('thinking' as const) : ('text' as const)]: deltaText }, + }, + ]; + } + if (isSideChannel && rawType === 'turn.ended') { + return [ + { type: 'agentTurnEnded' as const, sessionId, agentId: frameAgentId, reason: p?.reason }, + ]; + } + if (MAIN_AGENT_TRANSCRIPT_FRAMES.has(rawType)) { + return projectSubagentProgress(s, sessionId, frameAgentId, rawType, p ?? {}, sideChannelAgents); + } + } + + switch (rawType) { + // ----------------------------------------------------------------------- + case 'session.meta.updated': { + // The daemon auto-generates a title from the first prompt (and other + // clients can rename a session). It announces both via this event. We + // don't have the full AppSession here, so emit a lightweight + // sessionMetaUpdated that patches only the title field. + const title: string | undefined = p?.patch?.title ?? p?.title; + if (typeof title === 'string' && title.length > 0) { + out.push({ type: 'sessionMetaUpdated', sessionId, title }); + } + break; + } + + // ----------------------------------------------------------------------- + case 'prompt.submitted': { + const promptId: string | undefined = p?.promptId; + const userMessageId: string | undefined = p?.userMessageId; + if (!promptId || !userMessageId) break; + const content = toAppPromptContent(p?.content); + if (content.length === 0) break; + s.currentPromptId = promptId; + const msg = startUserMessage( + s, + sessionId, + promptId, + userMessageId, + content, + typeof p?.createdAt === 'string' ? p.createdAt : new Date().toISOString(), + ); + out.push({ type: 'messageCreated', message: cloneMessage(msg) }); + break; + } + + // ----------------------------------------------------------------------- + case 'turn.started': { + // Bind turnId → promptId. Generate a synthetic one if none was pre-bound. + const turnId: number = p?.turnId; + const existingPromptId = s.currentPromptId ?? ulid('pr_'); + s.currentPromptId = existingPromptId; + if (turnId !== undefined) { + s.turnPromptId.set(turnId, existingPromptId); + } + // Fresh turn → fresh per-turn stream offsets. + s.turnTextLen = 0; + s.turnThinkLen = 0; + + out.push({ + type: 'sessionStatusChanged', + sessionId, + status: 'running', + previousStatus: 'idle', + currentPromptId: existingPromptId, + }); + break; + } + + // ----------------------------------------------------------------------- + case 'turn.step.started': { + const turnId: number = p?.turnId; + let promptId = s.turnPromptId.get(turnId) ?? s.currentPromptId; + if (!promptId) { + // Joined mid-turn (reconnect/resync wiped the binding): synthesize a + // promptId like turn.started does, so the REST of the turn still + // renders instead of every following event being dropped. + promptId = ulid('pr_'); + s.currentPromptId = promptId; + if (turnId !== undefined) s.turnPromptId.set(turnId, promptId); + } + + // Create a new pending assistant message + const msg = startAssistantMessage(s, sessionId, promptId); + s.currentAssistantMsgId = msg.id; + + out.push({ type: 'messageCreated', message: cloneMessage(msg) }); + break; + } + + // ----------------------------------------------------------------------- + case 'thinking.delta': { + const msgId = s.currentAssistantMsgId; + if (!msgId) break; + const delta: string = p?.delta ?? ''; + if (!delta) break; + + // Same missed-turn-boundary self-heal as assistant.delta (see there). + if (meta?.offset === 0 && s.turnThinkLen > 0) { + s.turnThinkLen = 0; + } + + const align = alignDelta(s.turnThinkLen, meta?.offset); + if (align === 'skip') break; + if (align === 'gap') { + out.push({ type: 'historyCompacted', sessionId, beforeSeq: 0, reason: 'delta_gap' }); + break; + } + + const thinkIdx = appendAssistantDelta(s, msgId, 'thinking', delta); + if (thinkIdx < 0) break; + s.turnThinkLen += delta.length; + out.push({ + type: 'assistantDelta', + sessionId, + messageId: msgId, + contentIndex: thinkIdx, + delta: { thinking: delta }, + }); + break; + } + + // ----------------------------------------------------------------------- + case 'assistant.delta': { + const msgId = s.currentAssistantMsgId; + if (!msgId) break; + const delta: string = p?.delta ?? ''; + if (!delta) break; + + // Self-heal a missed turn boundary: a pre-append offset of 0 while we + // still believe we are mid-stream means the daemon began a fresh + // assistant stream (new turn / retry) whose turn.started we never saw — + // e.g. the durable replay and the live volatile deltas raced on the + // cursor after a reconnect. Without this reset every delta has + // offset < turnTextLen and is SILENTLY skipped forever (skip, unlike + // gap, never recovers), so streaming dies until a full page reload. + if (meta?.offset === 0 && s.turnTextLen > 0) { + s.turnTextLen = 0; + } + + const align = alignDelta(s.turnTextLen, meta?.offset); + if (align === 'skip') break; + if (align === 'gap') { + // Deltas were missed in the snapshot↔subscribe window — the only + // exact recovery is a fresh snapshot. historyCompacted is routed to + // onResync by the client wrapper, which reloads via snapshot. + out.push({ type: 'historyCompacted', sessionId, beforeSeq: 0, reason: 'delta_gap' }); + break; + } + + const textIdx = appendAssistantDelta(s, msgId, 'text', delta); + if (textIdx < 0) break; + s.turnTextLen += delta.length; + out.push({ + type: 'assistantDelta', + sessionId, + messageId: msgId, + contentIndex: textIdx, + delta: { text: delta }, + }); + break; + } + + // ----------------------------------------------------------------------- + case 'tool.use': + case 'tool.call.started': { + const msgId = s.currentAssistantMsgId; + const turnId: number = p?.turnId; + const promptId = s.turnPromptId.get(turnId) ?? s.currentPromptId; + if (!msgId || !promptId) break; + + const toolCallId: string = p?.toolCallId; + // Real daemon field name is 'name' per event-projector.ts + const toolName: string = p?.name ?? p?.toolName ?? ''; + const args = p?.args ?? p?.input ?? {}; + + appendToolUse(s, msgId, toolCallId, toolName, args); + + const msg = getMsgById(s, msgId); + const contentIndex = msg ? msg.content.length - 1 : 0; + + // Record start time + s.toolStartTimes.set(toolCallId, Date.now()); + + // Emit messageUpdated so the reducer knows about the new tool-use slot + if (msg) { + out.push({ + type: 'messageUpdated', + sessionId, + messageId: msgId, + content: msg.content.map((c) => ({ ...c })), + status: 'pending', + }); + } + void contentIndex; + break; + } + + // ----------------------------------------------------------------------- + case 'tool.call.delta': { + // Input streaming — no-op for the web client (content already in tool.call.started.args) + break; + } + + // ----------------------------------------------------------------------- + case 'tool.progress': { + const toolCallId: string = p?.toolCallId; + const progress = toolProgressOutput(p ?? {}); + if (toolCallId && progress) { + out.push({ + type: 'toolOutput', + sessionId, + toolCallId, + outputChunk: progress.outputChunk, + stream: progress.stream, + }); + } + break; + } + + // ----------------------------------------------------------------------- + case 'tool.result': { + const turnId: number = p?.turnId; + let promptId = s.turnPromptId.get(turnId) ?? s.currentPromptId; + if (!promptId) { + // Same mid-turn-join fallback as turn.step.started. + promptId = ulid('pr_'); + s.currentPromptId = promptId; + if (turnId !== undefined) s.turnPromptId.set(turnId, promptId); + } + + const toolCallId: string = p?.toolCallId; + const output = p?.output; + const isError: boolean = p?.isError ?? false; + + const startTime = s.toolStartTimes.get(toolCallId) ?? Date.now(); + s.toolStartTimes.delete(toolCallId); + void (Date.now() - startTime); // duration — unused at client level + + const resultMsg = appendToolResultMessage(s, sessionId, toolCallId, output, isError, promptId); + out.push({ type: 'messageCreated', message: cloneMessage(resultMsg) }); + + // Reset assistant message tracking — next step.started will create a fresh one + s.currentAssistantMsgId = undefined; + break; + } + + // ----------------------------------------------------------------------- + case 'turn.step.completed': { + const msgId = s.currentAssistantMsgId; + + // Feed usage + const u = normalizeUsage(p?.usage); + s.totalInput += u.input; + s.totalOutput += u.output; + s.totalCacheRead += u.cacheRead; + s.totalCacheCreate += u.cacheCreate; + + if (msgId) { + finishAssistantMessage(s, msgId); + const msg = getMsgById(s, msgId); + if (msg) { + out.push({ + type: 'messageUpdated', + sessionId, + messageId: msgId, + content: msg.content.map((c) => ({ ...c })), + status: 'completed', + }); + } + } + break; + } + + // ----------------------------------------------------------------------- + case 'agent.status.updated': { + if (p?.model) s.model = p.model; + if (p?.contextTokens !== undefined) s.contextTokens = p.contextTokens; + if (p?.maxContextTokens !== undefined) s.contextLimit = p.maxContextTokens; + + out.push({ + type: 'sessionUsageUpdated', + sessionId, + usage: buildUsageSnapshot(s), + // Carry the live model so the status bar shows the real running model + // instead of falling back to the daemon's (empty) REST model. + model: s.model || undefined, + swarmMode: p?.swarmMode === true ? true : p?.swarmMode === false ? false : undefined, + // The agent reports plan mode here too (e.g. it auto-entered plan mode + // for a "make a plan" prompt). Carry it so the composer's plan toggle + // reflects the agent's real state, not just the user's manual choice. + planMode: p?.planMode === true ? true : p?.planMode === false ? false : undefined, + }); + break; + } + + // ----------------------------------------------------------------------- + case 'turn.ended': { + const msgId = s.currentAssistantMsgId; + const reason: string = p?.reason ?? 'completed'; + const durationMs = numberField(p ?? {}, 'durationMs'); + + if (msgId) { + finishAssistantMessage(s, msgId); + const msg = getMsgById(s, msgId); + if (msg) { + out.push({ + type: 'messageUpdated', + sessionId, + messageId: msgId, + content: msg.content.map((c) => ({ ...c })), + status: reason === 'failed' ? 'error' : 'completed', + durationMs, + }); + } + } + + s.turnCount++; + const usageSnapshot = buildUsageSnapshot(s); + out.push({ type: 'sessionUsageUpdated', sessionId, usage: usageSnapshot }); + + const newStatus = reason === 'cancelled' ? 'aborted' : reason === 'failed' ? 'aborted' : 'idle'; + out.push({ + type: 'sessionStatusChanged', + sessionId, + status: newStatus, + previousStatus: 'running', + }); + + // Clear per-turn state. Reset the stream offsets too so a stale length + // from this turn can't wedge the next turn's delta alignment into a + // silent skip if its turn.started is missed across a reconnect. + s.currentAssistantMsgId = undefined; + s.currentPromptId = undefined; + s.turnTextLen = 0; + s.turnThinkLen = 0; + break; + } + + // ----------------------------------------------------------------------- + case 'prompt.completed': { + // No-op at AppEvent level — turn.ended already handles the transition to idle + break; + } + + // ----------------------------------------------------------------------- + case 'turn.step.retrying': + case 'turn.step.interrupted': { + // Discard current assistant message; next step.started will create a new one + s.currentAssistantMsgId = undefined; + break; + } + + // ----------------------------------------------------------------------- + case 'subagent.spawned': { + const taskId = typeof p?.subagentId === 'string' && p.subagentId.length > 0 ? p.subagentId : ulid('task_'); + const task: AppTask = { + id: taskId, + sessionId, + kind: 'subagent', + description: typeof p?.description === 'string' ? p.description : p?.subagentName ?? 'Sub Agent', + status: 'running', + createdAt: new Date().toISOString(), + subagentPhase: 'queued', + subagentType: typeof p?.subagentName === 'string' ? p.subagentName : undefined, + parentToolCallId: typeof p?.parentToolCallId === 'string' ? p.parentToolCallId : undefined, + swarmIndex: typeof p?.swarmIndex === 'number' ? p.swarmIndex : undefined, + }; + s.subagentMeta.set(task.id, task); + out.push({ + type: 'taskCreated', + sessionId, + task, + }); + break; + } + + case 'subagent.started': { + const task = patchSubagent(s, sessionId, p?.subagentId, { + subagentPhase: 'working', + status: 'running', + startedAt: new Date().toISOString(), + }); + if (task) out.push({ type: 'taskCreated', sessionId, task }); + break; + } + + case 'subagent.suspended': { + const task = patchSubagent(s, sessionId, p?.subagentId, { + subagentPhase: 'suspended', + status: 'running', + suspendedReason: typeof p?.reason === 'string' ? p.reason : undefined, + }); + if (task) out.push({ type: 'taskCreated', sessionId, task }); + break; + } + + case 'subagent.completed': { + const outputPreview = typeof p?.resultSummary === 'string' ? p.resultSummary : undefined; + const task = patchSubagent(s, sessionId, p?.subagentId, { + subagentPhase: 'completed', + status: 'completed', + completedAt: new Date().toISOString(), + outputPreview, + }); + if (task) out.push({ type: 'taskCreated', sessionId, task }); + out.push({ + type: 'taskCompleted', + sessionId, + taskId: p?.subagentId ?? '', + status: 'completed', + outputPreview, + }); + break; + } + + case 'subagent.failed': { + const outputPreview = typeof p?.error === 'string' ? p.error : undefined; + const task = patchSubagent(s, sessionId, p?.subagentId, { + subagentPhase: 'failed', + status: 'failed', + completedAt: new Date().toISOString(), + outputPreview, + }); + if (task) out.push({ type: 'taskCreated', sessionId, task }); + out.push({ + type: 'taskCompleted', + sessionId, + taskId: p?.subagentId ?? '', + status: 'failed', + outputPreview, + }); + break; + } + + // ----------------------------------------------------------------------- + case 'error': { + // Fold into an unknown event so the reducer pushes a warning string + out.push({ + type: 'unknown', + raw: { _agentError: true, code: p?.code, message: p?.message }, + }); + break; + } + + case 'warning': { + out.push({ + type: 'unknown', + raw: { _agentWarning: true, message: p?.message }, + }); + break; + } + + // ----------------------------------------------------------------------- + // Background tasks (e.g. a backgrounded Bash command). Real daemon shape: + // payload.info = { taskId, description, status, startedAt(ms), endedAt, + // kind:'process', command, pid, exitCode }. + case 'background.task.started': { + const info = (p?.info ?? {}) as Record; + const startedAt = + typeof info.startedAt === 'number' ? new Date(info.startedAt).toISOString() : undefined; + const taskId = + typeof info.taskId === 'string' + ? info.taskId + : typeof info.taskId === 'number' + ? String(info.taskId) + : ulid('task_'); + const description = + typeof info.description === 'string' + ? info.description + : typeof info.command === 'string' + ? info.command + : i18n.global.t('tasks.defaultDescription'); + const command = typeof info.command === 'string' ? info.command : undefined; + out.push({ + type: 'taskCreated', + sessionId, + task: { + id: taskId, + sessionId, + kind: 'bash', + description, + command, + status: 'running', + createdAt: startedAt ?? new Date().toISOString(), + startedAt, + outputPreview: command !== undefined ? `$ ${command}` : undefined, + }, + }); + break; + } + case 'background.task.terminated': { + const info = (p?.info ?? {}) as Record; + const failed = + info.status === 'failed' || + (typeof info.exitCode === 'number' && info.exitCode !== 0); + out.push({ + type: 'taskCompleted', + sessionId, + taskId: + typeof info.taskId === 'string' + ? info.taskId + : typeof info.taskId === 'number' + ? String(info.taskId) + : '', + status: failed ? 'failed' : 'completed', + // Do NOT set outputPreview here. The command is already kept on the + // task as `command`; setting outputPreview to `$ ` would + // clobber any real output captured by polling and prevents the UI + // from fetching the final terminal output after the task finishes. + }); + break; + } + + // ----------------------------------------------------------------------- + case 'compaction.completed': { + // Compaction replaced a batch of old messages with a summary on the + // daemon side. The visible transcript is NOT reloaded (the client keeps + // the scrollback and the reducer appends a divider marker); the + // historyCompacted signal still fires so seq bookkeeping and any + // non-compaction consumers stay correct. + const result = (p?.result ?? {}) as Record; + out.push({ + type: 'compactionCompleted', + sessionId, + tokensBefore: typeof result.tokensBefore === 'number' ? result.tokensBefore : undefined, + tokensAfter: typeof result.tokensAfter === 'number' ? result.tokensAfter : undefined, + summary: typeof result.summary === 'string' ? result.summary : undefined, + }); + out.push({ + type: 'historyCompacted', + sessionId, + beforeSeq: 0, + reason: 'auto_compact', + }); + break; + } + + case 'compaction.started': { + out.push({ + type: 'compactionStarted', + sessionId, + trigger: p?.trigger === 'manual' ? 'manual' : 'auto', + instruction: typeof p?.instruction === 'string' ? p.instruction : undefined, + }); + break; + } + + case 'compaction.cancelled': { + out.push({ type: 'compactionCancelled', sessionId }); + break; + } + + case 'goal.updated': { + const goal = mapGoalSnapshot(p?.snapshot ?? null); + out.push({ + type: 'goalUpdated', + sessionId, + goal: goal?.status === 'complete' ? null : goal, + }); + break; + } + + // ----------------------------------------------------------------------- + // Explicitly known but not projected + case 'compaction.blocked': + case 'cron.fired': + case 'hook.result': + case 'mcp.server.status': + case 'skill.activated': + case 'tool.list.updated': + break; + + // ----------------------------------------------------------------------- + default: + // Unknown future events — safe no-op + break; + } + + return out; + } + + return { project, bindNextPromptId, seedInFlight, reset, markSideChannelAgent }; +} + +// --------------------------------------------------------------------------- +// Helpers for integration layer +// --------------------------------------------------------------------------- + +/** + * Detect whether an incoming WS frame type is a raw agent-core event + * (as opposed to a projected "event.*" protocol event or a control frame). + * + * Raw agent-core events do NOT start with "event." and are not control frames. + * Control frames: server_hello, ack, ping, resync_required, error. + */ +const CONTROL_FRAME_TYPES = new Set([ + 'server_hello', + 'ack', + 'ping', + 'resync_required', + 'error', + 'pong', +]); + +export function isRawAgentCoreEvent(frameType: string): boolean { + if (frameType.startsWith('event.')) return false; + if (CONTROL_FRAME_TYPES.has(frameType)) return false; + return true; +} + +/** + * Agent-core event names the projector knows how to project. These are the + * raw events the real daemon emits. The same names may arrive WITH an "event." + * prefix (newer daemon) or WITHOUT it (older daemon). + */ +const KNOWN_AGENT_CORE_TYPES = new Set([ + 'turn.started', + 'turn.step.started', + 'turn.step.completed', + 'turn.step.retrying', + 'turn.step.interrupted', + 'turn.ended', + 'thinking.delta', + 'assistant.delta', + 'tool.call.started', + 'tool.use', // alias the daemon may use for tool.call.started + 'tool.call.delta', + 'tool.progress', + 'tool.result', + 'agent.status.updated', + 'prompt.submitted', + 'prompt.completed', + 'session.meta.updated', + 'compaction.started', + 'compaction.completed', + 'compaction.cancelled', + 'goal.updated', + 'error', + 'warning', + 'subagent.spawned', + 'subagent.started', + 'subagent.suspended', + 'subagent.completed', + 'subagent.failed', + 'background.task.started', + 'background.task.terminated', +]); + +/** + * "event."-prefixed names that are GENUINE protocol events (control/projected + * events produced server-side). The agent projector must NOT re-handle these — + * they go through the existing toAppEvent() path. This includes approval / + * question requests (which drive the approval/question UI) and the no-op-but- + * known streaming/tool protocol events. + */ +const PROTOCOL_EVENT_NAMES = new Set([ + // Session lifecycle (projected) + 'session.created', + 'session.updated', + 'session.deleted', + 'session.status_changed', + 'session.usage_updated', + 'session.history_compacted', + // Message lifecycle (projected) + 'message.created', + 'message.updated', + // Approval / Question — MUST stay on the protocol path to drive the UI + 'approval.requested', + 'approval.resolved', + 'approval.expired', + 'question.requested', + 'question.answered', + 'question.dismissed', + 'question.expired', + // Background tasks (projected) + 'task.created', + 'task.progress', + 'task.completed', + // No-op-but-known protocol streaming / tool events + 'assistant.tool_use_started', + 'assistant.tool_use_delta', + 'assistant.tool_use_completed', + 'assistant.completed', + 'tool.started', + 'tool.output', + 'tool.completed', +]); + +/** + * Names that are ambiguous between the raw agent-core form (payload.delta is a + * STRING) and the already-projected protocol form (payload.delta is an object + * { text? | thinking? }, or the payload carries message_id / content_index). + */ +const AMBIGUOUS_DELTA_NAMES = new Set(['assistant.delta', 'thinking.delta']); + +export type FrameRoute = + | { route: 'protocol' } + | { route: 'agent'; agentType: string } + | { route: 'ignore' }; + +/** + * Classify a (possibly "event."-prefixed) WS frame into the path it should take. + * + * - 'protocol' → hand the original frame to toAppEvent() (existing path). + * - 'agent' → hand `agentType` + payload to the agent projector. + * - 'ignore' → drop (no session context / unroutable). + * + * Robust to all three observed shapes: + * 1) raw agent-core (no prefix): turn.started, assistant.delta{delta:'…'} + * 2) "event."-prefixed agent-core: event.turn.started, event.assistant.delta{delta:'…'} + * 3) genuine protocol "event.*" events: event.message.created, event.session.*, … + */ +export function classifyFrame(rawType: string, payload: unknown): FrameRoute { + if (CONTROL_FRAME_TYPES.has(rawType)) return { route: 'ignore' }; + + const hasPrefix = rawType.startsWith('event.'); + const name = hasPrefix ? rawType.slice('event.'.length) : rawType; + + // Ambiguous delta events: disambiguate by payload shape regardless of prefix. + if (AMBIGUOUS_DELTA_NAMES.has(name)) { + if (deltaIsRawAgentCore(payload)) return { route: 'agent', agentType: name }; + // Object delta or protocol-shaped payload → projected protocol event. + return { route: 'protocol' }; + } + + // Unprefixed frames are raw agent-core (real daemon) when we know the name. + if (!hasPrefix) { + if (KNOWN_AGENT_CORE_TYPES.has(name)) return { route: 'agent', agentType: name }; + // Unknown unprefixed name with no protocol meaning → still try the projector + // (it safely no-ops on unknown types and advances nothing). + return { route: 'agent', agentType: name }; + } + + // Prefixed frames: genuine protocol events take priority. + if (PROTOCOL_EVENT_NAMES.has(name)) return { route: 'protocol' }; + // Prefixed agent-core event (e.g. event.turn.started) → strip + project. + if (KNOWN_AGENT_CORE_TYPES.has(name)) return { route: 'agent', agentType: name }; + // Unknown "event.*" → let toAppEvent() record it as an unknown protocol event. + return { route: 'protocol' }; +} + +/** + * True when an assistant.delta / thinking.delta payload is in the RAW agent-core + * form: payload.delta is a plain string, and there is no protocol-only field + * (message_id / content_index). The protocol form uses delta:{text|thinking}. + */ +function deltaIsRawAgentCore(payload: unknown): boolean { + if (!payload || typeof payload !== 'object') return false; + const p = payload as Record; + if ('message_id' in p || 'content_index' in p) return false; + return typeof p['delta'] === 'string'; +} diff --git a/apps/kimi-web/src/api/daemon/client.ts b/apps/kimi-web/src/api/daemon/client.ts new file mode 100644 index 000000000..97a92f8c2 --- /dev/null +++ b/apps/kimi-web/src/api/daemon/client.ts @@ -0,0 +1,1350 @@ +// apps/kimi-web/src/api/daemon/client.ts +// DaemonKimiWebApi — implements KimiWebApi using the daemon REST + WS APIs. + +import type { KimiApiConfig } from '../config'; +import { buildRestUrl, buildWsUrl } from '../config'; +import type { + AppConfig, + AppMessage, + AppMessageRole, + AppModel, + AppProvider, + ProviderRefreshResult, + AppSession, + AppSkill, + AppSessionCursor, + AppSessionRuntimeStatus, + AppSessionSnapshot, + AppSessionStatus, + AppTask, + AppTaskStatus, + AppTerminal, + AppWorkspace, + ApprovalResponse, + FsBrowseResult, + FsEntry, + KimiEventConnection, + KimiEventHandlers, + KimiWebApi, + Page, + PageRequest, + PromptSubmission, + PromptSubmitResult, + QuestionResponse, +} from '../types'; +import { createAgentProjector } from './agentEventProjector'; +import { DaemonHttpClient } from './http'; +import { + toAppApprovalRequest, + toAppConfig, + toAppEvent, + toAppFsEntry, + toAppMessage, + toAppModel, + toAppProvider, + toAppQuestionRequest, + toAppSession, + toAppTask, + toWireApprovalResponse, + toWirePromptSubmission, + toWireQuestionResponse, + toWireSessionStatus, + toAppWorkspace, + wireEventSeq, + wireEventSessionId, +} from './mappers'; +import type { + WireAuthResult, + WireBackgroundTask, + WireConfig, + WireEvent, + WireFileMeta, + WireFsBrowseResult, + WireFsEntry, + WireFsHomeResult, + WireMessage, + WireModel, + WireOAuthCancelResult, + WireOAuthLoginPollResult, + WireOAuthLoginStartResult, + WirePage, + WirePromptSubmitResult, + WirePromptSteerResult, + WireProvider, + WireProviderRefreshResult, + WireSession, + WireSessionAbortResult, + WireSessionRuntimeStatus, + WireSessionSnapshot, + WireWorkspace, + WireLogoutResult, +} from './wire'; +import { DaemonEventSocket } from './ws'; + +// --------------------------------------------------------------------------- +// Wire response shapes for endpoints not in shared wire.ts +// --------------------------------------------------------------------------- + +interface WireHealth { + status: 'ok'; + uptime_sec: number; +} + +interface WireMeta { + server_version: string; + server_id: string; + started_at: string; + capabilities: Record; + open_in_apps?: string[]; +} + +interface WireAbortResult { + aborted: boolean; + at_seq?: number; +} + +interface WireDismissResult { + dismissed: boolean; + dismissed_at: string; +} + +interface WireApprovalResolveResult { + resolved: true; + resolved_at: string; +} + +interface WireQuestionResolveResult { + resolved: true; + resolved_at: string; +} + +interface WireCancelResult { + cancelled: true; +} + +interface WireSkillDescriptor { + name: string; + description: string; + path: string; + source: string; + type?: string; + disable_model_invocation?: boolean; +} + +interface WireArchiveResult { + archived: true; +} + +interface WireListDirectoryResult { + items: WireFsEntry[]; + children_by_path?: Record; + truncated: boolean; +} + +interface WireReadFileResult { + path: string; + content: string; + encoding: 'utf-8' | 'base64'; + size: number; + truncated: boolean; + etag: string; + mime: string; + language_id?: string; + line_count?: number; + is_binary: boolean; +} + +interface WireSearchFilesResult { + items: Array<{ + path: string; + name: string; + kind: 'file' | 'directory' | 'symlink'; + score: number; + match_positions: number[]; + }>; + truncated: boolean; +} + +interface WireGrepFilesResult { + files: Array<{ + path: string; + matches: Array<{ + line: number; + col: number; + text: string; + before: string[]; + after: string[]; + }>; + }>; + files_scanned: number; + truncated: boolean; + elapsed_ms: number; +} + +interface WireGitStatusResult { + branch: string; + ahead: number; + behind: number; + entries: Record; + additions: number; + deletions: number; + pullRequest?: { number: number; state: string; url: string } | null; +} + +interface WireDiffResult { + path: string; + diff: string; +} + +interface WireTerminal { + id: string; + session_id: string; + cwd: string; + shell: string; + cols: number; + rows: number; + status: 'running' | 'exited'; + created_at: string; + exited_at?: string; + exit_code?: number | null; +} + +function toAppTerminal(data: WireTerminal): AppTerminal { + return { + id: data.id, + sessionId: data.session_id, + cwd: data.cwd, + shell: data.shell, + cols: data.cols, + rows: data.rows, + status: data.status, + createdAt: data.created_at, + exitedAt: data.exited_at, + exitCode: data.exit_code, + }; +} + +/** + * historyCompacted reasons caused by compaction itself. These do NOT trigger a + * snapshot reload: the client keeps the visible scrollback and renders a + * divider marker instead. Every other reason (delta_gap, history_rewrite, …) + * still means "cached messages are stale" and goes through onResync. + */ +function isCompactionReason(reason: string): boolean { + return reason === 'auto_compact' || reason === 'manual_compact'; +} + +// --------------------------------------------------------------------------- +// DaemonKimiWebApi +// --------------------------------------------------------------------------- + +export class DaemonKimiWebApi implements KimiWebApi { + private readonly http: DaemonHttpClient; + private readonly config: KimiApiConfig; + + constructor(config: KimiApiConfig) { + this.config = config; + this.http = new DaemonHttpClient(config.serverHttpUrl, { + clientId: config.clientId, + clientName: config.clientName, + clientVersion: config.clientVersion, + clientUiMode: config.clientUiMode, + }); + } + + // ------------------------------------------------------------------------- + // Health / Meta + // ------------------------------------------------------------------------- + + async getHealth(): Promise<{ status: 'ok'; uptimeSec: number }> { + // Real daemon returns { ok: true }; the older shape was { status, uptime_sec }. + const data = await this.http.get>('/healthz'); + return { status: 'ok', uptimeSec: data.uptime_sec ?? 0 }; + } + + async getMeta(): Promise<{ + serverVersion: string; + serverId: string; + startedAt: string; + capabilities: Record; + openInApps: string[]; + }> { + const data = await this.http.get('/meta'); + return { + serverVersion: data.server_version, + serverId: data.server_id, + startedAt: data.started_at, + capabilities: data.capabilities, + openInApps: Array.isArray(data.open_in_apps) ? data.open_in_apps : [], + }; + } + + // ------------------------------------------------------------------------- + // Sessions + // ------------------------------------------------------------------------- + + async listSessions( + input?: PageRequest & { status?: AppSessionStatus; workspaceId?: string; includeArchive?: boolean }, + ): Promise> { + const query: Record = { + before_id: input?.beforeId, + after_id: input?.afterId, + page_size: input?.pageSize, + status: input?.status ? toWireSessionStatus(input.status) : undefined, + include_archive: input?.includeArchive, + // PRESUMED — daemon supports ?workspace_id= once the registry ships; it + // ignores unknown query params until then, so this is safe to always send. + workspace_id: input?.workspaceId, + }; + const data = await this.http.get>('/sessions', query); + return { + items: data.items.map(toAppSession), + hasMore: data.has_more, + }; + } + + async createSession(input: { + title?: string; + cwd?: string; + model?: string; + workspaceId?: string; + }): Promise { + // The real daemon requires `metadata` to be an object (rejects a missing + // metadata with 40001), so always send it — with cwd when provided. + const body: Record = { + metadata: input.cwd !== undefined ? { cwd: input.cwd } : {}, + }; + // PRESUMED — daemon resolves cwd from workspace_id once the registry ships. + // We ALSO send metadata.cwd (above) as the fallback so today's daemon, which + // only understands cwd, still creates the session in the right folder. + if (input.workspaceId !== undefined) body['workspace_id'] = input.workspaceId; + if (input.title !== undefined) body['title'] = input.title; + if (input.model !== undefined) body['agent_config'] = { model: input.model }; + const data = await this.http.post('/sessions', body); + return toAppSession(data); + } + + // GET /sessions/{id} — fetch one session (deep links to sessions outside the + // first listSessions page). + async getSession(sessionId: string): Promise { + const data = await this.http.get( + `/sessions/${encodeURIComponent(sessionId)}`, + ); + return toAppSession(data); + } + + // The daemon has no PATCH on sessions; mutating title/metadata/agent_config + // (model + runtime controls) goes through POST /sessions/{id}/profile with a + // SessionUpdate body { title?, metadata?, agent_config? }. Runtime controls in + // agent_config are dispatched to the matching core RPCs (setModel/setThinking/ + // setPermission/enterPlan|cancelPlan); the live values are read back from + // GET /sessions/{id}/status (the profile echo's agent_config can be stale/""). + async updateSession( + sessionId: string, + input: { + title?: string; + cwd?: string; + model?: string; + permissionMode?: string; + planMode?: boolean; + swarmMode?: boolean; + goalObjective?: string; + goalControl?: 'pause' | 'resume' | 'cancel'; + thinking?: string; + }, + ): Promise { + const body: Record = {}; + if (input.title !== undefined) body['title'] = input.title; + if (input.cwd !== undefined) body['metadata'] = { cwd: input.cwd }; + const agentConfig: Record = {}; + if (input.model !== undefined) agentConfig['model'] = input.model; + if (input.permissionMode !== undefined) agentConfig['permission_mode'] = input.permissionMode; + if (input.planMode !== undefined) agentConfig['plan_mode'] = input.planMode; + if (input.swarmMode !== undefined) agentConfig['swarm_mode'] = input.swarmMode; + if (input.goalObjective !== undefined) agentConfig['goal_objective'] = input.goalObjective; + if (input.goalControl !== undefined) agentConfig['goal_control'] = input.goalControl; + if (input.thinking !== undefined) agentConfig['thinking'] = input.thinking; + if (Object.keys(agentConfig).length > 0) body['agent_config'] = agentConfig; + const data = await this.http.post( + `/sessions/${encodeURIComponent(sessionId)}/profile`, + body, + ); + return toAppSession(data); + } + + /** + * GET /sessions/{id}/status — the session's live runtime state (current model, + * thinking level, permission mode, plan flag, and context-window usage). This + * is the source of truth for the status line; Session.agent_config.model can + * be "" on the read path. + */ + async getSessionStatus(sessionId: string): Promise { + const data = await this.http.get( + `/sessions/${encodeURIComponent(sessionId)}/status`, + ); + return { + model: data.model && data.model.length > 0 ? data.model : null, + thinkingLevel: data.thinking_level, + permission: data.permission, + planMode: data.plan_mode === true, + swarmMode: data.swarm_mode === true, + contextTokens: data.context_tokens ?? 0, + maxContextTokens: data.max_context_tokens ?? 0, + contextUsage: data.context_usage ?? 0, + }; + } + + async archiveSession(sessionId: string): Promise<{ archived: true }> { + const data = await this.http.post( + `/sessions/${encodeURIComponent(sessionId)}:archive`, + {}, + ); + return data; + } + + // ------------------------------------------------------------------------- + // Messages + // ------------------------------------------------------------------------- + + async listMessages( + sessionId: string, + input?: PageRequest & { role?: AppMessageRole }, + ): Promise> { + const query: Record = { + before_id: input?.beforeId, + after_id: input?.afterId, + page_size: input?.pageSize, + role: input?.role, + }; + const data = await this.http.get>( + `/sessions/${encodeURIComponent(sessionId)}/messages`, + query, + ); + return { + items: data.items.map(toAppMessage), + hasMore: data.has_more, + }; + } + + /** + * v2 initial sync: atomic session state at an `as_of_seq` watermark. + * Rebuild flow: getSessionSnapshot() → seedSnapshot() → subscribe(cursor). + */ + async getSessionSnapshot(sessionId: string): Promise { + const data = await this.http.get( + `/sessions/${encodeURIComponent(sessionId)}/snapshot`, + ); + return { + asOfSeq: data.as_of_seq, + epoch: data.epoch, + session: toAppSession(data.session), + // Snapshot messages are already chronological ascending. + messages: data.messages.items.map(toAppMessage), + hasMoreMessages: data.messages.has_more, + inFlightTurn: + data.in_flight_turn === null + ? null + : { + turnId: data.in_flight_turn.turn_id, + assistantText: data.in_flight_turn.assistant_text, + thinkingText: data.in_flight_turn.thinking_text, + runningTools: data.in_flight_turn.running_tools.map((t) => ({ + toolCallId: t.tool_call_id, + name: t.name, + args: t.args, + description: t.description, + lastProgress: t.last_progress, + })), + promptId: data.in_flight_turn.current_prompt_id, + }, + pendingApprovals: data.pending_approvals.map(toAppApprovalRequest), + pendingQuestions: data.pending_questions.map(toAppQuestionRequest), + }; + } + + // ------------------------------------------------------------------------- + // Prompt + // ------------------------------------------------------------------------- + + async submitPrompt( + sessionId: string, + input: PromptSubmission, + ): Promise { + const data = await this.http.post( + `/sessions/${encodeURIComponent(sessionId)}/prompts`, + toWirePromptSubmission(input), + ); + return { + promptId: data.prompt_id, + userMessageId: data.user_message_id, + status: data.status, + }; + } + + // POST /sessions/{id}/prompts:steer — steer daemon-queued prompts into the + // active turn (TUI ctrl+s). Throws PROMPT_NOT_FOUND when there is no active + // turn anymore (the queued prompt then starts its own turn — callers may + // treat that as success). + async steerPrompts( + sessionId: string, + promptIds: string[], + ): Promise<{ steered: boolean; promptIds: string[] }> { + const data = await this.http.post( + `/sessions/${encodeURIComponent(sessionId)}/prompts:steer`, + { prompt_ids: promptIds }, + ); + return { steered: data.steered, promptIds: data.prompt_ids }; + } + + async abortPrompt( + sessionId: string, + promptId: string, + ): Promise<{ aborted: boolean; atSeq?: number }> { + const data = await this.http.post( + `/sessions/${encodeURIComponent(sessionId)}/prompts/${encodeURIComponent(promptId)}:abort`, + undefined, + { allowCodes: [40903] }, + ); + // data.aborted is false when 40903 (prompt already completed) — that's correct + return { aborted: data.aborted, atSeq: data.at_seq }; + } + + // POST /sessions/{id}:abort — cancel whatever is running in the session, + // including skill activations that bypass IPromptService. + async abortSession(sessionId: string): Promise<{ aborted: boolean }> { + const data = await this.http.post( + `/sessions/${encodeURIComponent(sessionId)}:abort`, + {}, + ); + return { aborted: data.aborted }; + } + + // POST /sessions/{id}:compact — request history compaction. Returns {}; + // progress and completion arrive via the WS compaction.* events (the + // transcript itself is not reloaded — a divider marker is appended). + async compactSession(sessionId: string, instruction?: string): Promise { + await this.http.post( + `/sessions/${encodeURIComponent(sessionId)}:compact`, + instruction ? { instruction } : {}, + ); + } + + // POST /sessions/{id}:undo — remove the last `count` turns from history. The + // response carries the resulting messages + status, but we re-sync the session + // afterwards for the authoritative (un-paginated) transcript, so we only need + // the call to succeed here. + async undoSession(sessionId: string, count = 1): Promise { + await this.http.post( + `/sessions/${encodeURIComponent(sessionId)}:undo`, + { count }, + ); + } + + // POST /sessions/{id}:fork — fork the session into a new child session. + async forkSession(sessionId: string, input?: { title?: string }): Promise { + const body: Record = {}; + if (input?.title !== undefined) body['title'] = input.title; + const data = await this.http.post( + `/sessions/${encodeURIComponent(sessionId)}:fork`, + body, + ); + return toAppSession(data); + } + + // POST /sessions/{id}/children — create a child ("side chat") session. The + // daemon forks the parent (so the child inherits its context) and tags it with + // parent_session_id + child_session_kind. + async createChildSession(sessionId: string, input?: { title?: string }): Promise { + const body: Record = {}; + if (input?.title !== undefined) body['title'] = input.title; + const data = await this.http.post( + `/sessions/${encodeURIComponent(sessionId)}/children`, + body, + ); + return toAppSession(data); + } + + // GET /sessions/{id}/children — list a session's child sessions. + async listChildSessions(sessionId: string): Promise { + const data = await this.http.get>( + `/sessions/${encodeURIComponent(sessionId)}/children`, + ); + return data.items.map(toAppSession); + } + + // POST /sessions/{id}:btw — start a TUI-style side-channel agent. Follow-up + // prompts use the returned agent_id on the normal /prompts route. + async startBtw(sessionId: string): Promise<{ agentId: string }> { + const data = await this.http.post<{ agent_id: string }>( + `/sessions/${encodeURIComponent(sessionId)}:btw`, + {}, + ); + return { agentId: data.agent_id }; + } + + // ------------------------------------------------------------------------- + // Approval / Question + // ------------------------------------------------------------------------- + + async respondApproval( + sessionId: string, + approvalId: string, + response: ApprovalResponse, + ): Promise<{ resolved: true; resolvedAt: string }> { + const data = await this.http.post( + `/sessions/${encodeURIComponent(sessionId)}/approvals/${encodeURIComponent(approvalId)}`, + toWireApprovalResponse(response), + ); + return { resolved: data.resolved, resolvedAt: data.resolved_at }; + } + + async respondQuestion( + sessionId: string, + questionId: string, + response: QuestionResponse, + ): Promise<{ resolved: true; resolvedAt: string }> { + const data = await this.http.post( + `/sessions/${encodeURIComponent(sessionId)}/questions/${encodeURIComponent(questionId)}`, + toWireQuestionResponse(response), + ); + return { resolved: data.resolved, resolvedAt: data.resolved_at }; + } + + async dismissQuestion( + sessionId: string, + questionId: string, + ): Promise<{ dismissed: true; dismissedAt: string }> { + const data = await this.http.post( + `/sessions/${encodeURIComponent(sessionId)}/questions/${encodeURIComponent(questionId)}:dismiss`, + undefined, + { allowCodes: [40909] }, + ); + // 40909 means question.dismissed — that's the success path per spec + return { dismissed: true, dismissedAt: data.dismissed_at }; + } + + // ------------------------------------------------------------------------- + // Tasks + // ------------------------------------------------------------------------- + + async listTasks(sessionId: string, status?: AppTaskStatus): Promise { + const query: Record = { + status: status, + }; + const data = await this.http.get<{ items: WireBackgroundTask[] }>( + `/sessions/${encodeURIComponent(sessionId)}/tasks`, + query, + ); + return data.items.map(toAppTask); + } + + async getTask( + sessionId: string, + taskId: string, + input?: { withOutput?: boolean; outputBytes?: number }, + ): Promise { + const query: Record = { + with_output: input?.withOutput, + output_bytes: input?.outputBytes, + }; + const data = await this.http.get( + `/sessions/${encodeURIComponent(sessionId)}/tasks/${encodeURIComponent(taskId)}`, + query, + ); + return toAppTask(data); + } + + async cancelTask(sessionId: string, taskId: string): Promise<{ cancelled: true }> { + const data = await this.http.post( + `/sessions/${encodeURIComponent(sessionId)}/tasks/${encodeURIComponent(taskId)}:cancel`, + ); + return data; + } + + async listTerminals(sessionId: string): Promise { + const data = await this.http.get<{ items: WireTerminal[] }>( + `/sessions/${encodeURIComponent(sessionId)}/terminals`, + ); + return data.items.map(toAppTerminal); + } + + async createTerminal( + sessionId: string, + input: { cwd?: string; shell?: string; cols?: number; rows?: number } = {}, + ): Promise { + const body: Record = { + cwd: input.cwd, + shell: input.shell, + cols: input.cols, + rows: input.rows, + }; + const data = await this.http.post( + `/sessions/${encodeURIComponent(sessionId)}/terminals`, + body, + ); + return toAppTerminal(data); + } + + async getTerminal(sessionId: string, terminalId: string): Promise { + const data = await this.http.get( + `/sessions/${encodeURIComponent(sessionId)}/terminals/${encodeURIComponent(terminalId)}`, + ); + return toAppTerminal(data); + } + + async closeTerminal(sessionId: string, terminalId: string): Promise<{ closed: true }> { + return this.http.post<{ closed: true }>( + `/sessions/${encodeURIComponent(sessionId)}/terminals/${encodeURIComponent(terminalId)}:close`, + ); + } + + // ------------------------------------------------------------------------- + // Skills — session-scoped slash-invocable skills + // GET /sessions/{id}/skills → { skills: WireSkillDescriptor[] } + // POST /sessions/{id}/skills/{name}:activate body { args? } → { activated, skill_name } + // ------------------------------------------------------------------------- + + async listSkills(sessionId: string): Promise { + const data = await this.http.get<{ skills: WireSkillDescriptor[] }>( + `/sessions/${encodeURIComponent(sessionId)}/skills`, + ); + return (data.skills ?? []).map((s) => ({ + name: s.name, + description: s.description, + source: s.source, + })); + } + + async activateSkill( + sessionId: string, + skillName: string, + args?: string, + ): Promise<{ activated: true; skillName: string }> { + const data = await this.http.post<{ activated: true; skill_name: string }>( + `/sessions/${encodeURIComponent(sessionId)}/skills/${encodeURIComponent(skillName)}:activate`, + args !== undefined && args.length > 0 ? { args } : {}, + ); + return { activated: data.activated, skillName: data.skill_name }; + } + + // ------------------------------------------------------------------------- + // File System + // ------------------------------------------------------------------------- + + async listDirectory( + sessionId: string, + input: { path?: string; depth?: number; includeGitStatus?: boolean }, + ): Promise<{ + items: FsEntry[]; + childrenByPath?: Record; + truncated: boolean; + }> { + const body: Record = {}; + if (input.path !== undefined) body['path'] = input.path; + if (input.depth !== undefined) body['depth'] = input.depth; + if (input.includeGitStatus !== undefined) body['include_git_status'] = input.includeGitStatus; + const data = await this.http.post( + `/sessions/${encodeURIComponent(sessionId)}/fs:list`, + body, + ); + const childrenByPath = data.children_by_path + ? Object.fromEntries( + Object.entries(data.children_by_path).map(([k, v]) => [k, v.map(toAppFsEntry)]), + ) + : undefined; + return { + items: data.items.map(toAppFsEntry), + childrenByPath, + truncated: data.truncated, + }; + } + + async readFile( + sessionId: string, + input: { path: string; offset?: number; length?: number }, + ): Promise<{ + path: string; + content: string; + encoding: 'utf-8' | 'base64'; + size: number; + truncated: boolean; + etag: string; + mime: string; + languageId?: string; + lineCount?: number; + isBinary: boolean; + }> { + const body: Record = { path: input.path }; + if (input.offset !== undefined) body['offset'] = input.offset; + if (input.length !== undefined) body['length'] = input.length; + const data = await this.http.post( + `/sessions/${encodeURIComponent(sessionId)}/fs:read`, + body, + ); + return { + path: data.path, + content: data.content, + encoding: data.encoding, + size: data.size, + truncated: data.truncated, + etag: data.etag, + mime: data.mime, + languageId: data.language_id, + lineCount: data.line_count, + isBinary: data.is_binary, + }; + } + + async searchFiles( + sessionId: string, + input: { query: string; limit?: number }, + ): Promise<{ + items: Array<{ + path: string; + name: string; + kind: 'file' | 'directory' | 'symlink'; + score: number; + matchPositions: number[]; + }>; + truncated: boolean; + }> { + const body: Record = { query: input.query }; + if (input.limit !== undefined) body['limit'] = input.limit; + const data = await this.http.post( + `/sessions/${encodeURIComponent(sessionId)}/fs:search`, + body, + ); + return { + items: data.items.map((item) => ({ + path: item.path, + name: item.name, + kind: item.kind, + score: item.score, + matchPositions: item.match_positions, + })), + truncated: data.truncated, + }; + } + + async grepFiles( + sessionId: string, + input: { pattern: string; regex?: boolean; caseSensitive?: boolean }, + ): Promise<{ + files: Array<{ + path: string; + matches: Array<{ + line: number; + col: number; + text: string; + before: string[]; + after: string[]; + }>; + }>; + filesScanned: number; + truncated: boolean; + elapsedMs: number; + }> { + const body: Record = { pattern: input.pattern }; + if (input.regex !== undefined) body['regex'] = input.regex; + if (input.caseSensitive !== undefined) body['case_sensitive'] = input.caseSensitive; + const data = await this.http.post( + `/sessions/${encodeURIComponent(sessionId)}/fs:grep`, + body, + ); + return { + files: data.files, + filesScanned: data.files_scanned, + truncated: data.truncated, + elapsedMs: data.elapsed_ms, + }; + } + + async getGitStatus( + sessionId: string, + paths?: string[], + ): Promise<{ branch: string; ahead: number; behind: number; entries: Record; additions: number; deletions: number; pullRequest: { number: number; state: string; url: string } | null }> { + const body: Record = {}; + if (paths !== undefined) body['paths'] = paths; + const data = await this.http.post( + `/sessions/${encodeURIComponent(sessionId)}/fs:git_status`, + body, + ); + return { + branch: data.branch, + ahead: data.ahead, + behind: data.behind, + entries: data.entries, + additions: data.additions, + deletions: data.deletions, + pullRequest: data.pullRequest ?? null, + }; + } + + async getFileDiff( + sessionId: string, + path: string, + ): Promise<{ path: string; diff: string }> { + const data = await this.http.post( + `/sessions/${encodeURIComponent(sessionId)}/fs:diff`, + { path }, + ); + return { path: data.path, diff: data.diff }; + } + + getFileDownloadUrl(sessionId: string, path: string): string { + const encodedPath = path.split('/').map((part) => encodeURIComponent(part)).join('/'); + return buildRestUrl( + this.config.serverHttpUrl, + `/sessions/${encodeURIComponent(sessionId)}/fs/${encodedPath}:download`, + ); + } + + async openFile( + sessionId: string, + input: { path: string; line?: number }, + ): Promise<{ opened: true }> { + const body: Record = { path: input.path }; + if (input.line !== undefined) body['line'] = input.line; + return this.http.post<{ opened: true }>( + `/sessions/${encodeURIComponent(sessionId)}/fs:open`, + body, + ); + } + + async revealFile( + sessionId: string, + input: { path: string }, + ): Promise<{ revealed: true }> { + return this.http.post<{ revealed: true }>( + `/sessions/${encodeURIComponent(sessionId)}/fs:reveal`, + { path: input.path }, + ); + } + + async openInApp( + sessionId: string, + appId: string, + path: string, + line?: number, + ): Promise { + const body: Record = { app_id: appId, path }; + if (line !== undefined) body['line'] = line; + await this.http.post<{ opened: true }>( + `/sessions/${encodeURIComponent(sessionId)}/fs:open-in`, + body, + ); + } + + // ------------------------------------------------------------------------- + // Workspaces + daemon folder browser + // PRESUMED — falls back until the daemon ships /workspaces, /fs:browse, /fs:home. + // ------------------------------------------------------------------------- + + /** + * List the registered workspaces. + * PRESUMED — GET /api/v1/workspaces. On 404/empty/error this returns [] and + * the composable DERIVES workspaces from the current sessions' cwds. So the + * switcher + grouping work immediately off existing sessions until the daemon + * ships the registry. + */ + async listWorkspaces(): Promise { + try { + const data = await this.http.get>('/workspaces'); + return (data.items ?? []).map(toAppWorkspace); + } catch { + return []; + } + } + + /** + * Register a workspace by folder path. + * PRESUMED — POST /api/v1/workspaces { root, name? }. On error this throws so + * the composable can fall back to a locally-derived workspace from the path. + */ + async addWorkspace(input: { root: string; name?: string }): Promise { + const body: Record = { root: input.root }; + if (input.name !== undefined) body['name'] = input.name; + const data = await this.http.post('/workspaces', body); + return toAppWorkspace(data); + } + + /** + * Remove a registered workspace. + * PRESUMED — DELETE /api/v1/workspaces/:id. On error this throws. + */ + async deleteWorkspace(id: string): Promise { + await this.http.delete(`/workspaces/${encodeURIComponent(id)}`); + } + + /** + * Browse directories under `path` (defaults to $HOME on the daemon). + * PRESUMED — GET /api/v1/fs:browse?path=. On error returns an empty path so + * the picker can distinguish "browse failed" from "directory has no children". + */ + async browseFs(path?: string): Promise { + try { + const data = await this.http.get('/fs:browse', { path }); + return { + path: data.path, + parent: data.parent, + entries: (data.entries ?? []).map((e) => ({ + name: e.name, + path: e.path, + isDir: e.is_dir, + isGitRepo: e.is_git_repo, + branch: e.branch, + })), + }; + } catch { + return { path: '', parent: null, entries: [] }; + } + } + + /** + * Get the picker start directory + recently-used roots. + * PRESUMED — GET /api/v1/fs:home. On error returns empty defaults. + */ + async getFsHome(): Promise<{ home: string; recentRoots: string[] }> { + try { + const data = await this.http.get('/fs:home'); + return { home: data.home, recentRoots: data.recent_roots ?? [] }; + } catch { + return { home: '', recentRoots: [] }; + } + } + + // ------------------------------------------------------------------------- + // Models + Providers + // PRESUMED — not in current daemon docs; isolated here, swap when backend defines them. + // ------------------------------------------------------------------------- + + async listModels(): Promise { + // PRESUMED endpoint: GET /v1/models → { items: WireModel[] } + const data = await this.http.get<{ items: WireModel[] }>('/models'); + return data.items.map(toAppModel); + } + + async listProviders(): Promise { + // PRESUMED endpoint: GET /v1/providers → { items: WireProvider[] } + const data = await this.http.get<{ items: WireProvider[] }>('/providers'); + return data.items.map(toAppProvider); + } + + async addProvider(input: { + type: string; + apiKey?: string; + baseUrl?: string; + defaultModel?: string; + }): Promise { + // PRESUMED endpoint: POST /v1/providers → WireProvider + const body: Record = { type: input.type }; + if (input.apiKey !== undefined) body['api_key'] = input.apiKey; + if (input.baseUrl !== undefined) body['base_url'] = input.baseUrl; + if (input.defaultModel !== undefined) body['default_model'] = input.defaultModel; + const data = await this.http.post('/providers', body); + return toAppProvider(data); + } + + async deleteProvider(id: string): Promise<{ deleted: true }> { + // PRESUMED endpoint: DELETE /v1/providers/{id} → { deleted: true } + return this.http.delete<{ deleted: true }>(`/providers/${encodeURIComponent(id)}`); + } + + async refreshProvider(id: string): Promise { + // PRESUMED endpoint: POST /v1/providers/{id}:refresh → WireProvider + const data = await this.http.post( + `/providers/${encodeURIComponent(id)}:refresh`, + ); + return toAppProvider(data); + } + + async refreshOAuthProviderModels(): Promise { + const data = await this.http.post('/providers:refresh_oauth'); + return { + changed: data.changed.map((item) => ({ + providerId: item.provider_id, + providerName: item.provider_name, + added: item.added, + removed: item.removed, + })), + unchanged: data.unchanged, + failed: data.failed, + }; + } + + // ------------------------------------------------------------------------- + // Config — REAL endpoints + // ------------------------------------------------------------------------- + + async getConfig(): Promise { + const data = await this.http.get('/config'); + return toAppConfig(data); + } + + async setConfig(patch: Partial): Promise { + const wirePatch: Record = {}; + const keyMap: Record = { + providers: 'providers', + defaultProvider: 'default_provider', + defaultModel: 'default_model', + models: 'models', + thinking: 'thinking', + planMode: 'plan_mode', + yolo: 'yolo', + defaultThinking: 'default_thinking', + defaultPermissionMode: 'default_permission_mode', + defaultPlanMode: 'default_plan_mode', + permission: 'permission', + hooks: 'hooks', + services: 'services', + mergeAllAvailableSkills: 'merge_all_available_skills', + extraSkillDirs: 'extra_skill_dirs', + loopControl: 'loop_control', + background: 'background', + experimental: 'experimental', + telemetry: 'telemetry', + raw: 'raw', + }; + for (const [key, value] of Object.entries(patch)) { + const wireKey = keyMap[key as keyof AppConfig]; + if (wireKey !== undefined) { + wirePatch[wireKey] = value; + } + } + const data = await this.http.post('/config', wirePatch); + return toAppConfig(data); + } + + // ------------------------------------------------------------------------- + // Auth — REAL endpoints + // ------------------------------------------------------------------------- + + async getAuth(): Promise<{ + ready: boolean; + providersCount: number; + defaultModel: string | null; + managedProvider: { status: string } | null; + }> { + const data = await this.http.get('/auth'); + return { + ready: data.ready, + providersCount: data.providers_count, + defaultModel: data.default_model, + managedProvider: data.managed_provider + ? { status: data.managed_provider.status } + : null, + }; + } + + async startOAuthLogin(): Promise<{ + flowId: string; + provider: string; + verificationUri: string; + verificationUriComplete: string; + userCode: string; + expiresIn: number; + interval: number; + status: 'pending'; + expiresAt: string; + }> { + const data = await this.http.post('/oauth/login', {}); + return { + flowId: data.flow_id, + provider: data.provider, + verificationUri: data.verification_uri, + verificationUriComplete: data.verification_uri_complete, + userCode: data.user_code, + expiresIn: data.expires_in, + interval: data.interval, + status: data.status, + expiresAt: data.expires_at, + }; + } + + async pollOAuthLogin(): Promise<{ + flowId: string; + status: 'pending' | 'authenticated' | 'expired' | 'cancelled'; + resolvedAt?: string; + } | null> { + // data may be null if no flow is active + const data = await this.http.get('/oauth/login'); + if (!data) return null; + return { + flowId: data.flow_id, + status: data.status, + resolvedAt: data.resolved_at, + }; + } + + async cancelOAuthLogin(): Promise<{ cancelled: boolean; status: string }> { + const data = await this.http.delete('/oauth/login'); + return { cancelled: data.cancelled, status: data.status }; + } + + async logout(): Promise<{ loggedOut: boolean }> { + const data = await this.http.post('/oauth/logout', {}); + return { loggedOut: data.logged_out }; + } + + // ------------------------------------------------------------------------- + // File upload + // ------------------------------------------------------------------------- + + async uploadFile(input: { file: Blob; name?: string }): Promise<{ id: string; name: string; mediaType: string; size: number }> { + const formData = new FormData(); + formData.append('file', input.file, input.name ?? (input.file instanceof File ? input.file.name : 'upload')); + if (input.name !== undefined) { + formData.append('name', input.name); + } + const data = await this.http.postForm('/files', formData); + return { + id: data.id, + name: data.name, + mediaType: data.media_type, + size: data.size, + }; + } + + getFileUrl(fileId: string): string { + return buildRestUrl(this.config.serverHttpUrl, `/files/${encodeURIComponent(fileId)}`); + } + + // ------------------------------------------------------------------------- + // WebSocket events + // ------------------------------------------------------------------------- + + connectEvents(handlers: KimiEventHandlers): KimiEventConnection { + const wsUrl = buildWsUrl(this.config.serverHttpUrl, this.config.clientId); + + // Per-session projector for raw agent-core events. + // Keyed by session_id; reset when a session is re-subscribed or resynced. + const projector = createAgentProjector(); + + const socket = new DaemonEventSocket(wsUrl, this.config.clientId, { + // ----------------------------------------------------------------------- + // Projected "event.*" frames — existing path (kept working for stub / spec) + // ----------------------------------------------------------------------- + onWireEvent: (wireEvent: WireEvent) => { + const sessionId = wireEventSessionId(wireEvent); + const seq = wireEventSeq(wireEvent); + const appEvent = toAppEvent(wireEvent); + + // Route history_compacted to onResync so the client reloads messages — + // EXCEPT for compaction itself: the transcript keeps the scrollback and + // the reducer appends a divider marker instead (reloading would replace + // the visible conversation with the compacted model context). + if (appEvent.type === 'historyCompacted' && !isCompactionReason(appEvent.reason)) { + handlers.onResync(appEvent.sessionId, appEvent.beforeSeq); + // Still dispatch the event to onEvent so the reducer can update lastSeqBySession + } + + // Deliver the AppEvent together with wire-level seq/session so the + // reducer can advance lastSeqBySession[sessionId] = seq. + handlers.onEvent(appEvent, { sessionId, seq }); + }, + + // ----------------------------------------------------------------------- + // Raw agent-core frames — client-side projection path (real daemon) + // ----------------------------------------------------------------------- + onRawAgentEvent: (frame) => { + const { type, seq, session_id: sessionId, payload, offset } = frame; + const appEvents = projector.project(type, payload, sessionId, { offset }); + for (const appEvent of appEvents) { + // historyCompacted from the projector is either a compaction signal + // (reason auto_compact — no reload, the divider marker handles it) or + // a delta-gap recovery (reason delta_gap — a real resync, routed to + // onResync with the real frame.seq, mirroring the protocol path). + if (appEvent.type === 'historyCompacted' && !isCompactionReason(appEvent.reason)) { + handlers.onResync(sessionId, seq); + } + handlers.onEvent(appEvent, { sessionId, seq }); + } + }, + + onResync: (sessionId: string, currentSeq: number, epoch?: string) => { + // Reset per-session projector state on resync + projector.reset(sessionId); + handlers.onResync(sessionId, currentSeq, epoch); + }, + + onConnectionState: (connected: boolean) => { + handlers.onConnectionChange(connected); + }, + + onError: (code: number, msg: string, fatal: boolean) => { + handlers.onError(code, msg, fatal); + }, + + onTerminalOutput: (sessionId, terminalId, data, seq) => { + handlers.onTerminalOutput?.(sessionId, terminalId, data, seq); + }, + + onTerminalExit: (sessionId, terminalId, exitCode) => { + handlers.onTerminalExit?.(sessionId, terminalId, exitCode); + }, + }); + + socket.connect(); + + return { + subscribe(sessionId: string, cursor?: AppSessionCursor): void { + // Do NOT reset projector state here: every sidebar click re-subscribes + // the (possibly running) session, and a reset wipes the turn/prompt + // bindings — the remainder of an in-flight turn would be dropped on + // the floor. The projector starts sessions fresh on first sight, and + // onResync (below) resets explicitly before messages are reloaded. + socket.subscribe(sessionId, cursor ?? { seq: 0 }); + }, + unsubscribe(sessionId: string): void { + socket.unsubscribe(sessionId); + }, + seedSnapshot(sessionId: string, snapshot: AppSessionSnapshot): void { + // Rebuild the projector's mid-turn state from the snapshot. The + // resulting AppEvents (running status + partially-streamed assistant + // message) flow through the SAME onEvent path as live events, so the + // rendering layer needs no special handling. When there is no + // in-flight turn we only reset, so stale turn state can't leak into + // the freshly-loaded message list. + if (snapshot.inFlightTurn === null) { + projector.reset(sessionId); + return; + } + const appEvents = projector.seedInFlight(sessionId, snapshot.inFlightTurn); + for (const appEvent of appEvents) { + handlers.onEvent(appEvent, { sessionId, seq: snapshot.asOfSeq }); + } + }, + bindNextPromptId(sessionId: string, promptId: string): void { + // Wire the real daemon prompt_id into the projector so turn.started + // uses it instead of a synthetic ulid('pr_'). Without this, the + // synthetic id propagates to session.currentPromptId and the REST + // :abort endpoint never matches the daemon's real prompt_id. + projector.bindNextPromptId(sessionId, promptId); + }, + abort(sessionId: string, promptId: string): void { + socket.abort(sessionId, promptId); + }, + terminalAttach(sessionId: string, terminalId: string, sinceSeq?: number): void { + socket.terminalAttach(sessionId, terminalId, sinceSeq); + }, + terminalInput(sessionId: string, terminalId: string, data: string): void { + socket.terminalInput(sessionId, terminalId, data); + }, + terminalResize(sessionId: string, terminalId: string, cols: number, rows: number): void { + socket.terminalResize(sessionId, terminalId, cols, rows); + }, + terminalDetach(sessionId: string, terminalId: string): void { + socket.terminalDetach(sessionId, terminalId); + }, + terminalClose(sessionId: string, terminalId: string): void { + socket.terminalClose(sessionId, terminalId); + }, + markSideChannelAgent(agentId: string): void { + projector.markSideChannelAgent(agentId); + }, + close(): void { + socket.close(); + }, + }; + } +} diff --git a/apps/kimi-web/src/api/daemon/eventReducer.ts b/apps/kimi-web/src/api/daemon/eventReducer.ts new file mode 100644 index 000000000..7bb394ca9 --- /dev/null +++ b/apps/kimi-web/src/api/daemon/eventReducer.ts @@ -0,0 +1,595 @@ +// apps/kimi-web/src/api/daemon/eventReducer.ts +// Pure TypeScript state reducer for KimiClient. +// Operates on plain TS state — no Vue reactivity here. +// The reducer consumes AppEvent (camelCase), produced by toAppEvent() in mappers.ts. +// +// No-op-but-known events (tool.*, assistant streaming, assistant.completed) +// are mapped to { type: 'unknown', raw: { _noop: true, ... } } by mappers.ts. +// The reducer detects `_noop: true` and silently advances lastSeqBySession +// without pushing a warning. + +import type { + AppApprovalRequest, + AppConfig, + AppEvent, + AppGoal, + AppMessage, + AppMessageContent, + AppWarning, + AppQuestionRequest, + AppSession, + AppTask, + CompactionMarkerMetadata, +} from '../types'; +import { COMPACTION_MARKER_METADATA_KEY } from '../types'; +import { i18n } from '../../i18n'; + +const OPTIMISTIC_USER_MESSAGE_METADATA_KEY = 'kimiWeb.optimisticUserMessage'; + +// --------------------------------------------------------------------------- +// State +// --------------------------------------------------------------------------- + +/** Live compaction progress for a session: present (status 'running') only + while the daemon is compacting. Completion is recorded as a persistent + divider marker message in the transcript, not as transient status. */ +export interface CompactionStatus { + status: 'running'; + trigger: 'manual' | 'auto'; +} + +export interface KimiClientState { + sessions: AppSession[]; + activeSessionId?: string; + messagesBySession: Record; + approvalsBySession: Record; + questionsBySession: Record; + tasksBySession: Record; + goalBySession: Record; + lastSeqBySession: Record; + compactionBySession: Record; + config?: AppConfig | null; + warnings: AppWarning[]; +} + +export function createInitialState(): KimiClientState { + return { + sessions: [], + activeSessionId: undefined, + messagesBySession: {}, + approvalsBySession: {}, + questionsBySession: {}, + tasksBySession: {}, + goalBySession: {}, + lastSeqBySession: {}, + compactionBySession: {}, + warnings: [], + }; +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function cloneState(s: KimiClientState): KimiClientState { + return { + ...s, + sessions: [...s.sessions], + messagesBySession: { ...s.messagesBySession }, + approvalsBySession: { ...s.approvalsBySession }, + questionsBySession: { ...s.questionsBySession }, + tasksBySession: { ...s.tasksBySession }, + goalBySession: { ...s.goalBySession }, + lastSeqBySession: { ...s.lastSeqBySession }, + compactionBySession: { ...s.compactionBySession }, + warnings: [...s.warnings], + }; +} + +function advanceSeq(state: KimiClientState, sessionId: string | undefined, seq: number | undefined): void { + if (sessionId !== undefined && seq !== undefined && seq > 0) { + const prev = state.lastSeqBySession[sessionId] ?? 0; + if (seq > prev) { + state.lastSeqBySession[sessionId] = seq; + } + } +} + +function isOptimisticUserMessage(message: AppMessage): boolean { + return ( + message.role === 'user' && + message.metadata?.[OPTIMISTIC_USER_MESSAGE_METADATA_KEY] === true + ); +} + +function sameMessageContent(a: AppMessage, b: AppMessage): boolean { + return JSON.stringify(a.content) === JSON.stringify(b.content); +} + +/** Concatenated text + count of image/file parts — a serialization-independent + shape of a user message. The daemon's echo carries images as a resolved + URL/base64 while our optimistic copy carries `{kind:'file',fileId}`, so the + raw content never matches; comparing (text, image-count) does. */ +function userMessageShape(m: AppMessage): { text: string; media: number } { + let text = ''; + let media = 0; + for (const c of m.content) { + if (c.type === 'text') text += c.text; + else if (c.type === 'image' || c.type === 'file') media += 1; + } + return { text, media }; +} + +function sameUserMessageLoosely(a: AppMessage, b: AppMessage): boolean { + const sa = userMessageShape(a); + const sb = userMessageShape(b); + return sa.text === sb.text && sa.media === sb.media; +} + +function findOptimisticUserEchoIndex(messages: AppMessage[], message: AppMessage): number { + // Prefer matching by prompt_id: image content serializes differently between + // our optimistic copy ({source:{kind:'file',fileId}}) and the daemon's echo + // (a resolved URL/base64), so content-equality alone lets an image steer's + // echo slip through as a duplicate. The submit response's prompt_id is stamped + // onto the optimistic message, so a shared prompt_id is the reliable match. + const promptId = message.promptId; + if (promptId !== undefined) { + for (let i = messages.length - 1; i >= 0; i--) { + const candidate = messages[i]!; + if (isOptimisticUserMessage(candidate) && candidate.promptId === promptId) { + return i; + } + } + } + for (let i = messages.length - 1; i >= 0; i--) { + const candidate = messages[i]!; + if (isOptimisticUserMessage(candidate) && sameMessageContent(candidate, message)) { + return i; + } + } + // Loose fallback for image steers: the daemon's messageCreated echo can arrive + // over the WS *before* submitPrompt resolves and stamps the prompt_id onto the + // optimistic copy, so neither the prompt_id nor the exact-content match fires — + // and because the image serializes differently, the echo used to slip through + // as a SECOND user bubble. Match on (text, image-count) instead so the echo + // still reconciles into the optimistic message. + for (let i = messages.length - 1; i >= 0; i--) { + const candidate = messages[i]!; + if (isOptimisticUserMessage(candidate) && sameUserMessageLoosely(candidate, message)) { + return i; + } + } + return -1; +} + +function appendToolOutputToMessages(messages: AppMessage[], toolCallId: string, outputChunk: string): AppMessage[] { + let changed = false; + const next = messages.map((message) => { + let contentChanged = false; + const content = message.content.map((part) => { + if (part.type !== 'toolUse' || part.toolCallId !== toolCallId) return part; + contentChanged = true; + return { + ...part, + outputLines: [...(part.outputLines ?? []), outputChunk], + }; + }); + if (!contentChanged) return message; + changed = true; + return { ...message, content }; + }); + return changed ? next : messages; +} + +// --------------------------------------------------------------------------- +// Reducer +// --------------------------------------------------------------------------- + +/** + * Apply a single AppEvent to the state, returning a new state object. + * The event carries `_wireSeq` and `_wireSessionId` as hidden extras when + * produced by the client wrapper, but the reducer only depends on the + * AppEvent.type discriminant. + * + * Extra metadata attached by the caller: + * meta.sessionId — wire session_id for lastSeqBySession update + * meta.seq — wire seq for lastSeqBySession update + */ +export interface EventMeta { + sessionId: string; + seq: number; +} + +export function reduceAppEvent( + state: KimiClientState, + event: AppEvent, + meta: EventMeta, +): KimiClientState { + const next = cloneState(state); + + // Always advance lastSeqBySession for every event that carries seq info. + advanceSeq(next, meta.sessionId, meta.seq); + + switch (event.type) { + // ------------------------------------------------------------------------- + case 'sessionCreated': { + const exists = next.sessions.some((s) => s.id === event.session.id); + if (!exists) { + next.sessions = [event.session, ...next.sessions]; + } + break; + } + + // ------------------------------------------------------------------------- + case 'sessionUpdated': { + next.sessions = next.sessions.map((s) => + s.id === event.session.id ? event.session : s, + ); + break; + } + + // ------------------------------------------------------------------------- + case 'sessionDeleted': { + const id = event.sessionId; + next.sessions = next.sessions.filter((s) => s.id !== id); + delete next.messagesBySession[id]; + delete next.tasksBySession[id]; + delete next.goalBySession[id]; + delete next.approvalsBySession[id]; + delete next.questionsBySession[id]; + delete next.lastSeqBySession[id]; + if (next.activeSessionId === id) { + next.activeSessionId = undefined; + } + break; + } + + // ------------------------------------------------------------------------- + case 'sessionStatusChanged': { + next.sessions = next.sessions.map((s) => { + if (s.id !== event.sessionId) return s; + return { + ...s, + status: event.status, + currentPromptId: event.currentPromptId, + }; + }); + break; + } + + // ------------------------------------------------------------------------- + case 'sessionMetaUpdated': { + // Lightweight title patch — the daemon's auto-generated title (or a title + // changed by another client) arrives via session.meta.updated. We patch + // only the title field; the full session object stays as-is. + next.sessions = next.sessions.map((s) => + s.id === event.sessionId ? { ...s, title: event.title } : s, + ); + break; + } + + // ------------------------------------------------------------------------- + case 'sessionUsageUpdated': { + next.sessions = next.sessions.map((s) => { + if (s.id !== event.sessionId) return s; + // The live model name (from agent.status.updated) rides along with usage. + // Only overwrite model when a non-empty one is supplied. + const model = event.model && event.model.length > 0 ? event.model : s.model; + return { ...s, usage: event.usage, model }; + }); + break; + } + + // ------------------------------------------------------------------------- + case 'historyCompacted': { + // Only advance lastSeqBySession; actual reload is triggered by client wrapper + // when it sees this event type (before_seq is in event.beforeSeq). + // The advanceSeq at top already handled seq update. + break; + } + + // ------------------------------------------------------------------------- + case 'compactionStarted': { + next.compactionBySession = { + ...next.compactionBySession, + [event.sessionId]: { status: 'running', trigger: event.trigger }, + }; + break; + } + + case 'compactionCompleted': { + const sid = event.sessionId; + const prev = next.compactionBySession[sid]; + const { [sid]: _doneEntry, ...rest } = next.compactionBySession; + next.compactionBySession = rest; + + // Append a persistent "context compacted" divider to the loaded + // transcript (TUI parity: the scrollback is kept untouched; only a + // one-line marker records that compaction happened). The marker id is + // derived from the wire seq so an event replay after reconnect can't + // duplicate it. + if (Object.prototype.hasOwnProperty.call(next.messagesBySession, sid)) { + const msgs = next.messagesBySession[sid] ?? []; + const markerId = `compaction_${sid}_${meta.seq}`; + if (!msgs.some((m) => m.id === markerId)) { + const marker: CompactionMarkerMetadata = { + trigger: prev?.trigger ?? 'auto', + tokensBefore: event.tokensBefore, + tokensAfter: event.tokensAfter, + }; + next.messagesBySession[sid] = [ + ...msgs, + { + id: markerId, + sessionId: sid, + role: 'assistant', + content: event.summary ? [{ type: 'text', text: event.summary }] : [], + createdAt: new Date().toISOString(), + metadata: { + origin: { kind: 'compaction_summary' }, + [COMPACTION_MARKER_METADATA_KEY]: marker, + }, + }, + ]; + } + } + break; + } + + case 'compactionCancelled': { + const { [event.sessionId]: _gone, ...rest } = next.compactionBySession; + next.compactionBySession = rest; + break; + } + + // ------------------------------------------------------------------------- + case 'messageCreated': { + const sid = event.message.sessionId; + const msgs = next.messagesBySession[sid] ?? []; + const exists = msgs.some((m) => m.id === event.message.id); + if (!exists) { + if (event.message.role === 'user') { + const optimisticIndex = findOptimisticUserEchoIndex(msgs, event.message); + if (optimisticIndex !== -1) { + const updated = [...msgs]; + const optimistic = updated[optimisticIndex]!; + updated[optimisticIndex] = { + ...event.message, + id: optimistic.id, + promptId: event.message.promptId ?? optimistic.promptId, + metadata: { + ...event.message.metadata, + ...optimistic.metadata, + }, + }; + next.messagesBySession[sid] = updated; + break; + } + } + next.messagesBySession[sid] = [...msgs, event.message]; + } + break; + } + + // ------------------------------------------------------------------------- + case 'messageUpdated': { + const sid = event.sessionId; + const msgs = next.messagesBySession[sid] ?? []; + next.messagesBySession[sid] = msgs.map((m) => { + if (m.id !== event.messageId) return m; + return { + ...m, + content: event.content, + durationMs: event.durationMs ?? m.durationMs, + }; + }); + break; + } + + // ------------------------------------------------------------------------- + case 'assistantDelta': { + const sid = event.sessionId; + const msgs = next.messagesBySession[sid] ?? []; + next.messagesBySession[sid] = msgs.map((m) => { + if (m.id !== event.messageId) return m; + const content = [...m.content]; + const idx = event.contentIndex; + // Ensure the slot exists + while (content.length <= idx) { + content.push({ type: 'text', text: '' }); + } + const existing = content[idx]!; + let patched: AppMessageContent; + if (event.delta.text !== undefined) { + if (existing.type === 'text') { + patched = { type: 'text', text: existing.text + event.delta.text }; + } else { + patched = { type: 'text', text: event.delta.text }; + } + } else if (event.delta.thinking !== undefined) { + if (existing.type === 'thinking') { + patched = { + type: 'thinking', + thinking: existing.thinking + event.delta.thinking, + signature: existing.signature, + }; + } else { + patched = { type: 'thinking', thinking: event.delta.thinking }; + } + } else { + patched = existing; + } + content[idx] = patched; + return { ...m, content }; + }); + break; + } + + // ------------------------------------------------------------------------- + case 'toolOutput': { + const sid = event.sessionId; + const msgs = next.messagesBySession[sid] ?? []; + next.messagesBySession[sid] = appendToolOutputToMessages(msgs, event.toolCallId, event.outputChunk); + break; + } + + // ------------------------------------------------------------------------- + case 'approvalRequested': { + const sid = event.sessionId; + const list = next.approvalsBySession[sid] ?? []; + const exists = list.some((a) => a.approvalId === event.approval.approvalId); + if (!exists) { + next.approvalsBySession[sid] = [...list, event.approval]; + } + break; + } + + // ------------------------------------------------------------------------- + case 'approvalResolved': + case 'approvalExpired': { + const sid = event.sessionId; + const aid = event.approvalId; + const list = next.approvalsBySession[sid] ?? []; + next.approvalsBySession[sid] = list.filter((a) => a.approvalId !== aid); + break; + } + + // ------------------------------------------------------------------------- + case 'questionRequested': { + const sid = event.sessionId; + const list = next.questionsBySession[sid] ?? []; + const exists = list.some((q) => q.questionId === event.question.questionId); + if (!exists) { + next.questionsBySession[sid] = [...list, event.question]; + } + break; + } + + // ------------------------------------------------------------------------- + case 'questionAnswered': + case 'questionDismissed': + case 'questionExpired': { + const sid = event.sessionId; + const qid = event.questionId; + const list = next.questionsBySession[sid] ?? []; + next.questionsBySession[sid] = list.filter((q) => q.questionId !== qid); + break; + } + + // ------------------------------------------------------------------------- + case 'taskCreated': { + const sid = event.sessionId; + const list = next.tasksBySession[sid] ?? []; + const idx = list.findIndex((t) => t.id === event.task.id); + if (idx === -1) { + next.tasksBySession[sid] = [...list, event.task]; + } else { + const patched = [...list]; + patched[idx] = event.task; + next.tasksBySession[sid] = patched; + } + break; + } + + // ------------------------------------------------------------------------- + case 'taskProgress': { + const sid = event.sessionId; + const list = next.tasksBySession[sid] ?? []; + next.tasksBySession[sid] = list.map((t) => { + if (t.id !== event.taskId) return t; + const outputLines = t.outputLines ?? []; + if (outputLines.at(-1) === event.outputChunk) return t; + return { + ...t, + outputLines: [...outputLines, event.outputChunk].slice(-40), + }; + }); + break; + } + + // ------------------------------------------------------------------------- + case 'taskCompleted': { + const sid = event.sessionId; + const list = next.tasksBySession[sid] ?? []; + next.tasksBySession[sid] = list.map((t) => { + if (t.id !== event.taskId) return t; + return { + ...t, + status: event.status, + outputPreview: event.outputPreview, + outputBytes: event.outputBytes, + }; + }); + break; + } + + // ------------------------------------------------------------------------- + case 'goalUpdated': { + const sid = event.sessionId; + if (event.goal === null || event.goal.status === 'complete') { + delete next.goalBySession[sid]; + } else { + next.goalBySession[sid] = event.goal; + } + break; + } + + // ------------------------------------------------------------------------- + case 'configChanged': { + next.config = event.config; + break; + } + + // ------------------------------------------------------------------------- + // Agent-scoped side-channel events (e.g. BTW side chat) are consumed by the + // web layer, not the session reducer. Advance seq silently. + case 'agentDelta': + case 'agentTurnEnded': + break; + + case 'unknown': { + // Distinguish no-op known events (sentinel _noop) from agent errors/warnings + // and truly unknown events. + const raw = event.raw as { + _noop?: boolean; + _agentError?: boolean; + _agentWarning?: boolean; + code?: string; + message?: string; + type?: string; + } | null; + if (raw && raw._noop === true) { + // No-op streaming/tool event — seq already advanced, nothing else to do + } else if (raw && (raw._agentError || raw._agentWarning)) { + // Surface the agent's real error/warning message (e.g. a 403 from the + // model provider) instead of a useless "Unhandled event". + const label = raw._agentError + ? i18n.global.t('warnings.errorLabel') + : i18n.global.t('warnings.noteLabel'); + const msg = raw.message ?? raw.code ?? 'agent error'; + next.warnings = [...next.warnings, `${label}: ${msg}`]; + } else { + // Truly unknown — push a warning + const wireType = raw?.type ?? '(unknown)'; + next.warnings = [...next.warnings, `Unhandled event: ${wireType}`]; + } + break; + } + + // Workspace lifecycle events are handled in the composable (rawState), not + // here — listed explicitly to keep the switch exhaustive. + case 'workspaceCreated': + case 'workspaceUpdated': + case 'workspaceDeleted': + break; + + default: { + // TypeScript exhaustiveness guard — should not reach here + const _exhaustive: never = event; + void _exhaustive; + break; + } + } + + return next; +} diff --git a/apps/kimi-web/src/api/daemon/http.ts b/apps/kimi-web/src/api/daemon/http.ts new file mode 100644 index 000000000..57ab317fd --- /dev/null +++ b/apps/kimi-web/src/api/daemon/http.ts @@ -0,0 +1,290 @@ +// apps/kimi-web/src/api/daemon/http.ts +// DaemonHttpClient — REST transport with envelope unwrap and allowCodes support. + +import { buildRestUrl } from '../config'; +import { DaemonApiError, DaemonNetworkError } from '../errors'; +import { traceRestFailure, traceRestRequest, traceRestResponse } from '../../debug/trace'; +import type { WireEnvelope } from './wire'; + +/** Per-request timeout. Without one, a hung connection (half-open TCP after a + network change, stuck daemon) leaves promises pending for minutes — and the + composer's in-flight flag with them. Generous enough for slow endpoints; + streaming runs over the WS, not these REST calls. */ +const REQUEST_TIMEOUT_MS = 30_000; +const ULID_ALPHABET = '0123456789ABCDEFGHJKMNPQRSTVWXYZ'; +const BODY_PREVIEW_LIMIT = 500; + +export interface DaemonHttpClientIdentity { + readonly clientId: string; + readonly clientName: string; + readonly clientVersion: string; + readonly clientUiMode: string; +} + +/** AbortSignal.timeout with a fallback for older environments (jsdom). */ +function timeoutSignal(): AbortSignal | undefined { + try { + return AbortSignal.timeout(REQUEST_TIMEOUT_MS); + } catch { + return undefined; + } +} + +function encodeBase32(value: number, length: number): string { + let out = ''; + let next = value; + for (let i = 0; i < length; i++) { + out = ULID_ALPHABET[next % 32] + out; + next = Math.floor(next / 32); + } + return out; +} + +function randomBase32(length: number): string { + const bytes = new Uint8Array(length); + if (globalThis.crypto?.getRandomValues) { + globalThis.crypto.getRandomValues(bytes); + } else { + for (let i = 0; i < bytes.length; i++) { + bytes[i] = Math.floor(Math.random() * 256); + } + } + return Array.from(bytes, (byte) => ULID_ALPHABET[byte % 32]).join(''); +} + +function createRequestId(): string { + return `${encodeBase32(Date.now(), 10)}${randomBase32(16)}`; +} + +/** Trace-only FormData summary: field names + file name/size/type, never content. */ +function describeFormData(formData: FormData): unknown { + try { + const fields: Array> = []; + formData.forEach((value, field) => { + if (typeof value === 'string') { + fields.push({ field, value }); + } else { + fields.push({ field, file: value.name, size: value.size, type: value.type }); + } + }); + return { formData: fields }; + } catch { + return '[FormData]'; + } +} + +async function readResponsePreview(response: Response): Promise { + try { + const text = await response.text(); + if (!text) return undefined; + return text.length > BODY_PREVIEW_LIMIT ? `${text.slice(0, BODY_PREVIEW_LIMIT)}...` : text; + } catch { + return undefined; + } +} + +export class DaemonHttpClient { + constructor( + private readonly origin: string, + private readonly identity?: DaemonHttpClientIdentity, + ) {} + + async get(path: string, query?: Record): Promise { + return this.request('GET', path, undefined, query); + } + + async post(path: string, body?: unknown, opts?: { allowCodes?: number[] }): Promise { + return this.request('POST', path, body, undefined, opts?.allowCodes); + } + + /** Send multipart/form-data (FormData). Does NOT set Content-Type — browser sets it with boundary. */ + async postForm(path: string, formData: FormData): Promise { + const url = buildRestUrl(this.origin, path); + const requestId = createRequestId(); + const headers: Record = { + 'X-Request-Id': requestId, + }; + this.addClientHeaders(headers); + const startedAt = Date.now(); + traceRestRequest({ method: 'POST', path, url, requestId, body: describeFormData(formData) }); + let response: Response; + try { + response = await fetch(url, { method: 'POST', headers, body: formData, signal: timeoutSignal() }); + } catch (err) { + traceRestFailure({ method: 'POST', path, requestId, phase: 'fetch', durationMs: Date.now() - startedAt, error: err }); + throw new DaemonNetworkError({ + message: `Network error calling POST ${path}`, + cause: err, + method: 'POST', + path, + url, + requestId, + phase: 'fetch', + timeoutMs: REQUEST_TIMEOUT_MS, + }); + } + let envelope: WireEnvelope; + const responseForDiagnostics = response.clone(); + try { + envelope = (await response.json()) as WireEnvelope; + } catch (err) { + traceRestFailure({ method: 'POST', path, requestId, phase: 'parse', durationMs: Date.now() - startedAt, status: response.status, error: err }); + throw new DaemonNetworkError({ + message: `Failed to parse JSON response from POST ${path}`, + cause: err, + method: 'POST', + path, + url, + requestId, + phase: 'parse', + timeoutMs: REQUEST_TIMEOUT_MS, + status: response.status, + statusText: response.statusText, + contentType: response.headers.get('content-type') ?? undefined, + bodyPreview: await readResponsePreview(responseForDiagnostics), + }); + } + traceRestResponse({ + method: 'POST', + path, + requestId, + status: response.status, + durationMs: Date.now() - startedAt, + code: envelope.code, + msg: envelope.msg, + envelopeRequestId: envelope.request_id, + data: envelope.data, + }); + if (envelope.code !== 0) { + throw new DaemonApiError({ + code: envelope.code, + msg: envelope.msg, + requestId: envelope.request_id, + details: envelope.details, + }); + } + return envelope.data as T; + } + + async patch(path: string, body: unknown): Promise { + return this.request('PATCH', path, body); + } + + async delete(path: string): Promise { + return this.request('DELETE', path); + } + + private async request( + method: string, + path: string, + body?: unknown, + query?: Record, + allowCodes: number[] = [], + ): Promise { + // Build URL, appending query string (omit undefined values) + let url = buildRestUrl(this.origin, path); + if (query) { + const params = new URLSearchParams(); + for (const [key, value] of Object.entries(query)) { + if (value !== undefined) { + params.set(key, String(value)); + } + } + const qs = params.toString(); + if (qs) url = `${url}?${qs}`; + } + + // Build headers + const requestId = createRequestId(); + const headers: Record = { + 'X-Request-Id': requestId, + }; + this.addClientHeaders(headers); + if (body !== undefined) { + headers['Content-Type'] = 'application/json; charset=utf-8'; + } + + const startedAt = Date.now(); + traceRestRequest({ method, path, url, requestId, body }); + + // Execute fetch + let response: Response; + try { + response = await fetch(url, { + method, + headers, + body: body !== undefined ? JSON.stringify(body) : undefined, + signal: timeoutSignal(), + }); + } catch (err) { + traceRestFailure({ method, path, requestId, phase: 'fetch', durationMs: Date.now() - startedAt, error: err }); + throw new DaemonNetworkError({ + message: `Network error calling ${method} ${path}`, + cause: err, + method, + path, + url, + requestId, + phase: 'fetch', + timeoutMs: REQUEST_TIMEOUT_MS, + }); + } + + // Parse envelope + let envelope: WireEnvelope; + const responseForDiagnostics = response.clone(); + try { + envelope = (await response.json()) as WireEnvelope; + } catch (err) { + traceRestFailure({ method, path, requestId, phase: 'parse', durationMs: Date.now() - startedAt, status: response.status, error: err }); + throw new DaemonNetworkError({ + message: `Failed to parse JSON response from ${method} ${path}`, + cause: err, + method, + path, + url, + requestId, + phase: 'parse', + timeoutMs: REQUEST_TIMEOUT_MS, + status: response.status, + statusText: response.statusText, + contentType: response.headers.get('content-type') ?? undefined, + bodyPreview: await readResponsePreview(responseForDiagnostics), + }); + } + + traceRestResponse({ + method, + path, + requestId, + status: response.status, + durationMs: Date.now() - startedAt, + code: envelope.code, + msg: envelope.msg, + envelopeRequestId: envelope.request_id, + data: envelope.data, + }); + + // Unwrap: code 0 = success; allowed non-zero = return data; else throw + if (envelope.code !== 0 && !allowCodes.includes(envelope.code)) { + throw new DaemonApiError({ + code: envelope.code, + msg: envelope.msg, + requestId: envelope.request_id, + details: envelope.details, + }); + } + + // For both code=0 and allowed non-zero codes, return the data field. + // Callers that pass allowCodes handle the null/non-null data themselves. + return envelope.data as T; + } + + private addClientHeaders(headers: Record): void { + if (this.identity === undefined) return; + headers['X-Kimi-Client-Id'] = this.identity.clientId; + headers['X-Kimi-Client-Name'] = this.identity.clientName; + headers['X-Kimi-Client-Version'] = this.identity.clientVersion; + headers['X-Kimi-Client-Ui-Mode'] = this.identity.clientUiMode; + } +} diff --git a/apps/kimi-web/src/api/daemon/mappers.ts b/apps/kimi-web/src/api/daemon/mappers.ts new file mode 100644 index 000000000..0670b8f34 --- /dev/null +++ b/apps/kimi-web/src/api/daemon/mappers.ts @@ -0,0 +1,764 @@ +// apps/kimi-web/src/api/daemon/mappers.ts +// wire→app and app→wire mapper functions. +// All snake_case ↔ camelCase conversion happens ONLY here. + +import type { + AppApprovalRequest, + AppConfig, + AppEvent, + AppGoal, + AppModel, + AppProvider, + FsEntry, + AppMessage, + AppMessageContent, + AppMessageRole, + AppQuestionRequest, + AppSession, + AppSessionStatus, + AppSessionUsage, + AppTask, + AppTaskStatus, + AppWorkspace, + ApprovalResponse, + ImageSource, + PromptSubmission, + QuestionAnswer, + QuestionItem, + QuestionOption, + QuestionResponse, +} from '../types'; + +import type { + WireApprovalRequest, + WireApprovalResponse, + WireBackgroundTask, + WireFsEntry, + WireImageSource, + WireMessage, + WireMessageContent, + WireModel, + WirePromptSubmission, + WireProvider, + WireQuestionAnswer, + WireQuestionItem, + WireQuestionOption, + WireQuestionRequest, + WireQuestionResponse, + WireSession, + WireSessionStatus, + WireSessionUsage, + WireWorkspace, + WireEvent, + WireConfig, +} from './wire'; + +// --------------------------------------------------------------------------- +// Session mappers +// --------------------------------------------------------------------------- + +export function toAppSessionUsage(wire: WireSessionUsage): AppSessionUsage { + return { + inputTokens: wire.input_tokens, + outputTokens: wire.output_tokens, + cacheReadTokens: wire.cache_read_tokens, + cacheCreationTokens: wire.cache_creation_tokens, + totalCostUsd: wire.total_cost_usd, + contextTokens: wire.context_tokens, + contextLimit: wire.context_limit, + turnCount: wire.turn_count, + }; +} + +export function toAppSessionStatus(wire: WireSessionStatus): AppSessionStatus { + switch (wire) { + case 'idle': return 'idle'; + case 'running': return 'running'; + case 'awaiting_approval': return 'awaitingApproval'; + case 'awaiting_question': return 'awaitingQuestion'; + case 'aborted': return 'aborted'; + } +} + +export function toWireSessionStatus(status: AppSessionStatus): WireSessionStatus { + switch (status) { + case 'idle': return 'idle'; + case 'running': return 'running'; + case 'awaitingApproval': return 'awaiting_approval'; + case 'awaitingQuestion': return 'awaiting_question'; + case 'aborted': return 'aborted'; + } +} + +export function toAppSession(wire: WireSession): AppSession { + return { + id: wire.id, + title: wire.title, + createdAt: wire.created_at, + updatedAt: wire.updated_at, + status: toAppSessionStatus(wire.status), + archived: wire.archived ?? false, + currentPromptId: wire.current_prompt_id, + cwd: wire.metadata.cwd, + model: wire.agent_config.model, + usage: toAppSessionUsage(wire.usage), + messageCount: wire.message_count, + lastSeq: wire.last_seq, + workspaceId: wire.workspace_id, + parentSessionId: + typeof wire.metadata['parent_session_id'] === 'string' + ? wire.metadata['parent_session_id'] + : undefined, + }; +} + +export function toAppWorkspace(wire: WireWorkspace): AppWorkspace { + return { + id: wire.id, + root: wire.root, + name: wire.name, + isGitRepo: wire.is_git_repo, + branch: wire.branch ?? undefined, + lastOpenedAt: wire.last_opened_at, + sessionCount: wire.session_count, + }; +} + +// --------------------------------------------------------------------------- +// Message mappers +// --------------------------------------------------------------------------- + +function toAppImageSource(src: WireImageSource): ImageSource { + if (src.kind === 'base64') { + return { kind: 'base64', mediaType: src.media_type, data: src.data }; + } + if (src.kind === 'file') { + return { kind: 'file', fileId: src.file_id }; + } + return { kind: 'url', url: src.url }; +} + +export function toAppMessageContent(wire: WireMessageContent): AppMessageContent { + switch (wire.type) { + case 'text': + return { type: 'text', text: wire.text }; + case 'tool_use': + return { + type: 'toolUse', + toolCallId: wire.tool_call_id, + toolName: wire.tool_name, + input: wire.input, + }; + case 'tool_result': + return { + type: 'toolResult', + toolCallId: wire.tool_call_id, + output: wire.output, + isError: wire.is_error, + }; + case 'image': + return { + type: 'image', + source: toAppImageSource(wire.source), + }; + case 'video': + return { + type: 'video', + source: toAppImageSource(wire.source), + }; + case 'file': + return { + type: 'file', + fileId: wire.file_id, + name: wire.name, + mediaType: wire.media_type, + size: wire.size, + }; + case 'thinking': + return { + type: 'thinking', + thinking: wire.thinking, + signature: wire.signature, + }; + default: { + // Unknown content type — pass raw through + return { type: 'unknown', raw: wire }; + } + } +} + +export function toAppMessage(wire: WireMessage): AppMessage { + return { + id: wire.id, + sessionId: wire.session_id, + role: wire.role as AppMessageRole, + content: wire.content.map(toAppMessageContent), + createdAt: wire.created_at, + promptId: wire.prompt_id, + parentMessageId: wire.parent_message_id, + metadata: wire.metadata, + }; +} + +// --------------------------------------------------------------------------- +// Prompt mappers +// --------------------------------------------------------------------------- + +function toWireMessageContent(app: AppMessageContent): WireMessageContent { + switch (app.type) { + case 'text': + return { type: 'text', text: app.text }; + case 'toolUse': + return { + type: 'tool_use', + tool_call_id: app.toolCallId, + tool_name: app.toolName, + input: app.input, + }; + case 'toolResult': + return { + type: 'tool_result', + tool_call_id: app.toolCallId, + output: app.output, + is_error: app.isError, + }; + case 'image': + case 'video': { + const src = app.source; + let wireSrc: WireImageSource; + if (src.kind === 'base64') { + wireSrc = { kind: 'base64', media_type: src.mediaType, data: src.data }; + } else if (src.kind === 'file') { + wireSrc = { kind: 'file', file_id: src.fileId }; + } else { + wireSrc = { kind: 'url', url: src.url }; + } + return { type: app.type, source: wireSrc }; + } + case 'file': + return { + type: 'file', + file_id: app.fileId, + name: app.name, + media_type: app.mediaType, + size: app.size, + }; + case 'thinking': + return { type: 'thinking', thinking: app.thinking, signature: app.signature }; + case 'unknown': + // Best-effort: pass raw back. May not be a valid WireMessageContent. + return app.raw as WireMessageContent; + } +} + +export function toWirePromptSubmission(input: PromptSubmission): WirePromptSubmission { + return { + content: input.content.map(toWireMessageContent), + metadata: input.metadata, + agent_id: input.agentId, + model: input.model, + thinking: input.thinking, + permission_mode: input.permissionMode, + plan_mode: input.planMode, + swarm_mode: input.swarmMode, + goal_objective: input.goalObjective, + goal_control: input.goalControl, + }; +} + +// --------------------------------------------------------------------------- +// Approval mappers +// --------------------------------------------------------------------------- + +export function toWireApprovalResponse(input: ApprovalResponse): WireApprovalResponse { + return { + decision: input.decision, + scope: input.scope, + feedback: input.feedback, + selected_label: input.selectedLabel, + }; +} + +export function toAppApprovalRequest(wire: WireApprovalRequest): AppApprovalRequest { + return { + approvalId: wire.approval_id, + sessionId: wire.session_id, + turnId: wire.turn_id, + toolCallId: wire.tool_call_id, + toolName: wire.tool_name, + action: wire.action, + // The real daemon sends `tool_input_display`; the stub sends `display`. + display: wire.tool_input_display ?? wire.display, + expiresAt: wire.expires_at, + createdAt: wire.created_at, + }; +} + +// --------------------------------------------------------------------------- +// Question mappers +// --------------------------------------------------------------------------- + +function toAppQuestionOption(wire: WireQuestionOption): QuestionOption { + return { + id: wire.id, + label: wire.label, + description: wire.description, + recommended: wire.recommended === true || wire.is_recommended === true, + }; +} + +function toAppQuestionItem(wire: WireQuestionItem): QuestionItem { + return { + id: wire.id, + question: wire.question, + header: wire.header, + body: wire.body, + options: wire.options.map(toAppQuestionOption), + multiSelect: wire.multi_select, + allowOther: wire.allow_other, + otherLabel: wire.other_label, + otherDescription: wire.other_description, + }; +} + +export function toAppQuestionRequest(wire: WireQuestionRequest): AppQuestionRequest { + return { + questionId: wire.question_id, + sessionId: wire.session_id, + turnId: wire.turn_id, + toolCallId: wire.tool_call_id, + questions: wire.questions.map(toAppQuestionItem), + expiresAt: wire.expires_at, + createdAt: wire.created_at, + }; +} + +function toWireQuestionAnswer(app: QuestionAnswer): WireQuestionAnswer { + switch (app.kind) { + case 'single': + return { kind: 'single', option_id: app.optionId }; + case 'multi': + return { kind: 'multi', option_ids: app.optionIds }; + case 'other': + return { kind: 'other', text: app.text }; + case 'multiWithOther': + return { kind: 'multi_with_other', option_ids: app.optionIds, other_text: app.otherText }; + case 'skipped': + return { kind: 'skipped' }; + } +} + +export function toWireQuestionResponse(input: QuestionResponse): WireQuestionResponse { + const wireAnswers: Record = {}; + for (const [questionId, answer] of Object.entries(input.answers)) { + wireAnswers[questionId] = toWireQuestionAnswer(answer); + } + return { + answers: wireAnswers, + method: input.method, + note: input.note, + }; +} + +// --------------------------------------------------------------------------- +// Task mapper +// --------------------------------------------------------------------------- + +export function toAppTask(wire: WireBackgroundTask): AppTask { + return { + id: wire.id, + sessionId: wire.session_id, + kind: wire.kind, + description: wire.description, + status: wire.status as AppTaskStatus, + command: wire.command, + createdAt: wire.created_at, + startedAt: wire.started_at, + completedAt: wire.completed_at, + outputPreview: wire.output_preview, + outputBytes: wire.output_bytes, + subagentPhase: wire.subagent_phase, + subagentType: wire.subagent_type, + parentToolCallId: wire.parent_tool_call_id, + suspendedReason: wire.suspended_reason, + swarmIndex: wire.swarm_index, + // outputLines starts undefined; populated by eventReducer via task.progress events + }; +} + +// --------------------------------------------------------------------------- +// FsEntry mapper +// --------------------------------------------------------------------------- + +export function toAppFsEntry(wire: WireFsEntry): FsEntry { + return { + path: wire.path, + name: wire.name, + kind: wire.kind, + size: wire.size, + modifiedAt: wire.modified_at, + etag: wire.etag, + mime: wire.mime, + languageId: wire.language_id, + isBinary: wire.is_binary, + isSymlinkTo: wire.is_symlink_to, + gitStatus: wire.git_status, + childCount: wire.child_count, + }; +} + +// --------------------------------------------------------------------------- +// WireEvent → AppEvent +// --------------------------------------------------------------------------- + +function recordString(source: Record, key: string): string | undefined { + const value = source[key]; + return typeof value === 'string' ? value : undefined; +} + +function recordNumber(source: Record, key: string): number | undefined { + const value = source[key]; + return typeof value === 'number' && Number.isFinite(value) ? value : undefined; +} + +function recordNullableNumber(source: Record, key: string): number | null { + const value = source[key]; + return typeof value === 'number' && Number.isFinite(value) ? value : null; +} + +function toAppGoal(snapshot: unknown): AppGoal | null { + if (!snapshot || typeof snapshot !== 'object') return null; + const source = snapshot as Record; + const status = recordString(source, 'status'); + if (status !== 'active' && status !== 'paused' && status !== 'blocked' && status !== 'complete') { + return null; + } + + const budgetRaw = source['budget']; + const budget = budgetRaw && typeof budgetRaw === 'object' ? budgetRaw as Record : {}; + + return { + goalId: recordString(source, 'goalId') ?? recordString(source, 'goal_id') ?? 'goal', + objective: recordString(source, 'objective') ?? '', + completionCriterion: recordString(source, 'completionCriterion') ?? recordString(source, 'completion_criterion'), + status, + turnsUsed: recordNumber(source, 'turnsUsed') ?? recordNumber(source, 'turns_used') ?? 0, + tokensUsed: recordNumber(source, 'tokensUsed') ?? recordNumber(source, 'tokens_used') ?? 0, + wallClockMs: recordNumber(source, 'wallClockMs') ?? recordNumber(source, 'wall_clock_ms') ?? 0, + terminalReason: recordString(source, 'terminalReason') ?? recordString(source, 'terminal_reason'), + budget: { + tokenBudget: recordNullableNumber(budget, 'tokenBudget') ?? recordNullableNumber(budget, 'token_budget'), + remainingTokens: recordNullableNumber(budget, 'remainingTokens') ?? recordNullableNumber(budget, 'remaining_tokens'), + turnBudget: recordNullableNumber(budget, 'turnBudget') ?? recordNullableNumber(budget, 'turn_budget'), + remainingTurns: recordNullableNumber(budget, 'remainingTurns') ?? recordNullableNumber(budget, 'remaining_turns'), + wallClockBudgetMs: recordNullableNumber(budget, 'wallClockBudgetMs') ?? recordNullableNumber(budget, 'wall_clock_budget_ms'), + remainingWallClockMs: recordNullableNumber(budget, 'remainingWallClockMs') ?? recordNullableNumber(budget, 'remaining_wall_clock_ms'), + overBudget: budget['overBudget'] === true || budget['over_budget'] === true, + }, + }; +} + +/** + * Map a WireEvent to an AppEvent. + * + * Decision: reducer consumes AppEvent. + * - Visible events are fully mapped to their camelCase AppEvent variant. + * - No-op-but-known streaming/tool events (tool.*, assistant.tool_use_*, + * assistant.completed) are folded to { type: 'unknown', raw } so the reducer + * can advance lastSeqBySession without emitting warnings. + * We use a dedicated sentinel raw: { _noop: true } so Task 7 reducer can + * distinguish real unknowns (push warning) from no-op knowns (silent advance). + * - Truly unknown events are also { type: 'unknown', raw } but raw._noop is absent. + */ +export function toAppEvent(wire: WireEvent): AppEvent { + // TypeScript cannot narrow the WireEvent union through specific `case` arms + // because the catch-all `WireEventUnknown` member has `type: string` (broad) + // and `payload: unknown`, which prevents discriminated-union narrowing. + // We cast to `any` once here; individual cases are still logically type-safe + // because the union member types document the actual payload shapes. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const w = wire as any; + switch ((wire as { type: string }).type) { + // ----- Session lifecycle ----- + case 'event.session.created': + return { type: 'sessionCreated', session: toAppSession(w.payload.session) }; + + case 'event.session.updated': + return { + type: 'sessionUpdated', + session: toAppSession(w.payload.session), + changedFields: w.payload.changed_fields, + }; + + case 'event.session.deleted': + return { type: 'sessionDeleted', sessionId: w.session_id }; + + // ----- Workspace lifecycle ----- + case 'event.workspace.created': + return { type: 'workspaceCreated', workspace: toAppWorkspace(w.payload.workspace) }; + + case 'event.workspace.updated': + return { type: 'workspaceUpdated', workspace: toAppWorkspace(w.payload.workspace) }; + + case 'event.workspace.deleted': + return { + type: 'workspaceDeleted', + workspaceId: w.payload.workspace_id, + root: w.payload.root, + }; + + case 'event.session.status_changed': + return { + type: 'sessionStatusChanged', + sessionId: w.session_id, + status: toAppSessionStatus(w.payload.status), + previousStatus: toAppSessionStatus(w.payload.previous_status), + currentPromptId: w.payload.current_prompt_id, + }; + + case 'event.session.usage_updated': + return { + type: 'sessionUsageUpdated', + sessionId: w.session_id, + usage: toAppSessionUsage(w.payload.usage), + }; + + case 'event.session.history_compacted': + return { + type: 'historyCompacted', + sessionId: w.session_id, + beforeSeq: w.payload.before_seq, + reason: w.payload.reason, + summaryMessageId: w.payload.summary_message_id, + }; + + case 'event.goal.updated': { + const goal = toAppGoal(w.payload.snapshot ?? null); + return { + type: 'goalUpdated', + sessionId: w.session_id, + goal: goal?.status === 'complete' ? null : goal, + }; + } + + // ----- Message lifecycle ----- + case 'event.message.created': + return { type: 'messageCreated', message: toAppMessage(w.payload.message) }; + + case 'event.message.updated': + return { + type: 'messageUpdated', + sessionId: w.session_id, + messageId: w.payload.message_id, + content: w.payload.content.map(toAppMessageContent), + status: w.payload.status, + }; + + // ----- Assistant streaming ----- + case 'event.assistant.delta': + return { + type: 'assistantDelta', + sessionId: w.session_id, + messageId: w.payload.message_id, + contentIndex: w.payload.content_index, + delta: w.payload.delta, + }; + + // No-op streaming events — advance seq silently + case 'event.assistant.tool_use_started': + case 'event.assistant.tool_use_delta': + case 'event.assistant.tool_use_completed': + case 'event.assistant.completed': + case 'event.tool.started': + return { type: 'unknown', raw: { _noop: true, _wireType: w.type } }; + + case 'event.tool.output': + return { + type: 'toolOutput', + sessionId: w.session_id, + toolCallId: w.payload.tool_call_id, + outputChunk: w.payload.chunk, + stream: w.payload.stream, + }; + + case 'event.tool.progress': + if (typeof w.payload.message === 'string' && w.payload.message.length > 0) { + return { + type: 'toolOutput', + sessionId: w.session_id, + toolCallId: w.payload.tool_call_id, + outputChunk: w.payload.message, + stream: 'stdout', + }; + } + return { type: 'unknown', raw: { _noop: true, _wireType: w.type } }; + + case 'event.tool.completed': + return { type: 'unknown', raw: { _noop: true, _wireType: w.type } }; + + // ----- Approval ----- + case 'event.approval.requested': + return { + type: 'approvalRequested', + sessionId: w.session_id, + approval: toAppApprovalRequest(w.payload), + }; + + case 'event.approval.resolved': + return { + type: 'approvalResolved', + sessionId: w.session_id, + approvalId: w.payload.approval_id, + decision: w.payload.decision, + resolvedAt: w.payload.resolved_at, + }; + + case 'event.approval.expired': + return { + type: 'approvalExpired', + sessionId: w.session_id, + approvalId: w.payload.approval_id, + }; + + // ----- Question ----- + case 'event.question.requested': + return { + type: 'questionRequested', + sessionId: w.session_id, + question: toAppQuestionRequest(w.payload), + }; + + case 'event.question.answered': + return { + type: 'questionAnswered', + sessionId: w.session_id, + questionId: w.payload.question_id, + resolvedAt: w.payload.resolved_at, + }; + + case 'event.question.dismissed': + return { + type: 'questionDismissed', + sessionId: w.session_id, + questionId: w.payload.question_id, + dismissedAt: w.payload.dismissed_at, + }; + + case 'event.question.expired': + return { + type: 'questionExpired', + sessionId: w.session_id, + questionId: w.payload.question_id, + }; + + // ----- Background tasks ----- + case 'event.task.created': + return { + type: 'taskCreated', + sessionId: w.session_id, + task: toAppTask(w.payload.task), + }; + + case 'event.task.progress': + return { + type: 'taskProgress', + sessionId: w.session_id, + taskId: w.payload.task_id, + outputChunk: w.payload.output_chunk, + stream: w.payload.stream, + }; + + case 'event.task.completed': + return { + type: 'taskCompleted', + sessionId: w.session_id, + taskId: w.payload.task_id, + status: w.payload.status as AppTaskStatus, + outputPreview: w.payload.output_preview, + outputBytes: w.payload.output_bytes, + }; + + case 'event.config.changed': + return { + type: 'configChanged', + changedFields: w.payload.changed_fields, + config: toAppConfig(w.payload.config), + }; + + default: { + // Truly unknown event — record warning + return { type: 'unknown', raw: wire }; + } + } +} + +// --------------------------------------------------------------------------- +// Model + Provider mappers +// PRESUMED — not in current daemon docs; isolated here, swap when backend defines them. +// --------------------------------------------------------------------------- + +export function toAppModel(wire: WireModel): AppModel { + return { + id: wire.model, + provider: wire.provider, + model: wire.model, + displayName: wire.display_name, + maxContextSize: wire.max_context_size, + capabilities: wire.capabilities, + }; +} + +export function toAppProvider(wire: WireProvider): AppProvider { + return { + id: wire.id, + type: wire.type, + baseUrl: wire.base_url, + defaultModel: wire.default_model, + hasApiKey: wire.has_api_key, + status: wire.status, + models: wire.models, + }; +} + +export function toAppConfig(wire: WireConfig): AppConfig { + const providers: Record = {}; + for (const [id, provider] of Object.entries(wire.providers)) { + providers[id] = { + type: provider.type, + baseUrl: provider.base_url, + defaultModel: provider.default_model, + hasApiKey: provider.has_api_key, + }; + } + return { + providers, + defaultProvider: wire.default_provider, + defaultModel: wire.default_model, + models: wire.models, + thinking: wire.thinking, + planMode: wire.plan_mode, + yolo: wire.yolo, + defaultThinking: wire.default_thinking, + defaultPermissionMode: wire.default_permission_mode, + defaultPlanMode: wire.default_plan_mode, + permission: wire.permission, + hooks: wire.hooks, + services: wire.services, + mergeAllAvailableSkills: wire.merge_all_available_skills, + extraSkillDirs: wire.extra_skill_dirs, + loopControl: wire.loop_control, + background: wire.background, + experimental: wire.experimental, + telemetry: wire.telemetry, + raw: wire.raw, + }; +} + +// Helper to extract sessionId from a WireEvent (needed by reducer for lastSeq update) +export function wireEventSessionId(wire: WireEvent): string { + return wire.session_id; +} + +export function wireEventSeq(wire: WireEvent): number { + return wire.seq; +} diff --git a/apps/kimi-web/src/api/daemon/wire.ts b/apps/kimi-web/src/api/daemon/wire.ts new file mode 100644 index 000000000..d76893b9e --- /dev/null +++ b/apps/kimi-web/src/api/daemon/wire.ts @@ -0,0 +1,800 @@ +// apps/kimi-web/src/api/daemon/wire.ts +// Daemon wire DTOs — ALL fields stay snake_case as they appear on the wire. +// No camelCase conversions here; that is mappers.ts's job. + +// --------------------------------------------------------------------------- +// Envelope & Page +// --------------------------------------------------------------------------- + +export interface WireEnvelope { + code: number; + msg: string; + data: T | null; + request_id: string; + details?: unknown; +} + +export interface WirePage { + items: T[]; + has_more: boolean; +} + +// --------------------------------------------------------------------------- +// Session +// --------------------------------------------------------------------------- + +export type WireSessionStatus = + | 'idle' + | 'running' + | 'awaiting_approval' + | 'awaiting_question' + | 'aborted'; + +export interface WireSessionUsage { + input_tokens: number; + output_tokens: number; + cache_read_tokens: number; + cache_creation_tokens: number; + total_cost_usd: number; + context_tokens: number; + context_limit: number; + turn_count: number; +} + +export interface WireSessionUsageDelta { + input_tokens: number; + output_tokens: number; + cache_read_tokens: number; + cache_creation_tokens: number; + cost_usd: number; +} + +export interface WirePermissionRule { + id: string; + tool_name: string; + matcher?: { + kind: 'command_prefix' | 'path_glob' | 'exact_input' | 'always'; + value?: string; + }; + decision: 'approved'; + created_at: string; + created_by: 'user' | 'agent'; +} + +export interface WireSession { + id: string; + title: string; + created_at: string; + updated_at: string; + status: WireSessionStatus; + archived: boolean; + current_prompt_id?: string; + // PRESUMED — daemon adds this once it ships the workspace registry; until then + // it is absent and the client maps sessions by metadata.cwd === workspace.root. + workspace_id?: string; + metadata: { + cwd: string; + [key: string]: unknown; + }; + agent_config: { + model: string; + system_prompt?: string; + tools?: string[]; + mcp_servers?: string[]; + // Runtime controls — optional on read (the daemon may not backfill them; + // live values come from GET /sessions/{id}/status). + thinking?: string; + permission_mode?: string; + plan_mode?: boolean; + swarm_mode?: boolean; + goal_objective?: string; + goal_control?: 'pause' | 'resume' | 'cancel'; + }; + usage: WireSessionUsage; + permission_rules: WirePermissionRule[]; + message_count: number; + last_seq: number; +} + +// GET /sessions/{id}/status — live runtime state, aligned with TUI /status. +export interface WireSessionRuntimeStatus { + model?: string; + thinking_level: string; + permission: string; + plan_mode: boolean; + swarm_mode: boolean; + context_tokens: number; + max_context_tokens: number; + context_usage: number; +} + +// --------------------------------------------------------------------------- +// Workspace + daemon folder browser wire DTOs +// PRESUMED — not in the live daemon yet; isolated here, swap when backend ships. +// --------------------------------------------------------------------------- + +export interface WireWorkspace { + id: string; + root: string; + name: string; + is_git_repo: boolean; + branch: string | null; + last_opened_at?: string; + session_count: number; +} + +export interface WireFsBrowseEntry { + name: string; + path: string; + is_dir: boolean; + is_git_repo: boolean; + branch?: string; +} + +export interface WireFsBrowseResult { + path: string; + parent: string | null; + entries: WireFsBrowseEntry[]; +} + +export interface WireFsHomeResult { + home: string; + recent_roots: string[]; +} + +// --------------------------------------------------------------------------- +// Message +// --------------------------------------------------------------------------- + +export type WireMessageContent = + | { type: 'text'; text: string } + | { type: 'tool_use'; tool_call_id: string; tool_name: string; input: unknown } + | { type: 'tool_result'; tool_call_id: string; output: unknown; is_error?: boolean } + | { type: 'image'; source: WireImageSource } + | { type: 'video'; source: WireImageSource } + | { type: 'file'; file_id: string; name: string; media_type: string; size: number } + | { type: 'thinking'; thinking: string; signature?: string }; + +export type WireImageSource = + | { kind: 'url'; url: string } + | { kind: 'base64'; media_type: string; data: string } + | { kind: 'file'; file_id: string }; + +export interface WireMessage { + id: string; + session_id: string; + role: 'user' | 'assistant' | 'tool' | 'system'; + content: WireMessageContent[]; + created_at: string; + prompt_id?: string; + parent_message_id?: string; + metadata?: Record; +} + +// --------------------------------------------------------------------------- +// Prompt +// --------------------------------------------------------------------------- + +export interface WirePromptSubmission { + content: WireMessageContent[]; + metadata?: Record; + agent_id?: string; + model?: string; + thinking?: string; + permission_mode?: string; + plan_mode?: boolean; + swarm_mode?: boolean; + goal_objective?: string; + goal_control?: 'pause' | 'resume' | 'cancel'; +} + +export interface WirePromptSubmitResult { + prompt_id: string; + user_message_id: string; + /** 'running' = started immediately; 'queued' = parked behind the active prompt. */ + status?: 'running' | 'queued'; +} + +export interface WirePromptSteerResult { + steered: boolean; + prompt_ids: string[]; +} + +// --------------------------------------------------------------------------- +// Approval +// --------------------------------------------------------------------------- + +export interface WireApprovalRequest { + approval_id: string; + session_id: string; + turn_id?: number; + tool_call_id: string; + tool_name: string; + action: string; + /** ToolInputDisplay — 12 discriminated kinds; client falls back to generic. + The daemon protocol field is `tool_input_display` (protocol/approval.ts); + `display` is the stub daemon's older shape, kept for compatibility. */ + tool_input_display?: unknown; + display?: unknown; + expires_at: string; + created_at: string; +} + +export interface WireApprovalResponse { + decision: 'approved' | 'rejected' | 'cancelled'; + scope?: 'session'; + feedback?: string; + selected_label?: string; +} + +// --------------------------------------------------------------------------- +// Question +// --------------------------------------------------------------------------- + +export interface WireQuestionOption { + id: string; + label: string; + description?: string; + recommended?: boolean; + is_recommended?: boolean; +} + +export interface WireQuestionItem { + id: string; + question: string; + header?: string; + body?: string; + options: WireQuestionOption[]; + multi_select?: boolean; + allow_other?: boolean; + other_label?: string; + other_description?: string; +} + +export interface WireQuestionRequest { + question_id: string; + session_id: string; + turn_id?: number; + tool_call_id?: string; + questions: WireQuestionItem[]; + expires_at: string; + created_at: string; +} + +export type WireQuestionAnswer = + | { kind: 'single'; option_id: string } + | { kind: 'multi'; option_ids: string[] } + | { kind: 'other'; text: string } + | { kind: 'multi_with_other'; option_ids: string[]; other_text: string } + | { kind: 'skipped' }; + +export interface WireQuestionResponse { + answers: Record; + method?: 'enter' | 'space' | 'number_key' | 'click'; + note?: string; +} + +// --------------------------------------------------------------------------- +// Background Task +// --------------------------------------------------------------------------- + +export type WireTaskStatus = 'running' | 'completed' | 'failed' | 'cancelled'; + +export interface WireBackgroundTask { + id: string; + session_id: string; + kind: 'subagent' | 'bash' | 'tool'; + description: string; + status: WireTaskStatus; + command?: string; + created_at: string; + started_at?: string; + completed_at?: string; + output_preview?: string; + output_bytes?: number; + subagent_phase?: 'queued' | 'working' | 'suspended' | 'completed' | 'failed'; + subagent_type?: string; + parent_tool_call_id?: string; + suspended_reason?: string; + swarm_index?: number; +} + +// --------------------------------------------------------------------------- +// File System +// --------------------------------------------------------------------------- + +export type WireFsKind = 'file' | 'directory' | 'symlink'; + +export interface WireFsEntry { + path: string; + name: string; + kind: WireFsKind; + size?: number; + modified_at: string; + etag?: string; + mime?: string; + language_id?: string; + is_binary?: boolean; + is_symlink_to?: string; + git_status?: string; + child_count?: number; +} + +// --------------------------------------------------------------------------- +// Model + Provider wire DTOs +// PRESUMED — not in current daemon docs; isolated here, swap when backend defines them. +// --------------------------------------------------------------------------- + +export interface WireModel { + provider: string; + model: string; + display_name?: string; + max_context_size: number; + capabilities?: string[]; +} + +export interface WireProvider { + id: string; + type: string; + base_url?: string; + default_model?: string; + has_api_key: boolean; + status: 'connected' | 'error' | 'unconfigured'; + models?: string[]; +} + +export interface WireProviderRefreshResult { + changed: Array<{ + provider_id: string; + provider_name: string; + added: number; + removed: number; + }>; + unchanged: string[]; + failed: Array<{ provider: string; reason: string }>; +} + +export interface WireConfigProvider { + type: string; + base_url?: string; + default_model?: string; + has_api_key: boolean; +} + +export interface WireConfig { + providers: Record; + default_provider?: string; + default_model?: string; + models?: Record; + thinking?: unknown; + plan_mode?: boolean; + yolo?: boolean; + default_thinking?: boolean; + default_permission_mode?: string; + default_plan_mode?: boolean; + permission?: unknown; + hooks?: unknown[]; + services?: unknown; + merge_all_available_skills?: boolean; + extra_skill_dirs?: string[]; + loop_control?: unknown; + background?: unknown; + experimental?: Record; + telemetry?: boolean; + raw?: Record; +} + +// --------------------------------------------------------------------------- +// Auth wire DTOs — REAL endpoints (GET /api/v1/auth, POST/GET/DELETE /api/v1/oauth/login, POST /api/v1/oauth/logout) +// --------------------------------------------------------------------------- + +export interface WireManagedProvider { + status: string; + [key: string]: unknown; +} + +export interface WireAuthResult { + ready: boolean; + providers_count: number; + default_model: string | null; + managed_provider: WireManagedProvider | null; +} + +export interface WireOAuthLoginStartResult { + flow_id: string; + provider: string; + verification_uri: string; + verification_uri_complete: string; + user_code: string; + expires_in: number; + interval: number; + status: 'pending'; + expires_at: string; +} + +export interface WireOAuthLoginPollResult { + flow_id: string; + status: 'pending' | 'authenticated' | 'expired' | 'cancelled'; + resolved_at?: string; +} + +export interface WireOAuthCancelResult { + cancelled: boolean; + status: string; +} + +export interface WireLogoutResult { + logged_out: boolean; +} + +// --------------------------------------------------------------------------- +// File upload wire DTOs +// --------------------------------------------------------------------------- + +export interface WireFileMeta { + id: string; + name: string; + media_type: string; + size: number; + created_at: string; + expires_at?: string; +} + +// --------------------------------------------------------------------------- +// WS Server frames (S→C) +// --------------------------------------------------------------------------- + +/** All typed server-to-client WS frames */ +export type WireServerFrame = + | WireServerHello + | WireAck + | WirePing + | WireResyncRequired + | WireErrorFrame + | WireEvent; + +export interface WireServerHello { + type: 'server_hello'; + timestamp: string; + payload: { + server_id: string; + heartbeat_ms: number; + max_event_buffer_size: number; + capabilities: { + event_batching: boolean; + compression: boolean; + }; + }; +} + +export interface WireAck { + type: 'ack'; + id: string; + code: number; + msg: string; + payload: unknown; +} + +export interface WirePing { + type: 'ping'; + timestamp: string; + payload: { nonce: string }; +} + +export interface WireResyncRequired { + type: 'resync_required'; + timestamp: string; + payload: { + session_id: string; + reason: 'buffer_overflow' | 'session_recreated' | 'epoch_changed'; + current_seq: number; + /** Current journal epoch — adopt it after resyncing (v2 sync protocol). */ + epoch?: string; + }; +} + +// --------------------------------------------------------------------------- +// v2 sync protocol: cursors + session snapshot +// --------------------------------------------------------------------------- + +/** Per-session sync cursor: durable seq + journal epoch. */ +export interface WireSessionCursor { + seq: number; + epoch?: string; +} + +export interface WireInFlightToolCall { + tool_call_id: string; + name: string; + args?: unknown; + description?: string; + display?: unknown; + last_progress?: { + kind: 'stdout' | 'stderr' | 'progress' | 'status' | 'custom'; + text?: string; + percent?: number; + }; +} + +export interface WireInFlightTurn { + turn_id: number; + assistant_text: string; + thinking_text: string; + running_tools: WireInFlightToolCall[]; + current_prompt_id?: string; +} + +/** `GET /sessions/{sid}/snapshot` — atomic rebuild state at a watermark. */ +export interface WireSessionSnapshot { + as_of_seq: number; + epoch: string; + session: WireSession; + messages: { items: WireMessage[]; has_more: boolean }; + in_flight_turn: WireInFlightTurn | null; + pending_approvals: WireApprovalRequest[]; + pending_questions: WireQuestionRequest[]; +} + +export interface WireSessionAbortResult { + aborted: boolean; +} + +export interface WireErrorFrame { + type: 'error'; + timestamp: string; + payload: { + code: number; + msg: string; + fatal: boolean; + request_id?: string; + details?: unknown; + }; +} + +// --------------------------------------------------------------------------- +// WS Client control messages (C→S) +// --------------------------------------------------------------------------- + +export type WireClientControl = + | WireClientHello + | WireSubscribe + | WireUnsubscribe + | WireAbort + | WirePong; + +export interface WireClientHello { + type: 'client_hello'; + id: string; + payload: { + client_id: string; + subscriptions: string[]; + cursors?: Record; + }; +} + +export interface WireSubscribe { + type: 'subscribe'; + id: string; + payload: { + session_ids: string[]; + cursors?: Record; + }; +} + +export interface WireUnsubscribe { + type: 'unsubscribe'; + id: string; + payload: { session_ids: string[] }; +} + +export interface WireAbort { + type: 'abort'; + id: string; + payload: { + session_id: string; + prompt_id: string; + }; +} + +export interface WirePong { + type: 'pong'; + payload: { nonce: string }; +} + +// --------------------------------------------------------------------------- +// WS Events (S→C) — all type: "event.*" +// --------------------------------------------------------------------------- + +/** Base shape for all WS event frames */ +interface WireEventBase { + type: T; + seq: number; + session_id: string; + timestamp: string; + payload: P; +} + +// Session lifecycle +type WireEventSessionCreated = WireEventBase<'event.session.created', { session: WireSession }>; +type WireEventSessionUpdated = WireEventBase<'event.session.updated', { session: WireSession; changed_fields: string[] }>; +type WireEventSessionDeleted = WireEventBase<'event.session.deleted', { session_id: string }>; +type WireEventSessionStatusChanged = WireEventBase<'event.session.status_changed', { + status: WireSessionStatus; + previous_status: WireSessionStatus; + current_prompt_id?: string; +}>; +type WireEventSessionUsageUpdated = WireEventBase<'event.session.usage_updated', { + usage: WireSessionUsage; + delta: WireSessionUsageDelta; +}>; +type WireEventSessionHistoryCompacted = WireEventBase<'event.session.history_compacted', { + before_seq: number; + reason: 'auto_compact' | 'manual_compact' | 'history_rewrite'; + summary_message_id?: string; +}>; + +// Workspace lifecycle (global — not session-scoped) +type WireEventWorkspaceCreated = WireEventBase<'event.workspace.created', { workspace: WireWorkspace }>; +type WireEventWorkspaceUpdated = WireEventBase<'event.workspace.updated', { workspace: WireWorkspace }>; +type WireEventWorkspaceDeleted = WireEventBase<'event.workspace.deleted', { workspace_id: string; root: string }>; + +// Message lifecycle +type WireEventMessageCreated = WireEventBase<'event.message.created', { message: WireMessage }>; +type WireEventMessageUpdated = WireEventBase<'event.message.updated', { + message_id: string; + content: WireMessageContent[]; + status: 'pending' | 'completed' | 'error'; +}>; + +// Assistant streaming +type WireEventAssistantDelta = WireEventBase<'event.assistant.delta', { + message_id: string; + content_index: number; + delta: { text?: string; thinking?: string }; +}>; +// No-op-but-known streaming events (advance lastSeq, no UI change) +type WireEventAssistantToolUseStarted = WireEventBase<'event.assistant.tool_use_started', { + message_id: string; + tool_call_id: string; + tool_name: string; + content_index: number; +}>; +type WireEventAssistantToolUseDelta = WireEventBase<'event.assistant.tool_use_delta', { + message_id: string; + tool_call_id: string; + input_delta: string; +}>; +type WireEventAssistantToolUseCompleted = WireEventBase<'event.assistant.tool_use_completed', { + message_id: string; + tool_call_id: string; + input: unknown; +}>; +type WireEventAssistantCompleted = WireEventBase<'event.assistant.completed', { + message_id: string; + finish_reason: 'stop' | 'tool_use' | 'length' | 'cancelled' | 'error'; +}>; + +// Tool execution (no-op-but-known) +type WireEventToolStarted = WireEventBase<'event.tool.started', { + tool_call_id: string; + tool_name: string; + input: unknown; + parent_message_id: string; +}>; +type WireEventToolOutput = WireEventBase<'event.tool.output', { + tool_call_id: string; + chunk: string; + stream: 'stdout' | 'stderr'; +}>; +type WireEventToolProgress = WireEventBase<'event.tool.progress', { + tool_call_id: string; + progress: number; + message?: string; +}>; +type WireEventToolCompleted = WireEventBase<'event.tool.completed', { + tool_call_id: string; + output: unknown; + is_error: boolean; + duration_ms: number; +}>; + +// Approval +type WireEventApprovalRequested = WireEventBase<'event.approval.requested', WireApprovalRequest>; +type WireEventApprovalResolved = WireEventBase<'event.approval.resolved', { + approval_id: string; + decision: 'approved' | 'rejected' | 'cancelled'; + scope?: 'session'; + feedback?: string; + selected_label?: string; + resolved_by: string; + resolved_at: string; +}>; +type WireEventApprovalExpired = WireEventBase<'event.approval.expired', { approval_id: string }>; + +// Question +type WireEventQuestionRequested = WireEventBase<'event.question.requested', WireQuestionRequest>; +type WireEventQuestionAnswered = WireEventBase<'event.question.answered', { + question_id: string; + answers: Record; + method?: string; + note?: string; + resolved_by: string; + resolved_at: string; +}>; +type WireEventQuestionDismissed = WireEventBase<'event.question.dismissed', { + question_id: string; + dismissed_by: string; + dismissed_at: string; +}>; +type WireEventQuestionExpired = WireEventBase<'event.question.expired', { question_id: string }>; + +// Background tasks +type WireEventTaskCreated = WireEventBase<'event.task.created', { task: WireBackgroundTask }>; +type WireEventTaskProgress = WireEventBase<'event.task.progress', { + task_id: string; + output_chunk: string; + stream: 'stdout' | 'stderr'; +}>; +type WireEventTaskCompleted = WireEventBase<'event.task.completed', { + task_id: string; + status: WireTaskStatus; + output_preview?: string; + output_bytes?: number; +}>; + +type WireEventConfigChanged = WireEventBase<'event.config.changed', { + changed_fields: string[]; + config: WireConfig; +}>; + +/** Catch-all for unrecognised event frames — keeps lastSeq advancing without warnings */ +type WireEventUnknown = { type: string; seq: number; session_id: string; timestamp: string; payload: unknown }; + +/** + * Union of all WS event frames the client will process. + * Visible events (UI updates) + no-op-but-known events (lastSeq only). + * The catch-all at the end handles future server events gracefully. + */ +export type WireEvent = + // Session lifecycle + | WireEventSessionCreated + | WireEventSessionUpdated + | WireEventSessionDeleted + | WireEventSessionStatusChanged + | WireEventSessionUsageUpdated + | WireEventSessionHistoryCompacted + // Workspace lifecycle + | WireEventWorkspaceCreated + | WireEventWorkspaceUpdated + | WireEventWorkspaceDeleted + // Message lifecycle + | WireEventMessageCreated + | WireEventMessageUpdated + // Assistant streaming + | WireEventAssistantDelta + | WireEventAssistantToolUseStarted + | WireEventAssistantToolUseDelta + | WireEventAssistantToolUseCompleted + | WireEventAssistantCompleted + // Tool execution + | WireEventToolStarted + | WireEventToolOutput + | WireEventToolProgress + | WireEventToolCompleted + // Approval + | WireEventApprovalRequested + | WireEventApprovalResolved + | WireEventApprovalExpired + // Question + | WireEventQuestionRequested + | WireEventQuestionAnswered + | WireEventQuestionDismissed + | WireEventQuestionExpired + // Background tasks + | WireEventTaskCreated + | WireEventTaskProgress + | WireEventTaskCompleted + // Config + | WireEventConfigChanged + // Unknown / future events + | WireEventUnknown; diff --git a/apps/kimi-web/src/api/daemon/ws.ts b/apps/kimi-web/src/api/daemon/ws.ts new file mode 100644 index 000000000..b59e2f238 --- /dev/null +++ b/apps/kimi-web/src/api/daemon/ws.ts @@ -0,0 +1,469 @@ +// apps/kimi-web/src/api/daemon/ws.ts +// DaemonEventSocket — browser WebSocket client for the daemon WS protocol. +// Handles: server_hello / client_hello handshake, subscribe/unsubscribe, +// ping/pong heartbeat, resync_required, error frames, event.* dispatch. + +import { traceWsIn, traceWsLifecycle, traceWsOut } from '../../debug/trace'; +import { classifyFrame } from './agentEventProjector'; +import type { WireEvent, WireServerFrame } from './wire'; + +// --------------------------------------------------------------------------- +// Handler interface +// --------------------------------------------------------------------------- + +export interface DaemonEventSocketHandlers { + /** Called for every event.* frame received */ + onWireEvent(event: WireEvent): void; + /** + * Called for raw agent-core frames (type does NOT start with "event." and + * is not a control frame). The full parsed frame object is passed so the + * caller can extract type / seq / session_id / timestamp / payload, plus + * the v2 envelope extras (volatile / offset). + */ + onRawAgentEvent?(frame: { + type: string; + seq: number; + session_id: string; + timestamp: string; + payload: unknown; + volatile?: boolean; + offset?: number; + }): void; + /** Called when server says client is out of sync for a session */ + onResync(sessionId: string, currentSeq: number, epoch?: string): void; + /** Called when the WS connection opens or closes */ + onConnectionState(connected: boolean): void; + /** Called on error frames or JSON parse failures */ + onError(code: number, msg: string, fatal: boolean): void; + onTerminalOutput?(sessionId: string, terminalId: string, data: string, seq: number): void; + onTerminalExit?(sessionId: string, terminalId: string, exitCode: number | null): void; +} + +// --------------------------------------------------------------------------- +// DaemonEventSocket +// --------------------------------------------------------------------------- + +/** v2 sync cursor: durable seq + journal epoch. */ +export interface SessionCursor { + seq: number; + epoch?: string; +} + +interface PendingSubscription { + sessionId: string; + cursor: SessionCursor; +} + +interface TerminalAttachment { + sessionId: string; + terminalId: string; + lastSeq: number; +} + +export class DaemonEventSocket { + private ws: WebSocket | null = null; + private connected = false; + private closed = false; + + /** subscriptions we manage: sessionId → last known cursor {seq, epoch} */ + private readonly subscriptions = new Map(); + + /** subscriptions queued while not yet connected */ + private readonly pendingSubscriptions: PendingSubscription[] = []; + private readonly terminalAttachments = new Map(); + + private msgSeq = 0; + + /** Automatic reconnect (exponential backoff, reset on a successful hello). */ + private reconnectAttempts = 0; + private reconnectTimer: ReturnType | null = null; + + constructor( + private readonly wsUrl: string, + private readonly clientId: string, + private readonly handlers: DaemonEventSocketHandlers, + ) {} + + /** Open the WebSocket connection. No-op while one is open or after close(). */ + connect(): void { + if (this.ws !== null || this.closed) return; + + traceWsLifecycle('connect', { url: this.wsUrl, attempt: this.reconnectAttempts }); + const ws = new WebSocket(this.wsUrl); + this.ws = ws; + + ws.onopen = () => { + // Don't mark as connected yet — wait for server_hello + traceWsLifecycle('open'); + }; + + ws.onmessage = (ev: MessageEvent) => { + try { + const frame = JSON.parse(String(ev.data)) as WireServerFrame; + traceWsIn(frame); + this.handleFrame(frame); + } catch (error) { + traceWsLifecycle('parse-error', { error: String(error) }); + this.handlers.onError(0, `Failed to parse WS frame: ${String(error)}`, false); + } + }; + + ws.onerror = () => { + // The error details are not exposed by the browser WS API; the close + // event with a reason code follows immediately. + traceWsLifecycle('error'); + this.handlers.onError(0, 'WebSocket error', false); + }; + + ws.onclose = (ev?: CloseEvent) => { + traceWsLifecycle('close', ev ? { code: ev.code, reason: ev.reason, wasClean: ev.wasClean } : undefined); + this.connected = false; + this.ws = null; + this.handlers.onConnectionState(false); + // Unexpected drop (daemon restart, sleep, network blip) → reconnect. + // onServerHello re-sends every kept subscription via client_hello, and + // the server answers a too-large seq gap with resync_required, so live + // updates resume without a page reload. + this.scheduleReconnect(); + }; + } + + private scheduleReconnect(): void { + if (this.closed || this.reconnectTimer !== null) return; + const base = Math.min(30_000, 1000 * 2 ** this.reconnectAttempts); + const delay = base + Math.floor(Math.random() * 250); // jitter + this.reconnectAttempts += 1; + traceWsLifecycle('reconnect-scheduled', { delayMs: delay, attempt: this.reconnectAttempts }); + this.reconnectTimer = setTimeout(() => { + this.reconnectTimer = null; + this.connect(); + }, delay); + } + + /** + * Subscribe to events for a session at a `{seq, epoch}` cursor. + * If connected, sends immediately; otherwise queues until after server_hello. + */ + subscribe(sessionId: string, cursor: SessionCursor = { seq: 0 }): void { + this.subscriptions.set(sessionId, { ...cursor }); + + if (this.connected) { + this.sendSubscribe([sessionId], { [sessionId]: cursor }); + } else { + // Remove any earlier pending entry for this session, then enqueue + const idx = this.pendingSubscriptions.findIndex((p) => p.sessionId === sessionId); + if (idx !== -1) this.pendingSubscriptions.splice(idx, 1); + this.pendingSubscriptions.push({ sessionId, cursor: { ...cursor } }); + } + } + + /** Unsubscribe from a session's events. */ + unsubscribe(sessionId: string): void { + this.subscriptions.delete(sessionId); + if (this.connected && this.ws) { + this.send({ + type: 'unsubscribe', + id: this.nextId(), + payload: { session_ids: [sessionId] }, + }); + } + } + + /** + * Send a WS abort control message for a prompt. + * (The REST :abort endpoint is the primary path; this is the WS path per spec.) + */ + abort(sessionId: string, promptId: string): void { + if (!this.connected || !this.ws) return; + this.send({ + type: 'abort', + id: this.nextId(), + payload: { session_id: sessionId, prompt_id: promptId }, + }); + } + + terminalAttach(sessionId: string, terminalId: string, sinceSeq?: number): void { + const key = terminalKey(sessionId, terminalId); + const previous = this.terminalAttachments.get(key); + const lastSeq = sinceSeq ?? previous?.lastSeq ?? 0; + this.terminalAttachments.set(key, { sessionId, terminalId, lastSeq }); + if (!this.connected || !this.ws) return; + this.sendTerminalAttach(sessionId, terminalId, lastSeq); + } + + terminalInput(sessionId: string, terminalId: string, data: string): void { + if (!this.connected || !this.ws) return; + this.send({ + type: 'terminal_input', + id: this.nextId(), + payload: { session_id: sessionId, terminal_id: terminalId, data }, + }); + } + + terminalResize(sessionId: string, terminalId: string, cols: number, rows: number): void { + if (!this.connected || !this.ws) return; + this.send({ + type: 'terminal_resize', + id: this.nextId(), + payload: { session_id: sessionId, terminal_id: terminalId, cols, rows }, + }); + } + + terminalDetach(sessionId: string, terminalId: string): void { + this.terminalAttachments.delete(terminalKey(sessionId, terminalId)); + if (!this.connected || !this.ws) return; + this.send({ + type: 'terminal_detach', + id: this.nextId(), + payload: { session_id: sessionId, terminal_id: terminalId }, + }); + } + + terminalClose(sessionId: string, terminalId: string): void { + this.terminalAttachments.delete(terminalKey(sessionId, terminalId)); + if (!this.connected || !this.ws) return; + this.send({ + type: 'terminal_close', + id: this.nextId(), + payload: { session_id: sessionId, terminal_id: terminalId }, + }); + } + + /** Close the socket. Stops reconnect attempts. */ + close(): void { + this.closed = true; + this.connected = false; + if (this.reconnectTimer !== null) { + clearTimeout(this.reconnectTimer); + this.reconnectTimer = null; + } + if (this.ws) { + this.ws.close(1000); + this.ws = null; + } + } + + // --------------------------------------------------------------------------- + // Private helpers + // --------------------------------------------------------------------------- + + private handleFrame(rawFrame: WireServerFrame): void { + // WireServerFrame union contains WireAck (payload: unknown) which prevents + // TypeScript from narrowing .payload in each case arm. Cast once here. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const frame = rawFrame as any; + switch ((rawFrame as { type: string }).type) { + case 'server_hello': + this.onServerHello(); + break; + + case 'ping': + this.send({ type: 'pong', payload: { nonce: frame.payload.nonce } }); + break; + + case 'resync_required': { + const sid = frame.payload.session_id as string; + const epoch = frame.payload.epoch as string | undefined; + // Adopt the announced cursor so the next reconnect handshake doesn't + // re-trigger the same resync before the snapshot reload lands. + this.subscriptions.set(sid, { seq: frame.payload.current_seq, epoch }); + this.handlers.onResync(sid, frame.payload.current_seq, epoch); + break; + } + + case 'error': { + // A session-scoped error (has top-level session_id) is a real agent-core + // 'error' event — e.g. a 403 from the model provider — whose message + // must surface in the conversation. A connection-level control error + // (no session_id) goes to onError. + const sid = (frame as { session_id?: unknown }).session_id; + if (typeof sid === 'string' && this.handlers.onRawAgentEvent) { + this.handlers.onRawAgentEvent({ + type: 'error', + seq: frame.seq, + session_id: sid, + timestamp: frame.timestamp, + payload: frame.payload, + }); + } else { + this.handlers.onError(frame.payload.code, frame.payload.msg, frame.payload.fatal); + } + break; + } + + case 'ack': + // ack frames are fire-and-forget for now (no request tracking) + break; + + case 'terminal_output': { + const sessionId = frame.session_id as string; + const terminalId = frame.terminal_id as string; + const seq = frame.seq as number; + const key = terminalKey(sessionId, terminalId); + const existing = this.terminalAttachments.get(key); + if (existing) { + this.terminalAttachments.set(key, { + ...existing, + lastSeq: Math.max(existing.lastSeq, seq), + }); + } + const data = typeof frame.payload?.data === 'string' ? frame.payload.data : ''; + this.handlers.onTerminalOutput?.(sessionId, terminalId, data, seq); + break; + } + + case 'terminal_exit': { + const sessionId = frame.session_id as string; + const terminalId = frame.terminal_id as string; + const rawExitCode = frame.payload?.exit_code; + const exitCode = typeof rawExitCode === 'number' ? rawExitCode : null; + this.handlers.onTerminalExit?.(sessionId, terminalId, exitCode); + break; + } + + default: { + // Track the per-session cursor from durable event envelopes so the + // reconnect handshake resumes from the freshest watermark. Volatile + // frames carry the same watermark (never ahead), so skipping them is + // safe and avoids regressing the cursor. + this.trackCursor(frame as Record); + + // Classify the frame into protocol vs agent-core. Robust to all three + // shapes: raw agent-core, "event."-prefixed agent-core, and genuine + // projected "event.*" protocol events. See classifyFrame() for rules. + const type = (frame as { type: string }).type; + const decision = classifyFrame(type, (frame as { payload?: unknown }).payload); + + if (decision.route === 'protocol') { + // Genuine projected protocol event → existing toAppEvent() path. + this.handlers.onWireEvent(frame as unknown as WireEvent); + break; + } + + if (decision.route === 'agent') { + // Raw (or prefix-stripped) agent-core event → client-side projector. + // We pass the prefix-stripped agentType so the projector matches its + // raw case arms regardless of whether the wire frame carried "event.". + if ( + this.handlers.onRawAgentEvent && + typeof (frame as { session_id?: unknown }).session_id === 'string' + ) { + const f = frame as { + seq: number; + session_id: string; + timestamp: string; + payload: unknown; + }; + const extras = frame as { volatile?: boolean; offset?: number }; + this.handlers.onRawAgentEvent({ + type: decision.agentType, + seq: f.seq, + session_id: f.session_id, + timestamp: f.timestamp, + payload: f.payload, + ...(extras.volatile !== undefined ? { volatile: extras.volatile } : {}), + ...(extras.offset !== undefined ? { offset: extras.offset } : {}), + }); + } + break; + } + + // decision.route === 'ignore' (control-shaped or unroutable) → drop. + break; + } + } + } + + private onServerHello(): void { + this.connected = true; + this.reconnectAttempts = 0; + this.handlers.onConnectionState(true); + + // Build the initial subscription list from current subscriptions + pending + const allSessionIds = Array.from(this.subscriptions.keys()); + // Drain pending: merge into subscriptions map (pending overrides if seq differs) + for (const p of this.pendingSubscriptions) { + this.subscriptions.set(p.sessionId, p.cursor); + if (!allSessionIds.includes(p.sessionId)) allSessionIds.push(p.sessionId); + } + this.pendingSubscriptions.length = 0; + + // Build cursors from subscriptions + const cursors: Record = {}; + for (const [sid, cursor] of this.subscriptions.entries()) { + cursors[sid] = cursor; + } + + this.send({ + type: 'client_hello', + id: this.nextId(), + payload: { + client_id: this.clientId, + subscriptions: allSessionIds, + cursors, + }, + }); + + for (const attachment of this.terminalAttachments.values()) { + this.sendTerminalAttach(attachment.sessionId, attachment.terminalId, attachment.lastSeq); + } + } + + private sendSubscribe(sessionIds: string[], cursors: Record): void { + this.send({ + type: 'subscribe', + id: this.nextId(), + payload: { + session_ids: sessionIds, + cursors, + }, + }); + } + + private sendTerminalAttach(sessionId: string, terminalId: string, sinceSeq: number): void { + this.send({ + type: 'terminal_attach', + id: this.nextId(), + payload: { + session_id: sessionId, + terminal_id: terminalId, + since_seq: sinceSeq > 0 ? sinceSeq : undefined, + }, + }); + } + + /** + * Advance the tracked cursor from a durable event envelope (seq + epoch). + * Volatile frames are skipped (their seq is the same watermark, and a + * volatile frame can never carry a NEWER seq than the last durable one). + */ + private trackCursor(frame: Record): void { + if (frame['volatile'] === true) return; + const sid = frame['session_id']; + const seq = frame['seq']; + if (typeof sid !== 'string' || typeof seq !== 'number') return; + const existing = this.subscriptions.get(sid); + if (!existing) return; // not a session we manage + if (seq <= existing.seq && existing.epoch !== undefined) return; + const epoch = typeof frame['epoch'] === 'string' ? (frame['epoch'] as string) : existing.epoch; + this.subscriptions.set(sid, { seq: Math.max(seq, existing.seq), epoch }); + } + + private send(msg: unknown): void { + if (!this.ws || this.ws.readyState !== WebSocket.OPEN) return; + try { + this.ws.send(JSON.stringify(msg)); + traceWsOut(msg); + } catch { + // Ignore send errors (socket closing races) + } + } + + private nextId(): string { + return `c_${++this.msgSeq}`; + } +} + +function terminalKey(sessionId: string, terminalId: string): string { + return `${sessionId}\0${terminalId}`; +} diff --git a/apps/kimi-web/src/api/errors.ts b/apps/kimi-web/src/api/errors.ts new file mode 100644 index 000000000..b4c2bce9a --- /dev/null +++ b/apps/kimi-web/src/api/errors.ts @@ -0,0 +1,80 @@ +// apps/kimi-web/src/api/errors.ts +// DaemonApiError, DaemonNetworkError, and type guard. + +export class DaemonApiError extends Error { + readonly code: number; + readonly requestId: string; + readonly details: unknown; + + constructor(input: { code: number; msg: string; requestId: string; details?: unknown }) { + super(input.msg); + this.name = 'DaemonApiError'; + this.code = input.code; + this.requestId = input.requestId; + this.details = input.details; + } +} + +export class DaemonNetworkError extends Error { + readonly cause: unknown; + readonly method: string; + readonly path: string; + readonly url: string; + readonly requestId: string; + readonly phase: 'fetch' | 'parse'; + readonly timeoutMs: number; + readonly status?: number; + readonly statusText?: string; + readonly contentType?: string; + readonly bodyPreview?: string; + + constructor(input: { + message: string; + cause: unknown; + method: string; + path: string; + url: string; + requestId: string; + phase: 'fetch' | 'parse'; + timeoutMs: number; + status?: number; + statusText?: string; + contentType?: string; + bodyPreview?: string; + }) { + super(input.message); + this.name = 'DaemonNetworkError'; + this.cause = input.cause; + this.method = input.method; + this.path = input.path; + this.url = input.url; + this.requestId = input.requestId; + this.phase = input.phase; + this.timeoutMs = input.timeoutMs; + this.status = input.status; + this.statusText = input.statusText; + this.contentType = input.contentType; + this.bodyPreview = input.bodyPreview; + } +} + +export function isDaemonApiError(error: unknown): error is DaemonApiError { + return ( + error instanceof DaemonApiError || + (typeof error === 'object' && + error !== null && + (error as { name?: unknown }).name === 'DaemonApiError' && + typeof (error as { code?: unknown }).code === 'number') + ); +} + +export function isDaemonNetworkError(error: unknown): error is DaemonNetworkError { + return ( + error instanceof DaemonNetworkError || + (typeof error === 'object' && + error !== null && + (error as { name?: unknown }).name === 'DaemonNetworkError' && + typeof (error as { method?: unknown }).method === 'string' && + typeof (error as { path?: unknown }).path === 'string') + ); +} diff --git a/apps/kimi-web/src/api/index.ts b/apps/kimi-web/src/api/index.ts new file mode 100644 index 000000000..3e2dffa2e --- /dev/null +++ b/apps/kimi-web/src/api/index.ts @@ -0,0 +1,13 @@ +// apps/kimi-web/src/api/index.ts +// Singleton factory for the KimiWebApi daemon client. + +import { readKimiApiConfig } from './config'; +import type { KimiWebApi } from './types'; +import { DaemonKimiWebApi } from './daemon/client'; + +let singleton: KimiWebApi | undefined; + +export function getKimiWebApi(): KimiWebApi { + singleton ??= new DaemonKimiWebApi(readKimiApiConfig()); + return singleton; +} diff --git a/apps/kimi-web/src/api/types.ts b/apps/kimi-web/src/api/types.ts new file mode 100644 index 000000000..ade373520 --- /dev/null +++ b/apps/kimi-web/src/api/types.ts @@ -0,0 +1,701 @@ +// apps/kimi-web/src/api/types.ts +// App-facing camelCase model + KimiWebApi interface. +// No daemon wire details here — Vue components consume only these types. + +// --------------------------------------------------------------------------- +// Pagination +// --------------------------------------------------------------------------- + +export interface Page { + items: T[]; + hasMore: boolean; +} + +export interface PageRequest { + beforeId?: string; + afterId?: string; + pageSize?: number; +} + +// --------------------------------------------------------------------------- +// Notices +// --------------------------------------------------------------------------- + +export type AppNoticeSeverity = 'info' | 'warning' | 'error'; + +export interface AppNoticeDetail { + label: string; + value: string; +} + +export interface AppNotice { + severity: AppNoticeSeverity; + title: string; + message?: string; + details?: AppNoticeDetail[]; +} + +export type AppWarning = string | AppNotice; + +// --------------------------------------------------------------------------- +// Session +// --------------------------------------------------------------------------- + +export type AppSessionStatus = + | 'idle' + | 'running' + | 'awaitingApproval' + | 'awaitingQuestion' + | 'aborted'; + +export interface AppSessionUsage { + inputTokens: number; + outputTokens: number; + cacheReadTokens: number; + cacheCreationTokens: number; + totalCostUsd: number; + contextTokens: number; + contextLimit: number; + turnCount: number; +} + +export interface AppSession { + id: string; + title: string; + createdAt: string; + updatedAt: string; + status: AppSessionStatus; + archived: boolean; + currentPromptId?: string; + cwd: string; + model: string; + usage: AppSessionUsage; + messageCount: number; + lastSeq: number; + /** + * The workspace this session belongs to. Present once the daemon ships the + * workspace registry (returns `workspace_id` on Session). Until then it is + * undefined and the composable maps sessions to workspaces by cwd === root. + */ + workspaceId?: string; + /** + * Set on a child ("side chat") session — the id of the parent it was forked + * from. Used to keep child sessions out of the main session list. + */ + parentSessionId?: string; +} + +/** + * Live runtime state from GET /sessions/{id}/status — the source of truth for + * the current model + context usage (Session.agent_config.model can be ""). + */ +export interface AppSessionRuntimeStatus { + /** Current model alias, or null if the daemon couldn't resolve it. */ + model: string | null; + thinkingLevel: string; + permission: string; + planMode: boolean; + swarmMode: boolean; + contextTokens: number; + maxContextTokens: number; + contextUsage: number; +} + +// --------------------------------------------------------------------------- +// Workspace — a real folder the client organizes sessions by. +// 1 Workspace : N Sessions. A session inherits the workspace's root as its cwd. +// --------------------------------------------------------------------------- + +export interface AppWorkspace { + /** Stable id. In fallback mode (derived from session cwds) this IS the root. */ + id: string; + /** Absolute path to the project root. */ + root: string; + /** Display name — defaults to basename(root), may be renamed on the daemon. */ + name: string; + /** Whether root is inside a git repository. */ + isGitRepo: boolean; + /** Current branch, when known. */ + branch?: string; + /** ISO timestamp of when this workspace was last opened. */ + lastOpenedAt?: string; + /** Number of sessions belonging to this workspace. */ + sessionCount: number; +} + +/** One directory entry from the daemon folder browser (fs:browse). */ +export interface FsBrowseEntry { + name: string; + path: string; + isDir: boolean; + isGitRepo: boolean; + branch?: string; +} + +export interface FsBrowseResult { + path: string; + parent: string | null; + entries: FsBrowseEntry[]; +} + +// --------------------------------------------------------------------------- +// Message +// --------------------------------------------------------------------------- + +export type AppMessageRole = 'user' | 'assistant' | 'tool' | 'system'; + +export type AppMessageContent = + | { type: 'text'; text: string } + | { type: 'toolUse'; toolCallId: string; toolName: string; input: unknown; outputLines?: string[] } + | { type: 'toolResult'; toolCallId: string; output: unknown; isError?: boolean } + | { type: 'image'; source: ImageSource } + | { type: 'video'; source: ImageSource } + | { type: 'file'; fileId: string; name: string; mediaType: string; size: number } + | { type: 'thinking'; thinking: string; signature?: string } + | { type: 'unknown'; raw: unknown }; + +export type ImageSource = + | { kind: 'url'; url: string } + | { kind: 'base64'; mediaType: string; data: string } + | { kind: 'file'; fileId: string }; + +export interface AppMessage { + id: string; + sessionId: string; + role: AppMessageRole; + content: AppMessageContent[]; + createdAt: string; + promptId?: string; + parentMessageId?: string; + /** Client-side measured duration from turn.started to turn.ended (ms). */ + durationMs?: number; + metadata?: Record; +} + +/** + * Metadata key of the client-side compaction marker message appended on + * compactionCompleted. The transcript keeps all prior messages (TUI parity); + * this marker renders as a "context compacted" divider. Snapshot-loaded + * summary messages (origin kind 'compaction_summary') render the same way + * but carry no token stats. + */ +export const COMPACTION_MARKER_METADATA_KEY = 'kimiWeb.compaction'; + +export interface CompactionMarkerMetadata { + trigger: 'manual' | 'auto'; + tokensBefore?: number; + tokensAfter?: number; +} + +// --------------------------------------------------------------------------- +// Prompt +// --------------------------------------------------------------------------- + +export type ThinkingLevel = 'off' | 'low' | 'medium' | 'high' | 'xhigh' | 'max'; + +export interface PromptSubmission { + content: AppMessageContent[]; + metadata?: Record; + /** Optional non-main agent id, used by BTW side-channel prompts. */ + agentId?: string; + /** The daemon requires these on every prompt (per-prompt, not session-level). */ + model?: string; + thinking?: ThinkingLevel; + permissionMode?: 'manual' | 'auto' | 'yolo'; + planMode?: boolean; + swarmMode?: boolean; + goalObjective?: string; + goalControl?: 'pause' | 'resume' | 'cancel'; +} + +export interface PromptSubmitResult { + promptId: string; + userMessageId: string; + /** 'running' when the prompt started a turn immediately; 'queued' when + another prompt is active and the daemon parked it (steerable). */ + status?: 'running' | 'queued'; +} + +// --------------------------------------------------------------------------- +// Approval +// --------------------------------------------------------------------------- + +export type ApprovalDecision = 'approved' | 'rejected' | 'cancelled'; + +export interface ApprovalResponse { + decision: ApprovalDecision; + scope?: 'session'; + feedback?: string; + selectedLabel?: string; +} + +export interface AppApprovalRequest { + approvalId: string; + sessionId: string; + turnId?: number; + toolCallId: string; + toolName: string; + action: string; + display: unknown; // ToolInputDisplay — Web renders what it knows, falls back to generic + expiresAt: string; + createdAt: string; +} + +// --------------------------------------------------------------------------- +// Question +// --------------------------------------------------------------------------- + +export interface QuestionOption { + id: string; + label: string; + description?: string; + recommended?: boolean; +} + +export interface QuestionItem { + id: string; + question: string; + header?: string; + body?: string; + options: QuestionOption[]; + multiSelect?: boolean; + allowOther?: boolean; + otherLabel?: string; + otherDescription?: string; +} + +export interface AppQuestionRequest { + questionId: string; + sessionId: string; + turnId?: number; + toolCallId?: string; + questions: QuestionItem[]; + expiresAt: string; + createdAt: string; +} + +export type QuestionAnswer = + | { kind: 'single'; optionId: string } + | { kind: 'multi'; optionIds: string[] } + | { kind: 'other'; text: string } + | { kind: 'multiWithOther'; optionIds: string[]; otherText: string } + | { kind: 'skipped' }; + +export interface QuestionResponse { + answers: Record; + method?: 'enter' | 'space' | 'number_key' | 'click'; + note?: string; +} + +// --------------------------------------------------------------------------- +// Background Task +// --------------------------------------------------------------------------- + +export type AppTaskStatus = 'running' | 'completed' | 'failed' | 'cancelled'; +export type AppSubagentPhase = 'queued' | 'working' | 'suspended' | 'completed' | 'failed'; + +export interface AppTask { + id: string; + sessionId: string; + kind: 'subagent' | 'bash' | 'tool'; + description: string; + status: AppTaskStatus; + command?: string; + createdAt: string; + startedAt?: string; + completedAt?: string; + outputPreview?: string; + outputBytes?: number; + outputLines?: string[]; // accumulated by eventReducer from task.progress chunks + subagentPhase?: AppSubagentPhase; + subagentType?: string; + parentToolCallId?: string; + suspendedReason?: string; + swarmIndex?: number; +} + +// --------------------------------------------------------------------------- +// Goal +// --------------------------------------------------------------------------- + +export type AppGoalStatus = 'active' | 'paused' | 'blocked' | 'complete'; + +export interface AppGoal { + goalId: string; + objective: string; + completionCriterion?: string; + status: AppGoalStatus; + turnsUsed: number; + tokensUsed: number; + wallClockMs: number; + terminalReason?: string; + budget: { + tokenBudget: number | null; + remainingTokens: number | null; + turnBudget: number | null; + remainingTurns: number | null; + wallClockBudgetMs: number | null; + remainingWallClockMs: number | null; + overBudget: boolean; + }; +} + +// --------------------------------------------------------------------------- +// Terminal +// --------------------------------------------------------------------------- + +export type AppTerminalStatus = 'running' | 'exited'; + +export interface AppTerminal { + id: string; + sessionId: string; + cwd: string; + shell: string; + cols: number; + rows: number; + status: AppTerminalStatus; + createdAt: string; + exitedAt?: string; + exitCode?: number | null; +} + +// --------------------------------------------------------------------------- +// File System +// --------------------------------------------------------------------------- + +export type FsKind = 'file' | 'directory' | 'symlink'; + +export interface FsEntry { + path: string; + name: string; + kind: FsKind; + size?: number; + modifiedAt: string; + etag?: string; + mime?: string; + languageId?: string; + isBinary?: boolean; + isSymlinkTo?: string; + gitStatus?: string; + childCount?: number; +} + +// --------------------------------------------------------------------------- +// Events (app-facing, camelCase) +// --------------------------------------------------------------------------- + +export type AppEvent = + | { type: 'sessionCreated'; session: AppSession } + | { type: 'workspaceCreated'; workspace: AppWorkspace } + | { type: 'workspaceUpdated'; workspace: AppWorkspace } + | { type: 'workspaceDeleted'; workspaceId: string; root: string } + | { type: 'sessionUpdated'; session: AppSession; changedFields: string[] } + | { type: 'sessionDeleted'; sessionId: string } + | { type: 'sessionStatusChanged'; sessionId: string; status: AppSessionStatus; previousStatus: AppSessionStatus; currentPromptId?: string } + | { type: 'sessionMetaUpdated'; sessionId: string; title: string } + | { type: 'sessionUsageUpdated'; sessionId: string; usage: AppSessionUsage; model?: string; swarmMode?: boolean; planMode?: boolean } + | { type: 'historyCompacted'; sessionId: string; beforeSeq: number; reason: string; summaryMessageId?: string } + | { type: 'compactionStarted'; sessionId: string; trigger: 'manual' | 'auto'; instruction?: string } + | { type: 'compactionCompleted'; sessionId: string; tokensBefore?: number; tokensAfter?: number; summary?: string } + | { type: 'compactionCancelled'; sessionId: string } + | { type: 'messageCreated'; message: AppMessage } + | { type: 'messageUpdated'; sessionId: string; messageId: string; content: AppMessageContent[]; status: 'pending' | 'completed' | 'error'; durationMs?: number } + | { type: 'assistantDelta'; sessionId: string; messageId: string; contentIndex: number; delta: { text?: string; thinking?: string } } + // Side-channel / non-main-agent streaming: carries text/thinking deltas for a + // specific agent (e.g. a BTW side chat) without folding them into the parent + // transcript. The web layer routes these to the side-chat panel. + | { type: 'agentDelta'; sessionId: string; agentId: string; delta: { text?: string; thinking?: string } } + | { type: 'agentTurnEnded'; sessionId: string; agentId: string; reason?: string } + | { type: 'toolOutput'; sessionId: string; toolCallId: string; outputChunk: string; stream: 'stdout' | 'stderr' } + | { type: 'approvalRequested'; sessionId: string; approval: AppApprovalRequest } + | { type: 'approvalResolved'; sessionId: string; approvalId: string; decision: ApprovalDecision; resolvedAt: string } + | { type: 'approvalExpired'; sessionId: string; approvalId: string } + | { type: 'questionRequested'; sessionId: string; question: AppQuestionRequest } + | { type: 'questionAnswered'; sessionId: string; questionId: string; resolvedAt: string } + | { type: 'questionDismissed'; sessionId: string; questionId: string; dismissedAt: string } + | { type: 'questionExpired'; sessionId: string; questionId: string } + | { type: 'taskCreated'; sessionId: string; task: AppTask } + | { type: 'taskProgress'; sessionId: string; taskId: string; outputChunk: string; stream: 'stdout' | 'stderr' } + | { type: 'taskCompleted'; sessionId: string; taskId: string; status: AppTaskStatus; outputPreview?: string; outputBytes?: number } + | { type: 'goalUpdated'; sessionId: string; goal: AppGoal | null } + | { type: 'configChanged'; changedFields: string[]; config: AppConfig } + | { type: 'unknown'; raw: unknown }; + +// --------------------------------------------------------------------------- +// WebSocket connection helpers +// --------------------------------------------------------------------------- + +/** Per-session sync cursor (v2): durable seq + journal epoch. */ +export interface AppSessionCursor { + seq: number; + epoch?: string; +} + +/** In-flight (mid-turn) state recovered from the session snapshot. */ +export interface AppInFlightToolCall { + toolCallId: string; + name: string; + args?: unknown; + description?: string; + lastProgress?: { kind: string; text?: string; percent?: number }; +} + +export interface AppInFlightTurn { + turnId: number; + assistantText: string; + thinkingText: string; + runningTools: AppInFlightToolCall[]; + /** Authoritative daemon prompt_id for the active prompt, if known. */ + promptId?: string; +} + +/** + * IM-style initial sync result: everything needed to rebuild a session's UI + * state, consistent at `asOfSeq`. The standard flow is + * `getSessionSnapshot()` → `subscribe(sessionId, {seq: asOfSeq, epoch})`. + */ +export interface AppSessionSnapshot { + asOfSeq: number; + epoch: string; + session: AppSession; + /** Most recent messages, chronological ascending. */ + messages: AppMessage[]; + hasMoreMessages: boolean; + inFlightTurn: AppInFlightTurn | null; + pendingApprovals: AppApprovalRequest[]; + pendingQuestions: AppQuestionRequest[]; +} + +export interface KimiEventHandlers { + onEvent(event: AppEvent, meta: { sessionId: string; seq: number }): void; + onResync(sessionId: string, currentSeq: number, epoch?: string): void; + onError(code: number, msg: string, fatal: boolean): void; + onConnectionChange(connected: boolean): void; + onTerminalOutput?(sessionId: string, terminalId: string, data: string, seq: number): void; + onTerminalExit?(sessionId: string, terminalId: string, exitCode: number | null): void; +} + +export interface KimiEventConnection { + subscribe(sessionId: string, cursor?: AppSessionCursor): void; + unsubscribe(sessionId: string): void; + /** + * Bind the real daemon prompt_id to the next turn for a session, so the + * client-side projector stops synthesizing a random promptId on turn.started. + * Call right after submitPrompt() returns. + */ + bindNextPromptId(sessionId: string, promptId: string): void; + /** + * Seed the client-side projector with a snapshot's in-flight turn so a + * reconnecting client renders mid-turn state immediately; emits the + * corresponding AppEvents through `onEvent`. Resets per-session projector + * state first — call BEFORE subscribe(), with the snapshot's cursor. + */ + seedSnapshot(sessionId: string, snapshot: AppSessionSnapshot): void; + abort(sessionId: string, promptId: string): void; + terminalAttach(sessionId: string, terminalId: string, sinceSeq?: number): void; + terminalInput(sessionId: string, terminalId: string, data: string): void; + terminalResize(sessionId: string, terminalId: string, cols: number, rows: number): void; + terminalDetach(sessionId: string, terminalId: string): void; + terminalClose(sessionId: string, terminalId: string): void; + /** + * Mark an agent as a side-channel (e.g. BTW side chat). The client-side + * projector will then emit its text/thinking deltas as agent-scoped events + * instead of dropping them like background subagents. + */ + markSideChannelAgent(agentId: string): void; + close(): void; +} + +// --------------------------------------------------------------------------- +// Model + Provider (app-facing, camelCase) +// PRESUMED — not in current daemon docs; isolated in adapter, swap when backend defines them. +// --------------------------------------------------------------------------- + +export interface AppModel { + /** Unique identifier for this model (the string passed to PATCH session agent_config.model) */ + id: string; + /** Provider id this model belongs to */ + provider: string; + /** Raw model name (e.g. "moonshot-v1-128k") */ + model: string; + /** Optional human-readable display name */ + displayName?: string; + /** Maximum context size in tokens */ + maxContextSize: number; + /** Optional capability tags (e.g. ["vision", "thinking"]) */ + capabilities?: string[]; +} + +export interface AppProvider { + /** Provider id */ + id: string; + /** Provider type (e.g. "moonshot", "anthropic", "openai", "custom") */ + type: string; + /** Optional custom base URL */ + baseUrl?: string; + /** Optional default model alias */ + defaultModel?: string; + /** Whether an API key is stored for this provider */ + hasApiKey: boolean; + /** Provider connectivity status */ + status: 'connected' | 'error' | 'unconfigured'; + /** Model ids available from this provider */ + models?: string[]; +} + +export interface ProviderRefreshResult { + changed: Array<{ + providerId: string; + providerName: string; + added: number; + removed: number; + }>; + unchanged: string[]; + failed: Array<{ provider: string; reason: string }>; +} + +export interface AppConfigProvider { + type: string; + baseUrl?: string; + defaultModel?: string; + hasApiKey: boolean; +} + +export interface AppConfig { + providers: Record; + defaultProvider?: string; + defaultModel?: string; + models?: Record; + thinking?: unknown; + planMode?: boolean; + yolo?: boolean; + defaultThinking?: boolean; + defaultPermissionMode?: string; + defaultPlanMode?: boolean; + permission?: unknown; + hooks?: unknown[]; + services?: unknown; + mergeAllAvailableSkills?: boolean; + extraSkillDirs?: string[]; + loopControl?: unknown; + background?: unknown; + experimental?: Record; + telemetry?: boolean; + raw?: Record; +} + +/** A session-scoped skill the user can invoke from the slash menu. */ +export interface AppSkill { + name: string; + description: string; + /** Skill source (e.g. 'builtin' | 'project' | 'plugin') for grouping/labels. */ + source: string; +} + +// --------------------------------------------------------------------------- +// KimiWebApi — the app-facing interface +// --------------------------------------------------------------------------- + +export interface KimiWebApi { + getHealth(): Promise<{ status: 'ok'; uptimeSec: number }>; + getMeta(): Promise<{ serverVersion: string; serverId: string; startedAt: string; capabilities: Record; openInApps: string[] }>; + listSessions(input?: PageRequest & { status?: AppSessionStatus; workspaceId?: string; includeArchive?: boolean }): Promise>; + createSession(input: { title?: string; cwd?: string; model?: string; workspaceId?: string }): Promise; + /** Fetch one session by id (deep links beyond the first listSessions page). */ + getSession(sessionId: string): Promise; + updateSession(sessionId: string, input: { title?: string; cwd?: string; model?: string; permissionMode?: string; planMode?: boolean; swarmMode?: boolean; goalObjective?: string; goalControl?: 'pause' | 'resume' | 'cancel'; thinking?: string }): Promise; + getSessionStatus(sessionId: string): Promise; + archiveSession(sessionId: string): Promise<{ archived: true }>; + listMessages(sessionId: string, input?: PageRequest & { role?: AppMessageRole }): Promise>; + /** v2 initial sync: atomic session state + `asOfSeq` watermark + epoch. */ + getSessionSnapshot(sessionId: string): Promise; + submitPrompt(sessionId: string, input: PromptSubmission): Promise; + /** Steer daemon-queued prompts into the active turn (TUI ctrl+s). */ + steerPrompts(sessionId: string, promptIds: string[]): Promise<{ steered: boolean; promptIds: string[] }>; + abortPrompt(sessionId: string, promptId: string): Promise<{ aborted: boolean; atSeq?: number }>; + /** Cancel whatever is running in the session, including skill activations. */ + abortSession(sessionId: string): Promise<{ aborted: boolean }>; + compactSession(sessionId: string, instruction?: string): Promise; + undoSession(sessionId: string, count?: number): Promise; + forkSession(sessionId: string, input?: { title?: string }): Promise; + /** Create a child session under a parent — POST /sessions/{id}/children. */ + createChildSession(sessionId: string, input?: { title?: string }): Promise; + /** List a session's child sessions — GET /sessions/{id}/children. */ + listChildSessions(sessionId: string): Promise; + /** Start a BTW side-channel agent under the session — POST /sessions/{id}:btw. */ + startBtw(sessionId: string): Promise<{ agentId: string }>; + respondApproval(sessionId: string, approvalId: string, response: ApprovalResponse): Promise<{ resolved: true; resolvedAt: string }>; + respondQuestion(sessionId: string, questionId: string, response: QuestionResponse): Promise<{ resolved: true; resolvedAt: string }>; + dismissQuestion(sessionId: string, questionId: string): Promise<{ dismissed: true; dismissedAt: string }>; + listSkills(sessionId: string): Promise; + activateSkill(sessionId: string, skillName: string, args?: string): Promise<{ activated: true; skillName: string }>; + listTasks(sessionId: string, status?: AppTaskStatus): Promise; + getTask(sessionId: string, taskId: string, input?: { withOutput?: boolean; outputBytes?: number }): Promise; + cancelTask(sessionId: string, taskId: string): Promise<{ cancelled: true }>; + listTerminals(sessionId: string): Promise; + createTerminal(sessionId: string, input?: { cwd?: string; shell?: string; cols?: number; rows?: number }): Promise; + getTerminal(sessionId: string, terminalId: string): Promise; + closeTerminal(sessionId: string, terminalId: string): Promise<{ closed: true }>; + listDirectory(sessionId: string, input: { path?: string; depth?: number; includeGitStatus?: boolean }): Promise<{ items: FsEntry[]; childrenByPath?: Record; truncated: boolean }>; + readFile(sessionId: string, input: { path: string; offset?: number; length?: number }): Promise<{ path: string; content: string; encoding: 'utf-8' | 'base64'; size: number; truncated: boolean; etag: string; mime: string; languageId?: string; lineCount?: number; isBinary: boolean }>; + searchFiles(sessionId: string, input: { query: string; limit?: number }): Promise<{ items: Array<{ path: string; name: string; kind: FsKind; score: number; matchPositions: number[] }>; truncated: boolean }>; + grepFiles(sessionId: string, input: { pattern: string; regex?: boolean; caseSensitive?: boolean }): Promise<{ files: Array<{ path: string; matches: Array<{ line: number; col: number; text: string; before: string[]; after: string[] }> }>; filesScanned: number; truncated: boolean; elapsedMs: number }>; + getGitStatus(sessionId: string, paths?: string[]): Promise<{ branch: string; ahead: number; behind: number; entries: Record; additions: number; deletions: number; pullRequest: { number: number; state: string; url: string } | null }>; + getFileDiff(sessionId: string, path: string): Promise<{ path: string; diff: string }>; + getFileDownloadUrl(sessionId: string, path: string): string; + openFile(sessionId: string, input: { path: string; line?: number }): Promise<{ opened: true }>; + revealFile(sessionId: string, input: { path: string }): Promise<{ revealed: true }>; + /** Open the session working directory (or a session-relative path) in an external application. */ + openInApp(sessionId: string, appId: string, path: string, line?: number): Promise; + connectEvents(handlers: KimiEventHandlers): KimiEventConnection; + + // Workspaces + daemon folder browser + // PRESUMED — falls back until the daemon ships /workspaces, /fs:browse, /fs:home. + listWorkspaces(): Promise; + addWorkspace(input: { root: string; name?: string }): Promise; + deleteWorkspace(id: string): Promise; + browseFs(path?: string): Promise; + getFsHome(): Promise<{ home: string; recentRoots: string[] }>; + + // PRESUMED — not in current daemon docs; isolated in adapter, swap when backend defines them. + listModels(): Promise; + listProviders(): Promise; + addProvider(input: { type: string; apiKey?: string; baseUrl?: string; defaultModel?: string }): Promise; + deleteProvider(id: string): Promise<{ deleted: true }>; + refreshProvider(id: string): Promise; + refreshOAuthProviderModels(): Promise; + + // File upload / download + uploadFile(input: { file: Blob; name?: string }): Promise<{ id: string; name: string; mediaType: string; size: number }>; + getFileUrl(fileId: string): string; + + // Config — REAL endpoints + getConfig(): Promise; + setConfig(patch: Partial): Promise; + + // Auth — REAL endpoints + getAuth(): Promise<{ + ready: boolean; + providersCount: number; + defaultModel: string | null; + managedProvider: { status: string } | null; + }>; + startOAuthLogin(): Promise<{ + flowId: string; + provider: string; + verificationUri: string; + verificationUriComplete: string; + userCode: string; + expiresIn: number; + interval: number; + status: 'pending'; + expiresAt: string; + }>; + pollOAuthLogin(): Promise<{ + flowId: string; + status: 'pending' | 'authenticated' | 'expired' | 'cancelled'; + resolvedAt?: string; + } | null>; + cancelOAuthLogin(): Promise<{ cancelled: boolean; status: string }>; + logout(): Promise<{ loggedOut: boolean }>; +} diff --git a/apps/kimi-web/src/components/ActivityNotice.vue b/apps/kimi-web/src/components/ActivityNotice.vue new file mode 100644 index 000000000..7b8176b15 --- /dev/null +++ b/apps/kimi-web/src/components/ActivityNotice.vue @@ -0,0 +1,66 @@ + + + + + + + diff --git a/apps/kimi-web/src/components/AddWorkspaceDialog.vue b/apps/kimi-web/src/components/AddWorkspaceDialog.vue new file mode 100644 index 000000000..d3b792bc1 --- /dev/null +++ b/apps/kimi-web/src/components/AddWorkspaceDialog.vue @@ -0,0 +1,666 @@ + + + + + + + + + + + diff --git a/apps/kimi-web/src/components/AgentCard.vue b/apps/kimi-web/src/components/AgentCard.vue new file mode 100644 index 000000000..1b937e7bb --- /dev/null +++ b/apps/kimi-web/src/components/AgentCard.vue @@ -0,0 +1,168 @@ + + + + + diff --git a/apps/kimi-web/src/components/AgentDetailPanel.vue b/apps/kimi-web/src/components/AgentDetailPanel.vue new file mode 100644 index 000000000..d2c975128 --- /dev/null +++ b/apps/kimi-web/src/components/AgentDetailPanel.vue @@ -0,0 +1,202 @@ + + + + + + + diff --git a/apps/kimi-web/src/components/AgentGroup.vue b/apps/kimi-web/src/components/AgentGroup.vue new file mode 100644 index 000000000..eb1341e04 --- /dev/null +++ b/apps/kimi-web/src/components/AgentGroup.vue @@ -0,0 +1,103 @@ + + + + + diff --git a/apps/kimi-web/src/components/ApprovalCard.vue b/apps/kimi-web/src/components/ApprovalCard.vue new file mode 100644 index 000000000..0c92deee0 --- /dev/null +++ b/apps/kimi-web/src/components/ApprovalCard.vue @@ -0,0 +1,456 @@ + + + +