Imported from bhjia-phys/Hakimi (
packages/kap-server/AGENTS.md). Install upstream withnpx skills add bhjia-phys/Hakimi --skill kap-server. Copyright stays with the author.
kap-server Agent Guide
The Kimi Code server, backed by the DI × Scope agent engine (@moonshot-ai/agent-core-v2 — four scopes, App/Workspace/Session/Agent). Exposes sessions over REST + WebSocket (/api/v1 + /api/v1/ws); bootstrapped from src/start.ts and consumed by apps/kimi-code.
Routes
- Session create/resume/fork routes compose
ISessionIndex→IWorkspaceLifecycleService.handlerFor→ the handler'sISessionLifecycleService, and the fs routes resolve session → handler → the Workspace-scope fs services. One exception:fs:searchalso accepts a workspace reference (registered id or absolute root) in the{session_id}slot, so a not-yet-created draft session's@file mention resolves the workspace handler directly; the first-class session-less form isPOST /api/v1/workspace/fs:search(the workspace reference travels in the body). GET /provider-usage?provider=<id>(src/routes/providerUsage.ts) is the full provider-usage surface: oneIProviderUsageService.queryUsagecall per request, projecting the domain results (oksummary/limits/extra_usage,error/unsupportedmessage+status) to the same snake_case mapping as/oauth/usage(src/routes/oauth.tstoWireUsage). The service owns endpoint resolution, credentials, and error scrubbing — the route caches nothing and never re-touches credentials.- Subagent preset config boundaries live in
src/routes/config.ts:GET /config/subagent-preset/statusreturns the App-scopeIAutoSubagentPresetService.status()snapshot through an explicit snake_case, secret-safe projector (data: nullbefore the first evaluation) and never writes it into/configorconfig.toml;POST /config/subagent-preset/activateaccepts a configured preset name (or''for base routing), delegates validation and serialization toISubagentPresetActivationService, and returns the authoritative redacted config snapshot. Do not replace activation with a generic multi-domain/configpatch; that would bypass the manual-revision/write-lock contract shared with automatic selection. - The RPC surface is
/api/v1/debug/*— a reflection dispatcher over the ENTIRE scoped DI registry (every Service callable, no whitelist, Workspace scope addressable alongside App/Session/Agent;src/transport/registerDebugRoutes.ts+serviceDispatcherRoutes.ts), mounted only with--debug-endpointson a loopback bind and gated by the global bearer auth; repo dev scripts pass the flag. Lookup falls back to the Feature contributed-service table (features/featureRegistry), so Feature-contributed Services (contributeService, which bypasses the static scoped registry) stay callable even thoughGET /channelsdoes not list them; kernel tokens registered neither way (e.g.instantiationService) stay unreachable. startServer({ remoteAccess: { sessionId } })remains an opt-in post-auth restricted embedding boundary (src/middleware/remoteAccess.ts): HTTP is an explicit Web-bootstrap + target-session allowlist with dedicated40302denials,GET /sessionsprojects only the target, prompt submissions retain content but drop runtime/config overrides, error envelopes drop stack/provider/path details, and WS rejects foreign subscriptions/fs watches while dropping foreign and__global__fan-out.sessionId: nullis the corresponding ALL-SESSIONS restricted embedding mode. Product remote control (RemoteAccessEdgeFactory, TUI/remote, and persistenthakimi remote) deliberately does NOT pass this option: after token authentication it exposes the standard Web data plane. WhenremoteAccessis used through a public tunnel, forcebindClass: 'public'; authentication and Host validation must stay enabled.- Remote-share control (
src/remoteShare/; gated by theremote_controlexperimental flag, envKIMI_CODE_EXPERIMENTAL_REMOTE_CONTROL, surfaced in/api/v1/metaexperimental_flags): whenstartServer({ remoteShareController })gets a host-suppliedIRemoteShareControllerAND the flag is on, the main listener mountsGET /api/v1/remote-shareandPOST /api/v1/remote-share:start(body{session_id, ttl?};40927when one share is already active) /POST /api/v1/remote-share:stop, all behind the main bearer auth; every success returns the browser-safeRemoteShareStatus, includingurl: string | null(the complete tunnel URL, including any fragment credential) but NEVER a separate rawtoken.controller.start(input, edgeFactory)still returns the internalRemoteShareStartResultwithtokenso the host can construct/storeurl; the basic controller has no tunnel and reportsurl: null. Control routes MUST pass status/start/stop through the explicitprojectRemoteShareStatusfield projector — never spread an internal start result onto the wire.close()callscontroller.close()BEFORE any app/core teardown. The controller owns one share at a time and mints the ephemeral per-share credential; it must NEVER start a second Core. The producer-suppliedRemoteAccessEdgeFactory(built in thestartServerclosure over the shared core/transcript/broadcaster/fs bridge/gui store) receives{sessionId, authTokenService, webAssetsDir?}and returns{host, port, close}— the edge is a127.0.0.1:0Fastify+WS listener with its own connection registry / auth limiter / wss that hardens like a public bind (.trycloudflare.comHost, security headers), authenticates only the ephemeral per-share credential, and mounts the standard v1/v2 Web data plane plus full WS events — debug, shutdown, terminals, OpenAPI docs/control routes, and instance-registry registration remain absent.edge.close()tears down ONLY the edge's own listener/connections/limiter. The edge does not register the:actioncontrol paths, so they return not found.
/api/v2 surface
GET /api/v2/sessions (src/routes/v2/sessions.ts, mounted by src/routes/registerApiV2Routes.ts) is the first endpoint of the v2 API. The v2 surface shares v1's wire conventions: every response is wrapped in the { code, msg, data, request_id } envelope with the business outcome in code (40001 invalid query params with details, 40922 page_token mismatch), and the HTTP status only reports server-/transport-level outcomes (401 from the global auth hook, 50001 via the catch-all error hook). Pagination is an opaque page_token (base64url JSON: version + sha256 query-condition fingerprint + keyset position) — any condition flip mid-pagination fails 40922. Response domains are grouped (workspace / meta / activity always; git opt-in via include=git, deduped per unique cwd with a 60s TTL cache over IGitService, all git/gh failures degrading to cached null fields). Sorts/filters are applied at the edge over the index's canonical updatedAt desc, id desc drain, so all three sort orders share one comparator + cursor encoding; activity.status maps the core ISessionActivityView facts (pending interaction > active turn > failed last turn > idle; cold sessions are always idle).
Transcript surface
Implements the op-batch sequencing contract:
TranscriptService.dispatchOpsassigns every dispatched batch a per-agent consecutiveseqand retains it in a bounded in-memory journal (TRANSCRIPT_OPS_JOURNAL_CAPACITY, dies with the live store). WStranscript.ops/transcript.resetpayloads carry the seq/watermark.- A
transcript_sincesubscription cursor (carried, with the per-agent grades, by thesubscribe_v2control frame — the only transcript subscription channel; its agent-grained counterpartunsubscribe_v2detaches listed agents' streams, or the whole session's whenagent_idsis absent, letting the detached agents' legacy events flow again) replays journaled batches instead of a baseline reset when the journal covers it, andGET /sessions/{id}/transcript/ops?since_seq=serves point-to-point catch-up (complete: false= journal can't cover or session cold → caller falls back to a full refresh). - Beside the paged route,
GET /sessions/{id}/transcript/plan?agent_id=[&tool_call_id=]projects an agent's ExitPlanMode plan info (content / path / options / review outcome;tool_call_idnarrows to one call, omitted lists every recoverable plan) from the first available fact — the linked approval interaction's persisted request display, the live tool frame's display, or the tool result output text. - The baseline
transcript.resetitself is items-empty (TRANSCRIPT_RESET_TAIL_TURNS = 0): it carries only global state + the watermark +has_more_older, because history always pages in over REST. - When a WS connection subscribes to the transcript protocol (grade ≠
offfor an agent), the broadcaster suppresses the transcript-projectedsession_eventtypes for that connection × agent (TRANSCRIPT_PROJECTED_EVENT_TYPES+suppressedByTranscriptinsessionEventBroadcaster.ts; cursor replay viagetBufferedSinceapplies the same filter). Suppression is only a per-connection send view — the journal still records everything, and connections without transcript grades are unaffected.
Session events
- The session's work aggregate behind
event.session.work_changed(busy/main_turn_active/pending_interaction/last_turn_reason) is owned by the core'sISessionActivityView(sessionActivitydomain, Session scope): the broadcaster only schedules the wire emission around turn frames (busy:falselands afterturn.ended), andresolveSessionFacts(src/routes/sessions.ts) reads the same view — never fold per-agent activity at the edge. - Delivery split on
/api/v1/ws: global events (session.meta.updatedand theevent.session.*/event.workspace.*/event.config.*/event.di.*families, including every activated session'sevent.session.work_changed) fan out to EVERY established connection —WsConnectionV1registers itself viabroadcaster.addGlobalTargeton construction and unregisters on close — while session/agent-grained events only reach connections subscribed to that session (subject toagent_filterand the transcript suppression above); transcript frames are a separate channel governed by the per-agent grades alone and bypassagent_filterentirely. One exception: the high-churnevent.di.*debug feed only reaches connections opted in viabroadcaster.addDiEventTarget— a temporary gate until a client-declared event whitelist exists, currently keyed onclient_hellocarryingclient_id: 'kimi-inspect'. event.subagent.preset_evaluatedandevent.subagent.preset_changedare App-scope facts with a real originatingsessionId:SessionEventBroadcasterstrictly validates them, strips unknown fields through explicit snake_case projection, writes them to that real session's durable journal, and still globally fans them out. Malformed facts are dropped; extra publisher fields are tolerated but never forwarded.event.config.changedhas one producer: the process-wide bridge instart.tsobserves effectiveIConfigService.onDidChangeConfigurationchanges from REST writes, internal tools, and external reloads (source === 'set' | 'reload'), drops deep-equal noops, and batches consecutive domains through a zero-delay task into one de-duplicated snake_casechangedFieldslist. The bridge MUST buildconfigwithroutes/config.ts::toConfigResponse: that single outbound projection maps providers tohas_api_key, recursively strips credential fields from other domains, and omits arbitraryraw, keeping REST responses, WS frames, and the durable global journal secret-free. It is disposed first inclose()and cancels a pending batch so nothing publishes into a closing broadcaster.SessionEventBroadcaster.onCoreEventthen projects the payload onto the flat v1ConfigChangedEventframe (changedFields+config, strict zod validation, no spread) and fans it out globally viadispatchGlobal; publishing any alternative shape will be dropped.
Global search
The global search surface is POST /api/v1/search (src/search/ + src/routes/search.ts): a cross-session full-text search over user messages, assistant text, and session titles, backed by a single minidb database at <home>/search-index (IGlobalSearchService, App scope — the write-lock holder is the indexer, other processes open read-only and catch up via WAL).
- Everything that touches the search-index MiniDb lives in the host-agnostic core (
src/search/indexCore.ts): open/reopen (writer election, corruption rebuild), read-only freshness (fingerprint + WAL catch-up), the incremental sync pass, the bounded index-route query execution, reindex, and close. It runs in one of two hosts behind theSearchBackendseam: a dedicated worker thread (src/search/worker/—host.tsRPC client +entry.tsworker entry +protocol.tscontract; the default) or the inline in-process host (the rollback). Thesearch_workerexperimental flag (KIMI_CODE_EXPERIMENTAL_SEARCH_WORKER, default ON) selects the host at service construction. The main thread keeps only query normalization, page-token encode/decode, the sync coordinator, the live route, and hit projection; worker failure/crash surfaces as recognizablebuilding/degradedpages — never an implicit inline fallback. Host lifecycle guards: the worker reports the write-lock token via alockTokenevent the moment the lock is acquired (OpenOptions.onLockAcquired), so a mid-open crash leaves a reapable lock; an orphan detector reaps a same-pid lock line whose token belongs to no live worker of the process and restarts (never a silent permanent read-only); every RPC carries a watchdog timeout (a wedged worker is terminated and restarted with backoff);beginClosereaches the worker-side core via a control message so dispose never wedges behind a running sync; and the token-pinning generation is salted per worker boot, so page tokens never validate across a worker restart. - It serves two modes:
terms(the default — minidb's inverted text index over ASCII words + CJK uni/bigrams, no positions, term-level AND) andliteral(substring-exact search: a hashed 2/3-gram index supplies candidates, every candidate's text is then confirmed withincludes, so hits carry zero false positives; literal ignoressortand returns newest-first). - The index route is fully bounded (stage 4): a search request serves the currently published generation and never awaits a sync/reopen/reindex — it kicks the single-flight + debounced background coordinator instead, and reports
index_state.stale/index_state.degradedwhen serving a behind view or after a failed refresh, andindex_state.state: 'building'while the served handle's text base is still being (re)built by the deferred fallback build (searches get the empty building page, never a partial result). - The aggregate lifecycle (stage 5) —
stopped → opening → ready → building/degraded → closing— is diagnosable two ways:IGlobalSearchService.status()keeps its historical semantics (may kick/await the open + read-only refresh) but never throws — a failed open/worker answerslifecycle.state: 'degraded'with the error as detail — andlifecycleReport()is the synchronous local read that never kicks an open or spawns the worker, so it still answers during a minutes-long first open or a worker backoff (both surface on/api/v1/debug/globalSearch/*). A corruption-triggered rebuild is its own logged outcome (search-index corruption detected; rebuilding from scratch), distinct from building/degraded lines. - Every query runs under explicit budgets (max terms, postings visits via
MiniDb.searchBoundedAsync, candidate caps, confirmation text volume, a match deadline) with over-budget pages flaggedincomplete: 'candidate_cap' | 'postings_budget' | 'deadline'. The budget VALUES are centralized and commented: per-query work budgets at the top ofsrc/search/searchService.ts(mirrored as service test knobs), worker-host budgets at the top ofsrc/search/worker/host.ts(ready/close/request/sync watchdogs, heap cap, crash backoff), minidb's process-wide worker slots and slice budgets inpackages/minidb/src/maintenance.ts/recovery.ts. Query concurrency is structural, not a semaphore: one worker thread per service serializes all index-route CPU work, each query is individually budgeted and watchdogged, and the worker heap cap turns a pathological burst into a degraded-restart rather than a main-process stall. - Pagination is keyset over
(time, key)/(score, time, key)with versioned v2 tokens pinning the index generation (a rebuild/reopen/rescan invalidates old tokens withinvalid_page_token; legacy v1 offset tokens are still accepted and upgraded), and per-session sync scans only that session's file-meta keys (\0meta\file\<sessionId>\<hash>, migrated from the pre-v2 hash-only keys by a one-time background pass). - When
container.session_idis provided and that session is live in this process (TranscriptService.forSessionLivereturns a store, wired viasetLiveTranscriptSourceinstart.ts), BOTH modes instead scan the in-memory transcript store (turn prompts + assistant text frames, history established viawhenReady/ensureAgentHistory) — no index involved; terms-mode live hits are scored Σ log(1+tf) (comparable only within a route, per theGlobalSearchSourcecontract), live-route errors never fall back to the index, and the response'ssource: 'live' | 'index'field (also mixed into the page-token fingerprint, so a mid-pagination route flip invalidates the old token) tells the caller which route served the page. - Dev/test worker runtime: the worker entry (
src/search/worker/entry.ts) loads from TS source under Node's native type stripping plus a repo-local.js→.tsresolve hook (src/search/worker/register-dev-hooks.mjs); the npm bundle emitsdist/search-worker.mjs(apps/kimi-code/tsdown.dist-worker.config.ts); the SEA binary embeds it as thekap-search-workerruntime asset (apps/kimi-code/scripts/native/manifest.mjs), installed at startup byapps/kimi-code/src/native/search-worker.ts.
