diff --git a/.changeset/README.md b/.changeset/README.md index d20552ded7..97fccfe80a 100644 --- a/.changeset/README.md +++ b/.changeset/README.md @@ -20,7 +20,6 @@ All other workspace packages are private internal packages, are not published to - `@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` diff --git a/.github/workflows/_native-build.yml b/.github/workflows/_native-build.yml index 7d09835032..bc190ed893 100644 --- a/.github/workflows/_native-build.yml +++ b/.github/workflows/_native-build.yml @@ -85,13 +85,6 @@ jobs: node apps/kimi-code/scripts/update-catalog.mjs --out "$CATALOG_FILE" echo "KIMI_CODE_BUILT_IN_CATALOG_FILE=$CATALOG_FILE" >> "$GITHUB_ENV" - - name: Build Kimi web assets - # The SEA blob step embeds apps/kimi-code/dist-web; build the web app - # and stage its assets before producing the native executable. - run: | - pnpm --filter @moonshot-ai/kimi-web run build - node apps/kimi-code/scripts/copy-web-assets.mjs - - name: Build native executable (release profile, macOS signed) if: runner.os == 'macOS' && inputs.sign-macos run: pnpm --filter @moonshot-ai/kimi-code run build:native:release diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 99d74a5398..eb395d638e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -125,8 +125,6 @@ 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 diff --git a/.github/workflows/desktop-build.yml b/.github/workflows/desktop-build.yml deleted file mode 100644 index 6c993662b7..0000000000 --- a/.github/workflows/desktop-build.yml +++ /dev/null @@ -1,170 +0,0 @@ -name: desktop-build - -# Builds the Kimi Desktop (Electron) installers for macOS, Windows and Linux. -# Each runner builds the matching-platform SEA backend first, then packages it -# with electron-builder. -# -# macOS is signed with a Developer ID certificate + notarized (so it opens on -# any Mac without the "app is damaged" Gatekeeper block) when `sign-macos` is -# true and the Apple secrets are configured. Windows/Linux ship unsigned in v1. -# -# Triggered two ways: -# - workflow_dispatch: manual ad-hoc builds from the Actions tab. -# - workflow_call: called by release.yml to attach installers to a release. -on: - workflow_dispatch: - inputs: - sign-macos: - description: 'Sign + notarize macOS (needs Apple secrets)' - required: false - type: boolean - default: true - retention-days: - description: 'Artifact retention in days' - required: false - type: number - default: 5 - upload-artifact-prefix: - description: 'Prefix for uploaded artifact name' - required: false - type: string - default: 'kimi-desktop' - workflow_call: - inputs: - sign-macos: - description: 'Sign + notarize macOS (needs Apple secrets)' - required: false - type: boolean - default: false - retention-days: - description: 'Artifact retention in days' - required: false - type: number - default: 7 - upload-artifact-prefix: - description: 'Prefix for uploaded artifact name' - required: false - type: string - default: 'kimi-desktop' - secrets: - APPLE_CERTIFICATE_P12: - required: false - APPLE_CERTIFICATE_PASSWORD: - required: false - APPLE_NOTARIZATION_KEY_P8: - required: false - APPLE_NOTARIZATION_KEY_ID: - required: false - APPLE_NOTARIZATION_ISSUER_ID: - required: false - -permissions: - contents: read - -jobs: - desktop: - name: Desktop installer (${{ matrix.target }}) - runs-on: ${{ matrix.os }} - strategy: - fail-fast: false - matrix: - include: - - os: macos-15 - target: darwin-arm64 - - os: macos-15-intel - target: darwin-x64 - - os: windows-2025-vs2026 - target: win32-x64 - - os: ubuntu-24.04 - target: linux-x64 - - steps: - - name: Checkout - uses: actions/checkout@v6 - - - name: Setup pnpm - uses: pnpm/action-setup@v6 - - - name: Setup Node.js - uses: actions/setup-node@v6 - with: - node-version-file: .nvmrc - cache: 'pnpm' - - - name: Install dependencies - run: pnpm install --frozen-lockfile - - - name: Build Kimi web assets - # The SEA blob embeds apps/kimi-code/dist-web; build the web app and - # stage its assets before producing the native executable. - # KIMI_WEB_DESKTOP=1 bakes the internal-build banner into the web bundle - # (see apps/kimi-web/src/components/InternalBuildBanner.vue); only the - # desktop sets this flag, so the CLI `kimi web` stays banner-free. - env: - KIMI_WEB_DESKTOP: '1' - run: | - pnpm --filter @moonshot-ai/kimi-web run build - node apps/kimi-code/scripts/copy-web-assets.mjs - - - name: Build native executable (local profile) - # The Electron app signs the SEA itself (electron-builder, inside-out), - # so the native build stays unsigned here. - run: pnpm --filter @moonshot-ai/kimi-code run build:native:sea - - - name: Setup macOS keychain (Developer ID) - if: runner.os == 'macOS' && inputs.sign-macos - uses: ./.github/actions/macos-keychain-setup - with: - certificate-p12: ${{ secrets.APPLE_CERTIFICATE_P12 }} - certificate-password: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} - - - name: Prepare CSC_NAME for electron-builder (macOS) - if: runner.os == 'macOS' && inputs.sign-macos - shell: bash - run: | - # electron-builder rejects the "Developer ID Application: " prefix in - # CSC_NAME; strip it so the certificate matches by team name + ID. - name="${APPLE_SIGNING_IDENTITY}" - name="${name#Developer ID Application: }" - echo "CSC_NAME=$name" >> "$GITHUB_ENV" - - - name: Prepare notarization API key (macOS) - if: runner.os == 'macOS' && inputs.sign-macos - shell: bash - env: - APPLE_NOTARIZATION_KEY_P8: ${{ secrets.APPLE_NOTARIZATION_KEY_P8 }} - run: | - set -euo pipefail - key_path="$RUNNER_TEMP/notary-AuthKey.p8" - printf '%s' "$APPLE_NOTARIZATION_KEY_P8" | base64 -d > "$key_path" - echo "APPLE_API_KEY=$key_path" >> "$GITHUB_ENV" - - - name: Build & package desktop app - shell: bash - env: - # macOS signing is driven by env: when sign-macos, electron-builder - # signs with the keychain's Developer ID and notarizes via the notary - # API key; otherwise it builds unsigned. - CSC_IDENTITY_AUTO_DISCOVERY: ${{ (runner.os == 'macOS' && inputs.sign-macos) && 'true' || 'false' }} - CSC_KEYCHAIN: ${{ env.APPLE_KEYCHAIN_PATH }} - KIMI_DESKTOP_NOTARIZE: ${{ (runner.os == 'macOS' && inputs.sign-macos) && 'true' || 'false' }} - APPLE_API_KEY_ID: ${{ secrets.APPLE_NOTARIZATION_KEY_ID }} - APPLE_API_ISSUER: ${{ secrets.APPLE_NOTARIZATION_ISSUER_ID }} - run: pnpm --filter @moonshot-ai/kimi-desktop run dist - - - name: Cleanup macOS keychain - if: always() && runner.os == 'macOS' && inputs.sign-macos - uses: ./.github/actions/macos-keychain-cleanup - - - name: Upload installers - uses: actions/upload-artifact@v7 - with: - name: ${{ inputs.upload-artifact-prefix }}-${{ matrix.target }} - retention-days: ${{ inputs.retention-days }} - path: | - apps/kimi-desktop/dist-app/*.dmg - apps/kimi-desktop/dist-app/*.zip - apps/kimi-desktop/dist-app/*.exe - apps/kimi-desktop/dist-app/*.AppImage - apps/kimi-desktop/dist-app/*.deb - if-no-files-found: ignore diff --git a/.github/workflows/pkg-pr-new.yml b/.github/workflows/pkg-pr-new.yml index fcce360b5f..86de76975a 100644 --- a/.github/workflows/pkg-pr-new.yml +++ b/.github/workflows/pkg-pr-new.yml @@ -36,9 +36,6 @@ jobs: - name: Build package dependencies run: pnpm run build:packages - - name: Build Kimi web assets - run: pnpm --filter @moonshot-ai/kimi-web run build - - name: Generate Kimi Code built-in catalog shell: bash run: | diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e5c4c6d37b..7b96653ed3 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -97,22 +97,6 @@ jobs: APPLE_NOTARIZATION_KEY_ID: ${{ secrets.APPLE_NOTARIZATION_KEY_ID }} APPLE_NOTARIZATION_ISSUER_ID: ${{ secrets.APPLE_NOTARIZATION_ISSUER_ID }} - desktop-artifacts: - name: Desktop release artifact - needs: release - if: needs.release.outputs.kimi_native_release == 'true' - uses: ./.github/workflows/desktop-build.yml - with: - upload-artifact-prefix: kimi-desktop - retention-days: 7 - sign-macos: true - secrets: - APPLE_CERTIFICATE_P12: ${{ secrets.APPLE_CERTIFICATE_P12 }} - APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} - APPLE_NOTARIZATION_KEY_P8: ${{ secrets.APPLE_NOTARIZATION_KEY_P8 }} - APPLE_NOTARIZATION_KEY_ID: ${{ secrets.APPLE_NOTARIZATION_KEY_ID }} - APPLE_NOTARIZATION_ISSUER_ID: ${{ secrets.APPLE_NOTARIZATION_ISSUER_ID }} - publish-native-assets: name: Publish native release assets needs: diff --git a/AGENTS.md b/AGENTS.md index 6891b73fd1..88d6012d6f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -15,7 +15,6 @@ This is a TypeScript monorepo built for agent-assisted development. Keep the roo ## Project Map - `apps/kimi-code`: the CLI / TUI application. It consumes core capabilities through `@moonshot-ai/kimi-code-sdk` and must not depend directly on `@moonshot-ai/agent-core`. When writing or modifying its terminal UI, use the `write-tui` skill (`.agents/skills/write-tui/SKILL.md`). -- `apps/kimi-web`: the browser web UI, a peer to the TUI. Vue 3 + Vite + vue-i18n; talks to the server over REST + WebSocket under `/api/v1`. It must not depend on `@moonshot-ai/agent-core` (wire types are re-implemented locally). Debug against the two engines via the root `pnpm dev:v1` / `pnpm dev:v2` backend scripts — the dev Sidebar shows the active backend and switches it at runtime. See `apps/kimi-web/AGENTS.md`. - `apps/vis`, `apps/vis/server`, `apps/vis/web`: visual debugging tools for sessions and replays. - `packages/agent-core`: the unified agent engine, including Agent, Session, profile, skills, tools, plan, permission, background, records, the in-process DI service layer (`src/services/`), and other core capabilities. - `packages/node-sdk`: the public TypeScript SDK and harness. diff --git a/apps/kimi-code/package.json b/apps/kimi-code/package.json index d67164feca..b0313f09ea 100644 --- a/apps/kimi-code/package.json +++ b/apps/kimi-code/package.json @@ -50,7 +50,7 @@ "provenance": true }, "scripts": { - "build": "pnpm -C ../kimi-web run build && tsdown && node scripts/copy-native-assets.mjs && node scripts/copy-web-assets.mjs", + "build": "tsdown && node scripts/copy-native-assets.mjs", "prebuild": "node scripts/build-vis-asset.mjs", "catalog:update": "node scripts/update-catalog.mjs --out dist/built-in-catalog.json", "smoke": "node scripts/smoke.mjs", @@ -88,7 +88,6 @@ "@moonshot-ai/kimi-code-oauth": "workspace:^", "@moonshot-ai/kimi-code-sdk": "workspace:^", "@moonshot-ai/kimi-telemetry": "workspace:^", - "@moonshot-ai/kimi-web": "workspace:^", "@moonshot-ai/migration-legacy": "workspace:^", "@moonshot-ai/pi-tui": "workspace:^", "@moonshot-ai/vis-server": "workspace:^", diff --git a/apps/kimi-code/scripts/copy-web-assets.mjs b/apps/kimi-code/scripts/copy-web-assets.mjs deleted file mode 100644 index d82f40de0a..0000000000 --- a/apps/kimi-code/scripts/copy-web-assets.mjs +++ /dev/null @@ -1,27 +0,0 @@ -import { cp, rm, stat } from 'node:fs/promises'; -import { dirname, resolve } from 'node:path'; -import { fileURLToPath } from 'node:url'; - -const appRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..'); -const repoRoot = resolve(appRoot, '../..'); -const source = resolve(repoRoot, 'apps/kimi-web/dist'); -const target = resolve(appRoot, 'dist-web'); - -async function assertBuiltWeb() { - try { - const info = await stat(resolve(source, 'index.html')); - if (!info.isFile()) { - throw new Error('index.html is not a file'); - } - } catch { - throw new Error( - `Kimi web build output was not found at ${source}. Run \`pnpm --filter @moonshot-ai/kimi-web run build\` first.`, - ); - } -} - -await assertBuiltWeb(); -await rm(target, { recursive: true, force: true }); -await cp(source, target, { recursive: true }); - -console.log(`Copied Kimi web assets to ${target}`); diff --git a/apps/kimi-code/scripts/smoke.mjs b/apps/kimi-code/scripts/smoke.mjs index b4a5afa094..eb368f3f72 100644 --- a/apps/kimi-code/scripts/smoke.mjs +++ b/apps/kimi-code/scripts/smoke.mjs @@ -7,7 +7,6 @@ import { promisify } from 'node:util'; const execFileAsync = promisify(execFile); const appRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..'); const bundlePath = resolve(appRoot, 'dist', 'main.mjs'); -const webIndexPath = resolve(appRoot, 'dist-web', 'index.html'); const packageJson = JSON.parse(await readFile(resolve(appRoot, 'package.json'), 'utf-8')); const expectedVersion = packageJson.version; @@ -24,14 +23,6 @@ async function ensureBundleExists() { } } -async function ensureRuntimeAssetsExist() { - try { - await stat(webIndexPath); - } catch { - fail(`Runtime asset not found at ${webIndexPath}. Run \`pnpm build\` first.`); - } -} - async function runBundle(args) { try { const { stdout, stderr } = await execFileAsync(process.execPath, [bundlePath, ...args], { @@ -54,7 +45,6 @@ function assertIncludes(output, expected, command) { } await ensureBundleExists(); -await ensureRuntimeAssetsExist(); const versionOutput = await runBundle(['--version']); assertIncludes(versionOutput, expectedVersion, '--version'); diff --git a/apps/kimi-desktop/.gitignore b/apps/kimi-desktop/.gitignore deleted file mode 100644 index 66de381f3f..0000000000 --- a/apps/kimi-desktop/.gitignore +++ /dev/null @@ -1,3 +0,0 @@ -out/ -dist-app/ -resources-stage/ diff --git a/apps/kimi-desktop/README.md b/apps/kimi-desktop/README.md deleted file mode 100644 index 32ca3ee64f..0000000000 --- a/apps/kimi-desktop/README.md +++ /dev/null @@ -1,107 +0,0 @@ -# Kimi Code Desktop - -An Electron desktop client for Kimi Code (product name **Kimi Code Desktop**; -workspace package `@moonshot-ai/kimi-desktop`). It is a thin **shell + process manager** -around the existing web UI (`apps/kimi-web`): it does not reimplement any UI or -backend, it just opens a native window onto the local Kimi server. - -## How it works - -The web UI cannot run on its own — it needs the Kimi Code **server** (REST + WS -under `/api/v1`). That server already ships as a self-contained single-file -executable (SEA) built from `apps/kimi-code`, with the web UI bundled inside it. - -On launch the app: - -1. Runs the bundled SEA's `server run`, which reuses a live shared daemon if one - is already running, or starts one — exactly the same `ensureDaemon` flow the - CLI (`kimi web`) uses. The daemon binds the well-known port (`58627`) and - writes `~/.kimi-code/server/lock`, so the CLI, the browser and the TUI all - share the **same** server. -2. Reads that lock file for the real port and loads the web UI from the daemon's - origin (e.g. `http://127.0.0.1:58627`) — same-origin, no CORS, no preload. - -On quit the daemon is **left running**; it self-exits ~60s after the last client -disconnects, so closing the desktop app never tears down a server another client -is still using. - -Key files: - -- `src/main/ensure-server.ts` — run the SEA, read the lock, confirm `/healthz`. -- `src/main/sea-path.ts` — resolve the bundled SEA path (dev vs packaged). -- `src/main/index.ts` — window, native menu, window-state, loading/error screens. - -## Develop - -The dev build loads the SEA from `apps/kimi-code/dist-native/bin//`, so -build the backend once for your platform first: - -```bash -# one-time (rebuild when kimi-code / kimi-web change): -pnpm --filter @moonshot-ai/kimi-web run build -node apps/kimi-code/scripts/copy-web-assets.mjs -pnpm --filter @moonshot-ai/kimi-code run build:native:sea - -# then run the desktop app (builds the main process, launches Electron): -pnpm -C apps/kimi-desktop run dev # or: pnpm dev:desktop (from repo root) -``` - -Checks: - -```bash -pnpm -C apps/kimi-desktop run typecheck -``` - -## Package - -`dist` builds the main process and runs electron-builder for the **current** -platform. `scripts/before-pack.cjs` stages the matching-platform SEA into the -app's resources (`/bin//`). - -```bash -# unsigned local build (for your own machine): -CSC_IDENTITY_AUTO_DISCOVERY=false pnpm -C apps/kimi-desktop run dist -# -> apps/kimi-desktop/dist-app/ -``` - -> Do **not** rename a built `.app` bundle — renaming invalidates its code -> signature and macOS will report it as "damaged". - -Cross-platform installers are produced in CI (`.github/workflows/desktop-build.yml`), -which builds the SEA on each platform runner and packages there. SEA injection -is per-platform (the blob is injected into the host Node binary), so each OS must -be built on its own runner. - -### macOS signing + notarization - -An **unsigned** macOS build shows *"app is damaged and can't be opened"* once it -has been transferred to another Mac (Gatekeeper quarantine). To distribute it, -the app must be signed with a **Developer ID Application** certificate and -notarized by Apple. The config (`electron-builder.config.cjs`) applies the -hardened runtime + entitlements (`build/entitlements.mac.plist`) to the app and -the nested SEA, and signing/notarization are environment-driven: - -```bash -KIMI_DESKTOP_NOTARIZE=true \ -CSC_NAME="Developer ID Application: … (TEAMID)" \ -APPLE_API_KEY=/path/AuthKey_XXX.p8 APPLE_API_KEY_ID=XXXX APPLE_API_ISSUER=…uuid… \ -pnpm -C apps/kimi-desktop run dist -``` - -In CI, run the **desktop-build** workflow with `sign-macos: true`; it reuses the -same Apple secrets / keychain action as the TUI native build -(`APPLE_CERTIFICATE_P12`, `APPLE_NOTARIZATION_KEY_*`). The resulting `.dmg` opens -on any Mac without warnings. - -> An `Apple Development` certificate is **not** enough — it can sign for your own -> machine but cannot be notarized. You need a `Developer ID Application` cert. - -## v1 scope / not done yet - -- **Auto-update**: not implemented (v2). -- **Windows / Linux signing**: unsigned in v1 (Windows shows a SmartScreen - prompt). Only macOS is signed + notarized. -- **App icon**: builds ship the Kimi logo (sourced from the docs site art) on - macOS, Windows, and Linux. -- **First launch may need network**: the SEA resolves its native sidecars - (clipboard / koffi) the same way the installed CLI does. diff --git a/apps/kimi-desktop/build/entitlements.mac.plist b/apps/kimi-desktop/build/entitlements.mac.plist deleted file mode 100644 index 949a280881..0000000000 --- a/apps/kimi-desktop/build/entitlements.mac.plist +++ /dev/null @@ -1,24 +0,0 @@ - - - - - - com.apple.security.cs.allow-jit - - - - com.apple.security.cs.allow-unsigned-executable-memory - - - - com.apple.security.cs.disable-library-validation - - - - com.apple.security.cs.allow-dyld-environment-variables - - - diff --git a/apps/kimi-desktop/build/icon.icns b/apps/kimi-desktop/build/icon.icns deleted file mode 100644 index 2c9de6605b..0000000000 Binary files a/apps/kimi-desktop/build/icon.icns and /dev/null differ diff --git a/apps/kimi-desktop/build/icon.ico b/apps/kimi-desktop/build/icon.ico deleted file mode 100644 index c4c168ba11..0000000000 Binary files a/apps/kimi-desktop/build/icon.ico and /dev/null differ diff --git a/apps/kimi-desktop/build/icon.png b/apps/kimi-desktop/build/icon.png deleted file mode 100644 index 5b41bb6095..0000000000 Binary files a/apps/kimi-desktop/build/icon.png and /dev/null differ diff --git a/apps/kimi-desktop/electron-builder.config.cjs b/apps/kimi-desktop/electron-builder.config.cjs deleted file mode 100644 index a0e80ee4bd..0000000000 --- a/apps/kimi-desktop/electron-builder.config.cjs +++ /dev/null @@ -1,80 +0,0 @@ -'use strict'; - -// electron-builder configuration. -// -// Signing / notarization are environment-driven so the same config produces -// either an unsigned local build or a fully signed + notarized distributable: -// -// unsigned (default / local): -// CSC_IDENTITY_AUTO_DISCOVERY=false -> no signing, no notarization -// -// signed + notarized (CI, with a Developer ID cert in the keychain): -// KIMI_DESKTOP_NOTARIZE=true -// APPLE_API_KEY= APPLE_API_KEY_ID= APPLE_API_ISSUER= -// -// The entitlements (hardened runtime) are applied to the app AND every nested -// Mach-O — including the bundled Kimi SEA backend — via entitlementsInherit, so -// the whole bundle passes notarization. Mirrors the TUI's native entitlements. - -const notarize = process.env.KIMI_DESKTOP_NOTARIZE === 'true'; - -// Internal-testing artifact name: -// KCD-beta-alpha-crazy-internal-v50--. -// The date is MMDD in UTC+8, computed at build time. `v50` is a fixed label -// (not a version number) — edit it here to bump the internal build label. -function mmddUTC8() { - const utc8 = new Date(Date.now() + 8 * 60 * 60 * 1000); - const mm = String(utc8.getUTCMonth() + 1).padStart(2, '0'); - const dd = String(utc8.getUTCDate()).padStart(2, '0'); - return mm + dd; -} -const artifactName = 'KCD-beta-alpha-crazy-internal-v50-${arch}-' + mmddUTC8() + '.${ext}'; - -module.exports = { - appId: 'ai.moonshot.kimi.desktop', - productName: 'Kimi Code Desktop', - copyright: 'Copyright © Moonshot AI', - - directories: { - output: 'dist-app', - }, - - // No native node modules in the Electron app itself; the backend is the - // prebuilt SEA staged by before-pack.cjs. - npmRebuild: false, - asar: true, - - files: ['out/**', 'package.json'], - - beforePack: './scripts/before-pack.cjs', - extraResources: [{ from: 'resources-stage/bin', to: 'bin' }], - - mac: { - category: 'public.app-category.developer-tools', - hardenedRuntime: true, - gatekeeperAssess: false, - entitlements: 'build/entitlements.mac.plist', - entitlementsInherit: 'build/entitlements.mac.plist', - target: ['dmg', 'zip'], - artifactName, - notarize, - }, - - win: { - target: ['nsis'], - artifactName, - }, - - nsis: { - oneClick: false, - perMachine: false, - allowToChangeInstallationDirectory: true, - }, - - linux: { - category: 'Development', - target: ['AppImage', 'deb'], - artifactName, - maintainer: 'Moonshot AI', - }, -}; diff --git a/apps/kimi-desktop/package.json b/apps/kimi-desktop/package.json deleted file mode 100644 index 6501c6ea6c..0000000000 --- a/apps/kimi-desktop/package.json +++ /dev/null @@ -1,24 +0,0 @@ -{ - "name": "@moonshot-ai/kimi-desktop", - "version": "0.1.1-internal.0", - "private": true, - "license": "MIT", - "description": "Kimi Code desktop client — an Electron shell around the Kimi web UI.", - "author": "Moonshot AI", - "homepage": "https://github.com/MoonshotAI/kimi-code", - "type": "module", - "main": "out/main.cjs", - "scripts": { - "build": "tsdown", - "start": "electron .", - "dev": "tsdown && electron .", - "typecheck": "tsc --noEmit", - "dist": "tsdown && electron-builder --config electron-builder.config.cjs" - }, - "devDependencies": { - "electron": "33.4.11", - "electron-builder": "25.1.8", - "tsdown": "0.22.0", - "typescript": "6.0.2" - } -} diff --git a/apps/kimi-desktop/scripts/before-pack.cjs b/apps/kimi-desktop/scripts/before-pack.cjs deleted file mode 100644 index 3b28402edb..0000000000 --- a/apps/kimi-desktop/scripts/before-pack.cjs +++ /dev/null @@ -1,42 +0,0 @@ -'use strict'; - -// electron-builder `beforePack` hook. -// -// Each electron-builder run targets one (platform, arch). We stage the matching -// prebuilt Kimi SEA backend into `resources-stage/bin//` so that the -// `extraResources` rule copies exactly that one binary into the packaged app's -// resources. sea-path.ts resolves `/bin//kimi[.exe]` at -// runtime, where is `${process.platform}-${process.arch}`. - -const { existsSync, rmSync, mkdirSync, cpSync } = require('node:fs'); -const { join, resolve } = require('node:path'); - -// electron-builder Arch enum -> Node `process.arch` name. -const ARCH_NAMES = { 0: 'ia32', 1: 'x64', 2: 'armv7l', 3: 'arm64', 4: 'universal' }; - -exports.default = async function beforePack(context) { - const platform = context.electronPlatformName; // 'darwin' | 'win32' | 'linux' - const archName = ARCH_NAMES[context.arch]; - if (archName === undefined) { - throw new Error(`Unsupported arch for packaging: ${String(context.arch)}`); - } - const target = `${platform}-${archName}`; - const exe = platform === 'win32' ? 'kimi.exe' : 'kimi'; - - const desktopRoot = resolve(__dirname, '..'); - const seaDir = resolve(desktopRoot, '..', 'kimi-code', 'dist-native', 'bin', target); - const seaExe = join(seaDir, exe); - if (!existsSync(seaExe)) { - throw new Error( - `Bundled Kimi server not found for ${target} at ${seaExe}. ` + - `Build it for this platform first: \`pnpm -C apps/kimi-code build:native:sea\` ` + - `(CI builds the SEA on each platform runner before packaging).`, - ); - } - - const stageDir = resolve(desktopRoot, 'resources-stage', 'bin', target); - rmSync(resolve(desktopRoot, 'resources-stage'), { recursive: true, force: true }); - mkdirSync(stageDir, { recursive: true }); - cpSync(seaDir, stageDir, { recursive: true }); - console.log(`[before-pack] staged Kimi server (${target}) -> ${stageDir}`); -}; diff --git a/apps/kimi-desktop/src/main/ensure-server.ts b/apps/kimi-desktop/src/main/ensure-server.ts deleted file mode 100644 index 075ae36067..0000000000 --- a/apps/kimi-desktop/src/main/ensure-server.ts +++ /dev/null @@ -1,129 +0,0 @@ -import { execFile } from 'node:child_process'; -import { readFileSync } from 'node:fs'; -import { homedir } from 'node:os'; -import { join } from 'node:path'; - -/** Overall budget for the bundled `kimi server run` to finish ensuring a daemon. */ -const RUN_TIMEOUT_MS = 30_000; -/** How long to keep polling `/healthz` before declaring the daemon unhealthy. */ -const HEALTH_TIMEOUT_MS = 20_000; -const HEALTH_POLL_MS = 200; - -/** Subset of the server lock JSON we read (apps/kimi-code writes the full shape). */ -interface LockContents { - pid: number; - host?: string; - port: number; -} - -/** `` or `~/.kimi-code` — must match the server's `resolveKimiHome`. */ -export function kimiHome(): string { - const override = process.env['KIMI_CODE_HOME']; - if (override !== undefined && override.trim().length > 0) { - return override; - } - return join(homedir(), '.kimi-code'); -} - -function lockPath(): string { - return join(kimiHome(), 'server', 'lock'); -} - -/** Background daemon log written by the SEA — surfaced in the error screen / menu. */ -export function serverLogPath(): string { - return join(kimiHome(), 'server', 'server.log'); -} - -function readLock(): LockContents | null { - try { - const parsed = JSON.parse(readFileSync(lockPath(), 'utf-8')) as Partial; - if (typeof parsed.port === 'number' && typeof parsed.pid === 'number') { - return { - pid: parsed.pid, - port: parsed.port, - host: typeof parsed.host === 'string' ? parsed.host : undefined, - }; - } - return null; - } catch { - return null; - } -} - -function originFromLock(lock: LockContents): string { - const host = lock.host !== undefined && lock.host !== '0.0.0.0' ? lock.host : '127.0.0.1'; - return `http://${host}:${lock.port}`; -} - -async function isHealthy(origin: string, timeoutMs: number): Promise { - const controller = new AbortController(); - const timer = setTimeout(() => { - controller.abort(); - }, timeoutMs); - try { - const res = await fetch(`${origin}/api/v1/healthz`, { signal: controller.signal }); - if (!res.ok) { - return false; - } - const body = (await res.json()) as { code?: unknown }; - return body.code === 0; - } catch { - return false; - } finally { - clearTimeout(timer); - } -} - -/** - * Run the bundled SEA's `server run`, which reuses a live shared daemon or - * spawns one and exits once it is healthy. All discovery / port / lock logic - * lives in apps/kimi-code's `ensureDaemon`; we do not reimplement it. - */ -function runServerRun(seaPath: string): Promise { - return new Promise((resolve, reject) => { - execFile( - seaPath, - ['server', 'run', '--log-level', 'error'], - { timeout: RUN_TIMEOUT_MS }, - (error, _stdout, stderr) => { - if (error) { - reject(new Error(`kimi server run failed: ${error.message}\n${stderr}`.trim())); - return; - } - resolve(); - }, - ); - }); -} - -export interface EnsureServerResult { - origin: string; -} - -/** - * Ensure the shared kimi-code daemon is running and return its origin. - * - * The desktop app participates in the same local-server ecosystem as the CLI, - * the browser and the TUI: it reuses a running daemon or starts one that the - * others can reuse — never a private, app-only server. - */ -export async function ensureServer(seaPath: string): Promise { - await runServerRun(seaPath); - - const lock = readLock(); - if (lock === null) { - throw new Error(`Kimi server lock not found at ${lockPath()} after starting the server.`); - } - const origin = originFromLock(lock); - - const deadline = Date.now() + HEALTH_TIMEOUT_MS; - while (Date.now() < deadline) { - if (await isHealthy(origin, 500)) { - return { origin }; - } - await new Promise((resolve) => { - setTimeout(resolve, HEALTH_POLL_MS); - }); - } - throw new Error(`Kimi server at ${origin} did not become healthy within ${HEALTH_TIMEOUT_MS}ms.`); -} diff --git a/apps/kimi-desktop/src/main/index.ts b/apps/kimi-desktop/src/main/index.ts deleted file mode 100644 index 2a765274b0..0000000000 --- a/apps/kimi-desktop/src/main/index.ts +++ /dev/null @@ -1,316 +0,0 @@ -import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'; -import { dirname, join } from 'node:path'; - -import { app, BrowserWindow, Menu, nativeTheme, shell } from 'electron'; -import type { MenuItemConstructorOptions } from 'electron'; - -import { ensureServer, kimiHome, serverLogPath } from './ensure-server'; -import { resolveSeaPath } from './sea-path'; - -let mainWindow: BrowserWindow | null = null; - -// --- window state persistence ------------------------------------------------- - -interface WindowBounds { - width: number; - height: number; - x?: number; - y?: number; -} - -const DEFAULT_BOUNDS: WindowBounds = { width: 1280, height: 860 }; - -function stateFile(): string { - return join(app.getPath('userData'), 'window-state.json'); -} - -function loadBounds(): WindowBounds { - try { - const parsed = JSON.parse(readFileSync(stateFile(), 'utf-8')) as Partial; - if (typeof parsed.width === 'number' && typeof parsed.height === 'number') { - return { - width: parsed.width, - height: parsed.height, - x: typeof parsed.x === 'number' ? parsed.x : undefined, - y: typeof parsed.y === 'number' ? parsed.y : undefined, - }; - } - } catch { - // No saved state yet, or it is unreadable — fall back to defaults. - } - return DEFAULT_BOUNDS; -} - -function saveBounds(win: BrowserWindow): void { - try { - const bounds = win.getBounds(); - mkdirSync(dirname(stateFile()), { recursive: true }); - writeFileSync( - stateFile(), - JSON.stringify({ width: bounds.width, height: bounds.height, x: bounds.x, y: bounds.y }), - ); - } catch { - // Best-effort; losing window position is not worth surfacing an error. - } -} - -// --- startup screens (no separate renderer files; inline data URLs) ----------- - -function dataUrl(html: string): string { - return `data:text/html;charset=utf-8,${encodeURIComponent(html)}`; -} - -const SCREEN_STYLE = ` - -`; - -function loadingHtml(): string { - return `${SCREEN_STYLE} -
-

正在启动 Kimi 本地服务…

-

首次启动可能需要几秒。

`; -} - -function errorHtml(message: string): string { - const safe = message.replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>'); - return `${SCREEN_STYLE} -

无法启动本地服务

-

${safe}

-

查看日志:${serverLogPath()}

-

菜单 → Kimi Code Desktop → 重试连接,或先检查日志。

`; -} - -// --- server auth token -------------------------------------------------------- - -/** On-disk filename of the daemon's persistent bearer token (under KIMI_CODE_HOME). */ -const SERVER_TOKEN_FILE = 'server.token'; - -/** - * Read the daemon's bearer token so the web UI can authenticate without showing - * the manual token dialog on a fresh launch. Returns undefined when the token - * cannot be read (the web UI then falls back to the dialog). - */ -function readServerToken(): string | undefined { - try { - const token = readFileSync(join(kimiHome(), SERVER_TOKEN_FILE), 'utf-8').trim(); - return token.length > 0 ? token : undefined; - } catch { - return undefined; - } -} - -// --- connect flow ------------------------------------------------------------- - -async function connect(win: BrowserWindow): Promise { - await win.loadURL(dataUrl(loadingHtml())); - try { - const { origin } = await ensureServer(resolveSeaPath()); - process.stdout.write(`[kimi-desktop] connected to ${origin}\n`); - if (!win.isDestroyed()) { - // Append a desktop marker so the web UI shows the internal-build banner - // even when it is served by an already-running shared daemon (the desktop - // reuses the local daemon rather than starting a private one). Carry the - // server token in the `#token=` fragment — like `kimi web` does — so the - // web UI can authenticate without falling into the manual token dialog on - // a fresh launch. - const token = readServerToken(); - const fragment = token === undefined ? '' : `#token=${encodeURIComponent(token)}`; - await win.loadURL( - `${origin}/?kimi_desktop=1&platform=${process.platform}${fragment}`, - ); - } - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - process.stderr.write(`[kimi-desktop] ensureServer failed: ${message}\n`); - if (!win.isDestroyed()) { - await win.loadURL(dataUrl(errorHtml(message))); - } - } -} - -function createWindow(): void { - const win = new BrowserWindow({ - ...loadBounds(), - minWidth: 720, - minHeight: 480, - backgroundColor: '#0b0b0c', - title: 'Kimi Code Desktop', - // macOS: hide the native title bar and float the traffic lights over the - // content; the web UI reserves a draggable strip at the top to clear them. - // 'hidden' (not 'hiddenInset') so trafficLightPosition can pin the lights - // to the vertical center of the web UI's 48px header row (y 18 + 12px - // button height / 2 = 24 = the header's midline — same line as the - // sidebar-expand button and the conversation title). - // 'default' on other platforms (they keep their native title bar). - titleBarStyle: process.platform === 'darwin' ? 'hidden' : 'default', - trafficLightPosition: { x: 16, y: 18 }, - webPreferences: { - contextIsolation: true, - nodeIntegration: false, - }, - }); - mainWindow = win; - // Keep the window title as the product name. The web page sets document.title - // ("Kimi Code Web"), which would otherwise replace it. - win.webContents.on('page-title-updated', (event) => { - event.preventDefault(); - }); - // macOS traffic lights. - // - // 1) Visibility across transitions: with titleBarStyle 'hidden' + a custom - // trafficLightPosition, the buttons can vanish (or lose their custom - // position) after a full-screen round-trip or on re-focus. Re-assert both - // on those transitions (observed on Electron 33; belt-and-braces). - // - // 2) Blur is NOT such a case: unfocused traffic lights are merely DIMMED by - // AppKit, and the dimmed color follows the WINDOW appearance, not the - // page (electron#27295) — with the OS in dark mode but the web UI on a - // light theme, the light-gray dimmed dots become invisible against the - // light sidebar. That is fixed by the theme sync below, which keeps the - // window appearance aligned with the web UI's . - if (process.platform === 'darwin') { - const showTrafficLights = (): void => { - if (win.isDestroyed()) return; - win.setWindowButtonPosition({ x: 16, y: 18 }); - win.setWindowButtonVisibility(true); - }; - win.on('enter-full-screen', showTrafficLights); - win.on('leave-full-screen', showTrafficLights); - win.on('focus', showTrafficLights); - - // Theme sync: no preload/IPC channel exists, so inject a tiny observer - // that reports ('light' | 'dark' | 'system') - // through a tagged console message, and mirror it into - // nativeTheme.themeSource (same three states). The startup/error screens - // (data: URLs) have no such attribute and harmlessly report 'system'. - const THEME_TAG = '__kimi_desktop_theme__:'; - win.webContents.on('console-message', (_event, _level, message) => { - if (!message.startsWith(THEME_TAG)) return; - const scheme = message.slice(THEME_TAG.length); - if (scheme === 'light' || scheme === 'dark' || scheme === 'system') { - nativeTheme.themeSource = scheme; - } - }); - win.webContents.on('did-finish-load', () => { - win.webContents - .executeJavaScript( - `(() => { - const report = () => { - const v = document.documentElement.dataset.colorScheme; - console.info(${JSON.stringify(THEME_TAG)} + (v === 'light' || v === 'dark' ? v : 'system')); - }; - new MutationObserver(report).observe(document.documentElement, { - attributes: true, - attributeFilter: ['data-color-scheme'], - }); - report(); - })();`, - ) - .catch(() => { - // Navigation can tear the page down mid-injection; theme sync is - // cosmetic, so ignore. - }); - }); - } - win.on('close', () => { - saveBounds(win); - }); - win.on('closed', () => { - if (mainWindow === win) { - mainWindow = null; - } - }); - void connect(win); -} - -// --- native menu -------------------------------------------------------------- - -function buildMenu(): void { - const isMac = process.platform === 'darwin'; - const appMenu: MenuItemConstructorOptions = { - label: 'Kimi Code Desktop', - submenu: [ - ...(isMac ? [{ role: 'about' as const }, { type: 'separator' as const }] : []), - { - label: '重试连接', - click: () => { - if (mainWindow !== null) { - void connect(mainWindow); - } else { - createWindow(); - } - }, - }, - { - label: '打开服务日志', - click: () => { - void shell.openPath(serverLogPath()); - }, - }, - { type: 'separator' }, - isMac ? { role: 'quit' } : { role: 'close' }, - ], - }; - - const template: MenuItemConstructorOptions[] = [ - appMenu, - { role: 'editMenu' }, - { - label: 'View', - submenu: [ - { role: 'reload' }, - { role: 'forceReload' }, - { role: 'toggleDevTools' }, - { type: 'separator' }, - { role: 'resetZoom' }, - { role: 'zoomIn' }, - { role: 'zoomOut' }, - { type: 'separator' }, - { role: 'togglefullscreen' }, - ], - }, - { role: 'windowMenu' }, - ]; - - Menu.setApplicationMenu(Menu.buildFromTemplate(template)); -} - -// --- app lifecycle ------------------------------------------------------------ - -function main(): void { - // The shared daemon is deliberately left running on quit — it self-exits ~60s - // after the last client disconnects, so we never tear down a server another - // client (CLI / browser / TUI) may still be using. - app.on('window-all-closed', () => { - if (process.platform !== 'darwin') { - app.quit(); - } - }); - - void app.whenReady().then(() => { - buildMenu(); - createWindow(); - app.on('activate', () => { - if (BrowserWindow.getAllWindows().length === 0) { - createWindow(); - } - }); - }); -} - -main(); diff --git a/apps/kimi-desktop/src/main/sea-path.ts b/apps/kimi-desktop/src/main/sea-path.ts deleted file mode 100644 index 8703d72b15..0000000000 --- a/apps/kimi-desktop/src/main/sea-path.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { join } from 'node:path'; - -import { app } from 'electron'; - -// The bundled backend targets the same 6 platform/arch pairs the kimi-code -// native SEA build supports (apps/kimi-code/scripts/native/native-deps.mjs). -const SUPPORTED_TARGETS = new Set([ - 'darwin-arm64', - 'darwin-x64', - 'linux-arm64', - 'linux-x64', - 'win32-arm64', - 'win32-x64', -]); - -/** `-` triple for the current process, validated against the SEA targets. */ -export function currentTarget(): string { - const target = `${process.platform}-${process.arch}`; - if (!SUPPORTED_TARGETS.has(target)) { - throw new Error(`No bundled Kimi server for this platform: ${target}`); - } - return target; -} - -function executableName(): string { - return process.platform === 'win32' ? 'kimi.exe' : 'kimi'; -} - -/** - * Absolute path to the bundled SEA backend executable. - * - * - packaged: `/bin//kimi[.exe]` — placed there by - * electron-builder `extraResources`. - * - dev: `apps/kimi-code/dist-native/bin//kimi[.exe]` — produced by - * `pnpm -C apps/kimi-code build:native:sea`. In dev `app.getAppPath()` is - * `apps/kimi-desktop`, so the sibling app is one level up. - */ -export function resolveSeaPath(): string { - const target = currentTarget(); - const exe = executableName(); - if (app.isPackaged) { - return join(process.resourcesPath, 'bin', target, exe); - } - return join(app.getAppPath(), '..', 'kimi-code', 'dist-native', 'bin', target, exe); -} diff --git a/apps/kimi-desktop/tsconfig.json b/apps/kimi-desktop/tsconfig.json deleted file mode 100644 index 596e2cf729..0000000000 --- a/apps/kimi-desktop/tsconfig.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "extends": "../../tsconfig.json", - "include": ["src"] -} diff --git a/apps/kimi-desktop/tsdown.config.ts b/apps/kimi-desktop/tsdown.config.ts deleted file mode 100644 index c58e8866d8..0000000000 --- a/apps/kimi-desktop/tsdown.config.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { defineConfig } from 'tsdown'; - -// The Electron main process is loaded as CommonJS (`out/main.cjs`). All sources -// under src/main are bundled into a single file; `electron` stays external -// (provided by the Electron runtime) and Node built-ins are external by default. -export default defineConfig({ - entry: { main: 'src/main/index.ts' }, - format: ['cjs'], - platform: 'node', - target: 'node20', - outDir: 'out', - clean: true, - dts: false, - fixedExtension: true, - deps: { neverBundle: ['electron'] }, -}); diff --git a/apps/kimi-web/AGENTS.md b/apps/kimi-web/AGENTS.md deleted file mode 100644 index 3e7dc6299d..0000000000 --- a/apps/kimi-web/AGENTS.md +++ /dev/null @@ -1,63 +0,0 @@ -# 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) + vue-i18n v11. (Tailwind was removed; all styling is via design tokens in `src/style.css` + scoped component styles.) There is no client router and no Pinia; state lives in composables/refs and provide/inject. - -## Design system (normative — required when modifying the UI) - -- **Before changing any component, style, layout, or theme, read the design system view at `src/views/DesignSystemView.vue` (open it as an overlay: long-press the sidebar logo).** It is the canonical design system and visual spec for this app (tokens §02, primitives §03, chat §04, theme rules §05, style rules §06). It consumes the product tokens from `src/style.css` directly, so it stays in sync with the app. New and modified UI must match it. -- **Use the primitives in `src/components/ui/`.** The library covers Button, IconButton, Badge, Pill, Card, Input/Select/Textarea/Field, Dialog, Spinner, MoonSpinner, Link, Menu/MenuItem, SegmentedControl, Tabs, Switch, Checkbox, Avatar, EmptyState, Divider, Tooltip, Banner, Sheet, Skeleton, CommandBar, TopBar. One semantic = one component — do not hand-roll a bespoke button/badge/dialog/input for a single screen. When a primitive replaces an element, **delete the old scoped CSS** (do not append override blocks). -- **Use the tokens, not ad-hoc values.** Colors, fonts, radii, spacing, shadows, z-index, and motion come from the CSS custom properties in `src/style.css` (catalogued in the design-system view §02). Canonical names are `--color-*` / `--radius-*` / `--space-*` / `--text-*` / `--font-*` / `--z-*` / `--shadow-*` / `--ease-*` / `--duration-*` / `--weight-*` / `--leading-*`. A small set of layout/focus tokens keep the `--p-` prefix: `--p-focus-ring`, `--p-selection`, `--p-ic-sm/md/lg`, `--p-sidebar-w`, `--p-content-max/-wide`, `--p-bp-sm/-md`. -- **The moon spinner (🌑…🌘) is reserved** for the chat "waiting for the agent's first response" state only, and is rendered solely by `ui/MoonSpinner.vue`; every other loading state uses the plain `Spinner`. -- **Run `pnpm --filter @moonshot-ai/kimi-web check:style`** (`scripts/check-style.mjs`) — it enforces the §06 anti-pattern rules (no-gradient, no-glassmorphism except TopBar `frost`, no-emoji-icon except moon, no-hardcoded-hex/font, radius/z/weight from scale). Do not add new violations. -- **Verify visually.** For any UI change, render it in the browser (light + dark, plus hover/focus states) and confirm it matches the design-system view and introduces no regression before considering it done. Build/typecheck/check-style are necessary but not sufficient. - -## 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/` — SFCs grouped by area: `chat/` (conversation/chat UI), `settings/` (settings & configuration), `dialogs/` (modal dialogs & sheets), `mobile/` (mobile-specific shell), `ui/` (design-system primitives — see "Design system" above), plus shared layout components at the top level. -- `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 deleted file mode 100644 index 85dc3ac22a..0000000000 --- a/apps/kimi-web/package.json +++ /dev/null @@ -1,39 +0,0 @@ -{ - "name": "@moonshot-ai/kimi-web", - "version": "0.1.2", - "private": true, - "license": "MIT", - "type": "module", - "scripts": { - "dev": "vite", - "build": "vite build", - "typecheck": "vue-tsc --noEmit", - "test": "vitest run", - "check:style": "node scripts/check-style.mjs" - }, - "dependencies": { - "@chenglou/pretext": "0.0.8", - "@fontsource-variable/inter": "5.2.8", - "@fontsource-variable/jetbrains-mono": "^5.2.8", - "@xterm/addon-fit": "^0.11.0", - "@xterm/xterm": "^6.0.0", - "katex": "^0.17.0", - "markstream-vue": "^1.0.4", - "mermaid": "^11.15.0", - "shiki": "^4.3.0", - "stream-markdown": "^0.0.16", - "vue": "^3.5.35", - "vue-i18n": "^11.4.5" - }, - "devDependencies": { - "@iconify-json/ri": "^1.2.10", - "@iconify-json/tabler": "^1.2.35", - "@vitejs/plugin-vue": "^5.2.4", - "typescript": "6.0.2", - "unplugin-icons": "^23.0.0", - "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 deleted file mode 100644 index 9b4870b727..0000000000 Binary files a/apps/kimi-web/public/favicon.ico and /dev/null differ diff --git a/apps/kimi-web/scripts/check-style.mjs b/apps/kimi-web/scripts/check-style.mjs deleted file mode 100644 index 81480efe96..0000000000 --- a/apps/kimi-web/scripts/check-style.mjs +++ /dev/null @@ -1,246 +0,0 @@ -#!/usr/bin/env node -// check-style.mjs — design-system §06 anti-pattern guard for apps/kimi-web. -// -// Scans src/** for the rules in the design system (§06 of the DesignSystemView spec): -// no-gradient-text, no-glassmorphism (.frost exempt), no-color-glow, -// icon-from-registry (hand-written ; Icon/Spinner/MoonSpinner + the -// 32x22 brand mark exempt), no-emoji-icon (moon in MoonSpinner exempt), -// no-hardcoded-hex (DiffView/DiffLines/Terminal domain colors + var() -// fallbacks exempt), no-hardcoded-font (token definitions exempt), -// radius-from-scale, z-from-scale, weight-from-scale. -// -// Default mode: report a baseline and exit 0 (warnings only). Pass --strict -// to exit 1 when any finding exists (flipped on in P3 enforcement). - -import fs from 'node:fs'; -import path from 'node:path'; -import { fileURLToPath } from 'node:url'; - -const __dirname = path.dirname(fileURLToPath(import.meta.url)); -const ROOT = path.resolve(__dirname, '..'); -const SRC = path.join(ROOT, 'src'); -const STRICT = process.argv.includes('--strict'); - -const DOMAIN_HEX_EXEMPT = new Set([ - 'chat/DiffView.vue', - 'chat/DiffLines.vue', - 'Terminal.vue', -]); - -// Files that legitimately render their own : bespoke data-viz / colored -// illustrations, the spinner, and brand marks (the Kimi wordmark on the loading -// screen). Everything else should use lib/icons.ts via /iconSvg(). The -// 32x22 Kimi eye logo is also exempted inline (matched by viewBox). The icon -// primitive (components/ui/Icon.vue) itself renders no hand-written , so it -// is not exempted here. -const ICON_EXEMPT = new Set([ - 'components/ui/Spinner.vue', - 'components/ui/MoonSpinner.vue', - 'components/ui/ContextRing.vue', - 'components/ui/AuthStateIcon.vue', - 'components/GlobalLoading.vue', -]); - -// Files entirely exempt from the §06 scan. The design-system showcase view is -// documentation/demo CSS (forced-dark previews, syntax-highlighting palettes, -// illustrative mockups) rather than product UI, so the anti-pattern rules do not -// apply to it. -const FILE_EXEMPT = new Set(['views/DesignSystemView.vue']); - -const RADIUS_SCALE = new Set([4, 6, 8, 12, 16, 20, 999]); -const WEIGHT_OK = new Set([ - '400', '500', - 'normal', 'bolder', 'lighter', - 'inherit', 'initial', 'unset', 'revert', -]); -const Z_OK = new Set(['0', '1', '-1', 'auto']); - -/** @type {{ rule: string, file: string, line: number, detail: string }[]} */ -const findings = []; - -function walk(dir, out = []) { - for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { - if (entry.name === 'node_modules' || entry.name === 'dist') continue; - const full = path.join(dir, entry.name); - if (entry.isDirectory()) walk(full, out); - else if (/\.(vue|css)$/.test(entry.name)) out.push(full); - } - return out; -} - -function rel(abs) { - return path.relative(SRC, abs).replaceAll(path.sep, '/'); -} - -function lineOf(text, index) { - let n = 1; - for (let i = 0; i < index; i++) if (text.charCodeAt(i) === 10) n++; - return n; -} - -function add(rule, file, line, detail) { - findings.push({ rule, file, line, detail }); -} - -function stripVarSpans(line) { - // Remove var(...) substrings so var() fallbacks don't trip hex checks. - return line.replace(/var\([^()]*(?:\([^()]*\)[^()]*)*\)/g, ''); -} - -function extractStyleBlocks(content) { - // For .vue: return [{text, baseLine}] for each
- -
-
- -
-

{{ t('app.authPageTitle') }}

-

{{ t('app.authPageMessage') }}

-
- -
-
-
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
- - -
- - - - - diff --git a/apps/kimi-web/src/api/config.ts b/apps/kimi-web/src/api/config.ts deleted file mode 100644 index 620e9eca8e..0000000000 --- a/apps/kimi-web/src/api/config.ts +++ /dev/null @@ -1,102 +0,0 @@ -// apps/kimi-web/src/api/config.ts -// Reads Vite env, builds REST/WS URLs, manages stable clientId. - -import { safeGetString, safeSetString, STORAGE_KEYS } from '../lib/storage'; - -const CLIENT_ID_KEY = STORAGE_KEYS.clientId; -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 = safeGetString(CLIENT_ID_KEY); - if (stored) return stored; - const generated = `web_${globalThis.crypto?.randomUUID?.() || Math.random().toString(36).slice(2)}`; - safeSetString(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 deleted file mode 100644 index bcfefbb5d3..0000000000 --- a/apps/kimi-web/src/api/daemon/agentEventProjector.ts +++ /dev/null @@ -1,1438 +0,0 @@ -// 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 { toolLabel, toolSummary } from '../../lib/toolMeta'; -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 live progress is surfaced separately via the -// subagent.* → task → right-side detail panel path (the spawning `Agent` tool -// itself renders as a normal tool card in the transcript). 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', - 'error', -]); - -// --------------------------------------------------------------------------- -// 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-step accumulated stream lengths — aligned against the (step-relative) - // 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; -} - -export function subagentProgressText(rawType: string, payload: Record): string | null { - // "Started a step" fires on every step and adds no information — the phase - // badge already shows the subagent is working, so skip it to cut the noise. - if (rawType === 'turn.step.started') return null; - if (rawType === 'tool.use' || rawType === 'tool.call.started') { - const name = stringField(payload, 'name') ?? stringField(payload, 'toolName') ?? 'tool'; - const label = toolLabel(cleanToolName(name)); - const summary = toolArgSummary(name, payload['args'] ?? payload['input']); - return summary ? `Calling ${label}: ${summary}` : `Calling ${label}`; - } - if (rawType === 'tool.progress') { - const update = payload['update']; - if (update && typeof update === 'object') { - const text = stringField(update as Record, 'text'); - if (text) return capProgressText(text); - const message = stringField(update as Record, 'message'); - if (message) return capProgressText(message); - } - const message = stringField(payload, 'message'); - if (message) return capProgressText(message); - } - // tool.result lines ("Finished X") add noise without much information — the - // next call or the final summary already implies completion — so skip them. - if (rawType === 'tool.result') return null; - return null; -} - -/** Strip a trailing `_N` index that some subagents append to tool names in - * `tool.result` events (e.g. `Read_0` → `Read`) so the label resolves. */ -function cleanToolName(name: string): string { - return name.replace(/_\d+$/, ''); -} - -/** Cap a progress text chunk so a single huge tool output (e.g. a big command - * result) cannot dominate the panel. */ -const MAX_PROGRESS_TEXT = 2000; -function capProgressText(text: string): string { - return text.length > MAX_PROGRESS_TEXT ? `${text.slice(0, MAX_PROGRESS_TEXT)}…` : text; -} - -/** A concise, human-readable summary of a tool call's arguments for progress - * lines (e.g. a file path or shell command), instead of the full JSON blob. */ -function toolArgSummary(name: string, args: unknown): string { - if (args === undefined || args === null) return ''; - const arg = typeof args === 'string' ? args : JSON.stringify(args); - return toolSummary(name, arg); -} - -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 []; - - // The subagent's own streamed text: forward each delta as a `text`-kind - // progress chunk so the reducer concatenates it into `AppTask.text`, letting - // the right-side detail panel show the subagent's output growing live (like - // a thinking block) instead of staying blank until the first tool call. - if (rawType === 'assistant.delta') { - const delta = stringField(payload, 'delta'); - if (!delta) return []; - // Ensure the subagent task exists before forwarding the text delta. A client - // that subscribed from a snapshot after `subagent.spawned` already fired - // never received the lifecycle taskCreated, and the reducer only applies - // taskProgress to existing tasks — without this, the deltas are dropped and - // the live detail stays blank until a non-text frame recreates the task. - 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: delta, - stream: 'stdout', - kind: 'text', - }); - return out; - } - - 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 — the current step only; earlier - * steps arrive via the transcript), and returns the messageCreated AppEvent - * 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. Session status is NOT seeded here — the REST - * snapshot's `session.status` is the authoritative value. - */ - 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; - // Seeded step-relative lengths; the next turn.step.started resets both. - s.turnTextLen = turn.assistantText.length; - s.turnThinkLen = turn.thinkingText.length; - - return [{ 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 also reports the latest user prompt - // via patch.lastPrompt. It announces all of these via this event. We - // don't have the full AppSession here, so emit a lightweight - // sessionMetaUpdated that patches only the changed meta fields. - const title: string | undefined = p?.patch?.title ?? p?.title; - const lastPrompt: string | undefined = p?.patch?.lastPrompt; - const patch: { title?: string; lastPrompt?: string } = {}; - if (typeof title === 'string' && title.length > 0) patch.title = title; - if (typeof lastPrompt === 'string') patch.lastPrompt = lastPrompt; - if (patch.title !== undefined || patch.lastPrompt !== undefined) { - out.push({ type: 'sessionMetaUpdated', sessionId, ...patch }); - } - 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. - // Session status is intentionally NOT projected here — the daemon's - // `event.session.status_changed` is the single source of status - // transitions (it carries the authoritative previousStatus / - // currentPromptId and dedupes per real transition); projecting a - // second running/idle event per turn from the raw stream made every - // turn-end consumer (notifications, sounds) fire twice. - 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 step stream offsets. - s.turnTextLen = 0; - s.turnThinkLen = 0; - 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); - } - - // Fresh step → fresh stream offsets: the server's delta `offset` is - // step-relative, so without this reset every delta from step 2 on is - // silently skipped or misread as a gap. - s.turnTextLen = 0; - s.turnThinkLen = 0; - - // 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' || reason === 'blocked' ? 'error' : 'completed', - durationMs, - }); - } - } - - s.turnCount++; - const usageSnapshot = buildUsageSnapshot(s); - out.push({ type: 'sessionUsageUpdated', sessionId, usage: usageSnapshot }); - - // No sessionStatusChanged here — see turn.started. The daemon's - // `event.session.status_changed` flips the session to idle/aborted. - - // 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, - runInBackground: p?.runInBackground === true, - }; - 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; - } - - // ----------------------------------------------------------------------- - // Tasks (e.g. a detached Bash command). Real daemon shape: - // payload.info = { taskId, description, status, startedAt(ms), endedAt, - // kind:'process', command, pid, exitCode }. - case '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 '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; - } - - // ----------------------------------------------------------------------- - case 'cron.fired': { - // A scheduled reminder fired into the session. agent-core persists the - // injected user message (so a refresh renders it via messagesToTurns), - // but turn.steer() does NOT broadcast a prompt.submitted / message.created - // for it — synthesize one here so the notice shows up live too. A later - // snapshot reload replaces the message log wholesale, so this synthesized - // copy never duplicates the persisted one. The promptId is intentionally - // omitted: the web client caches every user message's promptId into - // promptIdBySession for Stop/abort, and a synthetic id the daemon would - // reject would clobber the real active promptId. The reducer already skips - // optimistic-echo reconciliation for cron-origin messages, so no promptId - // is needed for de-dup either. - const origin = p?.origin; - const promptText = stringField(p ?? {}, 'prompt'); - if ( - origin && - typeof origin === 'object' && - (origin as Record)['kind'] === 'cron_job' && - promptText - ) { - const msg: AppMessage = { - id: ulid('cron_'), - sessionId, - role: 'user', - content: [{ type: 'text', text: promptText }], - createdAt: new Date().toISOString(), - metadata: { origin: origin as Record }, - }; - s.messages.push(msg); - out.push({ type: 'messageCreated', message: cloneMessage(msg) }); - } - break; - } - - // ----------------------------------------------------------------------- - // Explicitly known but not projected - case 'compaction.blocked': - 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', - 'task.started', - 'task.terminated', - 'background.task.started', - 'background.task.terminated', - 'cron.fired', -]); - -/** - * "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', - // 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 deleted file mode 100644 index 5ff57e741a..0000000000 --- a/apps/kimi-web/src/api/daemon/client.ts +++ /dev/null @@ -1,1554 +0,0 @@ -// 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 { traceKeyEvent } from '../../debug/trace'; -import type { - AppConfig, - AppGoal, - AppMessage, - AppMessageRole, - AppModel, - AppProvider, - ProviderRefreshResult, - AppSession, - AppSkill, - AppSessionCursor, - AppSessionRuntimeStatus, - AppSessionSnapshot, - AppSessionStatus, - AppTask, - AppTaskStatus, - AppTerminal, - AppWorkspace, - ApprovalResponse, - FsBrowseResult, - FsEntry, - KimiEventConnection, - KimiEventHandlers, - KimiWebApi, - OAuthLoginStartResult, - Page, - PageRequest, - PromptSubmission, - PromptSubmitResult, - QuestionResponse, -} from '../types'; -import { createAgentProjector } from './agentEventProjector'; -import { DaemonHttpClient } from './http'; -import { - toAppApprovalRequest, - toAppConfig, - toAppEvent, - toAppFsEntry, - toAppGoal, - toAppMessage, - toAppModel, - toAppProvider, - toAppQuestionRequest, - toAppSession, - toAppTask, - toWireApprovalResponse, - toWirePromptSubmission, - toWireQuestionResponse, - toWireSessionStatus, - toAppWorkspace, - wireEventSeq, - wireEventSessionId, -} from './mappers'; -import type { - WireAuthResult, - WireTask, - WireConfig, - WireEvent, - WireFileMeta, - WireFsBrowseResult, - WireFsEntry, - WireFsHomeResult, - WireGoalSnapshot, - WireMessage, - WireModel, - WireOAuthCancelResult, - WireOAuthLoginPollResult, - WireOAuthLoginStartResult, - WirePage, - WirePromptSubmitResult, - WirePromptSteerResult, - WireProvider, - WireProviderRefreshResult, - WireSession, - WireSessionAbortResult, - WireSessionWarning, - WireSessionWarningsResponse, - WireSessionRuntimeStatus, - WireSessionSnapshot, - WireWorkspace, - WireLogoutResult, -} from './wire'; -import { DaemonEventSocket } from './ws'; - -function safeExportFileName(contentDisposition: string | undefined, fallback: string): string { - if (contentDisposition === undefined) return fallback; - let candidate: string | undefined; - const encoded = /filename\*\s*=\s*UTF-8''([^;]+)/i.exec(contentDisposition)?.[1]?.trim(); - if (encoded !== undefined) { - try { - candidate = decodeURIComponent(encoded.replaceAll(/^"|"$/g, '')); - } catch { - return fallback; - } - } else { - candidate = - /filename\s*=\s*"([^"]*)"/i.exec(contentDisposition)?.[1] ?? - /filename\s*=\s*([^;]+)/i.exec(contentDisposition)?.[1]?.trim(); - } - if ( - candidate === undefined || - candidate.length === 0 || - candidate.length > 200 || - candidate === '.' || - candidate === '..' || - /[\u0000-\u001F\u007F/\\]/.test(candidate) || - !candidate.toLowerCase().endsWith('.zip') - ) { - return fallback; - } - return candidate; -} - -function errorTraceMetadata(err: unknown): Record { - if (typeof err !== 'object' || err === null) return { errorName: typeof err }; - const value = err as { - name?: unknown; - code?: unknown; - requestId?: unknown; - phase?: unknown; - status?: unknown; - }; - return { - errorName: typeof value.name === 'string' ? value.name : 'Error', - errorCode: typeof value.code === 'number' ? value.code : undefined, - requestId: typeof value.requestId === 'string' ? value.requestId : undefined, - phase: typeof value.phase === 'string' ? value.phase : undefined, - httpStatus: typeof value.status === 'number' ? value.status : undefined, - }; -} - -// --------------------------------------------------------------------------- -// 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[]; - dangerous_bypass_auth?: boolean; - /** Engine generation serving the API; older (v1) servers omit the field. */ - backend?: 'v1' | 'v2'; -} - -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[]; - dangerousBypassAuth: boolean; - /** Engine generation: 'v2' = kap-server / agent-core-v2; absent ⇒ 'v1'. */ - backend: 'v1' | 'v2'; - }> { - 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 : [], - dangerousBypassAuth: data.dangerous_bypass_auth === true, - backend: data.backend === 'v2' ? 'v2' : 'v1', - }; - } - - // ------------------------------------------------------------------------- - // Sessions - // ------------------------------------------------------------------------- - - async listSessions( - input?: PageRequest & { - status?: AppSessionStatus; - workspaceId?: string; - includeArchive?: boolean; - archivedOnly?: boolean; - excludeEmpty?: 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, - archived_only: input?.archivedOnly, - exclude_empty: input?.excludeEmpty, - // 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, - thinkingEffort: 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, - }; - } - - /** - * GET /sessions/{id}/goal — the session's current goal, or null when no goal - * is active. - */ - async getSessionGoal(sessionId: string): Promise { - const data = await this.http.get( - `/sessions/${encodeURIComponent(sessionId)}/goal`, - ); - return toAppGoal(data); - } - - async getSessionWarnings(sessionId: string): Promise { - const data = await this.http.get( - `/sessions/${encodeURIComponent(sessionId)}/warnings`, - ); - return data.warnings ?? []; - } - - async archiveSession(sessionId: string): Promise<{ archived: true }> { - const data = await this.http.post( - `/sessions/${encodeURIComponent(sessionId)}:archive`, - {}, - ); - return data; - } - - // POST /sessions/{id}:restore — clear the archived flag. The daemon returns - // the full restored session, so callers can merge it straight back into lists. - async restoreSession(sessionId: string): Promise { - const data = await this.http.post( - `/sessions/${encodeURIComponent(sessionId)}:restore`, - {}, - ); - return toAppSession(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 startedAt = Date.now(); - traceKeyEvent('session:snapshot:start', { sessionId }); - try { - const data = await this.http.get( - `/sessions/${encodeURIComponent(sessionId)}/snapshot`, - ); - const snapshot: AppSessionSnapshot = { - 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), - // Older servers omit the roster entirely; treat as an empty roster. - subagents: (data.subagents ?? []).map(toAppTask), - }; - traceKeyEvent('session:snapshot:accepted', { - sessionId, - status: snapshot.session.status, - seq: snapshot.asOfSeq, - messageCount: snapshot.messages.length, - durationMs: Date.now() - startedAt, - }); - return snapshot; - } catch (error) { - traceKeyEvent('session:snapshot:failed', { - sessionId, - status: 'failed', - durationMs: Date.now() - startedAt, - ...errorTraceMetadata(error), - }); - throw error; - } - } - - async exportSession( - sessionId: string, - webLog?: string, - ): Promise<{ blob: Blob; fileName: string }> { - const webLogBytes = webLog === undefined ? 0 : new TextEncoder().encode(webLog).byteLength; - const webLogEntries = webLog === undefined || webLog.length === 0 ? 0 : webLog.split('\n').length; - const result = await this.http.postZip( - `/sessions/${encodeURIComponent(sessionId)}/export`, - { web_log: webLog }, - { web_log_bytes: webLogBytes, web_log_entries: webLogEntries }, - ); - const fallback = `${sessionId}.zip`; - return { - blob: result.blob, - fileName: safeExportFileName(result.contentDisposition, fallback), - }; - } - - // ------------------------------------------------------------------------- - // Prompt - // ------------------------------------------------------------------------- - - async submitPrompt( - sessionId: string, - input: PromptSubmission, - ): Promise { - const startedAt = Date.now(); - traceKeyEvent('prompt:start', { - sessionId, - contentCount: input.content.length, - mediaCount: input.content.filter((part) => - part.type === 'image' || part.type === 'video' || part.type === 'file' - ).length, - }); - try { - const data = await this.http.post( - `/sessions/${encodeURIComponent(sessionId)}/prompts`, - toWirePromptSubmission(input), - ); - traceKeyEvent('prompt:accepted', { - sessionId, - promptId: data.prompt_id, - status: data.status, - durationMs: Date.now() - startedAt, - }); - return { - promptId: data.prompt_id, - userMessageId: data.user_message_id, - status: data.status, - }; - } catch (error) { - traceKeyEvent('prompt:failed', { - sessionId, - status: 'failed', - durationMs: Date.now() - startedAt, - ...errorTraceMetadata(error), - }); - throw error; - } - } - - // 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: WireTask[] }>( - `/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 — slash-invocable skills (session- or workspace-scoped) - // GET /sessions/{id}/skills → { skills: WireSkillDescriptor[] } - // GET /workspaces/{id}/skills → { skills: WireSkillDescriptor[] } (no session) - // 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 listSkillsForWorkspace(workspaceId: string): Promise { - const data = await this.http.get<{ skills: WireSkillDescriptor[] }>( - `/workspaces/${encodeURIComponent(workspaceId)}/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? }. Throws on error (e.g. - * path not found) so the caller can surface it to the user. - */ - 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)}`); - } - - /** - * Rename a workspace (display name only). - * PATCH /api/v1/workspaces/:id { name }. On error this throws. - */ - async updateWorkspace(id: string, input: { name: string }): Promise { - const data = await this.http.patch( - `/workspaces/${encodeURIComponent(id)}`, - { name: input.name }, - ); - return toAppWorkspace(data); - } - - /** - * 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 { - const data = await this.http.post( - `/providers/${encodeURIComponent(id)}:refresh`, - ); - return toProviderRefreshResult(data); - } - - async refreshAllProviders(): Promise { - const data = await this.http.post('/providers:refresh'); - return toProviderRefreshResult(data); - } - - async refreshOAuthProviderModels(): Promise { - const data = await this.http.post('/providers:refresh_oauth'); - return toProviderRefreshResult(data); - } - - // ------------------------------------------------------------------------- - // 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', - 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 { - const data = await this.http.post('/oauth/login', {}); - if (data.status === 'authenticated') { - return { - flowId: data.flow_id, - provider: data.provider, - status: 'authenticated', - }; - } - return { - flowId: data.flow_id, - provider: data.provider, - status: 'pending', - verificationUri: data.verification_uri, - verificationUriComplete: data.verification_uri_complete, - userCode: data.user_code, - expiresIn: data.expires_in, - interval: data.interval, - 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)}`); - } - - /** Fetch a file's bytes with the Bearer credential attached. Use this (not - * getFileUrl) when the bytes feed a