kimi-code/packages/klient
Haozhe 56a321d4d1
fix(workspace): dedupe workspaces across Windows path spelling variants (#1809)
* fix(workspace): dedupe workspaces across Windows path spelling variants

The same directory reached the workspace registry as distinct strings on
Windows (drive-letter casing, typed vs on-disk casing, slash style), and
every identity check compared exact strings, so one folder could appear
as multiple workspaces with sessions split across hash-keyed buckets.

- add workspaceRootKey (slash-normalize + case-fold Windows-shaped
  paths) in agent-core, agent-core-v2, and the web app, and compare
  roots by identity key everywhere instead of exact strings
- registry createOrTouch folds alias spellings onto the existing entry
  instead of minting a new workspace id; session buckets reuse the
  registered id via a resolver in the v1 session store
- list endpoints expand alias buckets (resolveAliasIds /
  resolveAliasWorkDirs, including session-index-only spellings) so
  previously split workspaces list all sessions and counts under one
  merged group; session_index entries use the registry-resolved id

* fix(workspace): fold the runtime touch path and drive-root identity keys

Two gaps in the Windows path-spelling folding, both reachable in the
v1 session-create flow:

- touchWorkspaceRegistry minted the alias spelling's id outright; the
  freshly persisted alias entry then became the resolver's preferred id
  on the next create, splitting sessions into a duplicate bucket again.
  It now folds onto the identity-matching existing entry, mirroring the
  registry service.
- workspaceRootKey stripped trailing separators before testing the
  Windows shape, so a drive root (C:\) collapsed to C: and escaped the
  case-fold. The shape test now runs before the strip in all three
  copies (agent-core, agent-core-v2, web).

* fix(workspace): unfold symmetric operations that escaped the identity key

Two asymmetric spots left the folded comparison one-sided:

- the web app matched hidden roots by folded key but cleared them on
  re-add by exact string, so hiding C:\Foo and re-adding c:\foo kept
  the workspace hidden forever; clearing now folds too
- registry delete (both engines) removed and tombstoned only the exact
  id, so a legacy split sibling resurfaced as the directory's
  representative on the next list; delete now removes every registered
  spelling sharing the root's identity key and tombstones the full
  alias set (registered ids plus session-index spelling mints), so the
  session-index merge cannot resurrect the directory either
2026-07-17 14:56:06 +08:00
..
examples fix(agent-core-v2): keep context size readings on the measured path (#1782) 2026-07-16 18:37:28 +08:00
scripts feat(klient): contract-driven facade with http/ipc/memory transports (#1768) 2026-07-16 16:43:09 +08:00
src fix(workspace): dedupe workspaces across Windows path spelling variants (#1809) 2026-07-17 14:56:06 +08:00
test fix(workspace): dedupe workspaces across Windows path spelling variants (#1809) 2026-07-17 14:56:06 +08:00
AGENTS.md feat(klient): contract-driven facade with http/ipc/memory transports (#1768) 2026-07-16 16:43:09 +08:00
CHANGELOG.md ci: release packages (#1583) 2026-07-14 15:01:30 +08:00
Dockerfile feat(klient): contract-driven facade with http/ipc/memory transports (#1768) 2026-07-16 16:43:09 +08:00
package.json feat(klient): contract-driven facade with http/ipc/memory transports (#1768) 2026-07-16 16:43:09 +08:00
README.md feat(klient): contract-driven facade with http/ipc/memory transports (#1768) 2026-07-16 16:43:09 +08:00
tsconfig.examples.json test(klient): add real-server smoke coverage (#1713) 2026-07-15 15:24:36 +08:00
tsconfig.json feat(klient): contract-driven facade with http/ipc/memory transports (#1768) 2026-07-16 16:43:09 +08:00
tsdown.config.ts feat(klient): contract-driven facade with http/ipc/memory transports (#1768) 2026-07-16 16:43:09 +08:00
vitest.config.ts feat(klient): contract-driven facade with http/ipc/memory transports (#1768) 2026-07-16 16:43:09 +08:00

@moonshot-ai/klient

Contract-driven client SDK for the agent-core-v2 engine. One facade, three transports — you pick the transport once at creation; everything after that is byte-identical:

import { createKlient } from '@moonshot-ai/klient/http';   // or '/ipc', '/memory'

const klient = createKlient({ url: 'http://127.0.0.1:58627', token });

const env = await klient.global.env();
const sessions = await klient.global.sessions.list({ limit: 20 });

const session = await klient.global.sessions.create({ workDir: process.cwd() });
const agent = klient.session(session.id).agent('main');
agent.events.on('assistant.delta', (e) => process.stdout.write(e.delta));
agent.events.on('prompt.completed', () => console.log('\ndone'));
await agent.prompt({ input: [{ type: 'text', text: 'Say OK.' }] });

await klient.close();

Architecture

facade (klient.global.*, klient.session(id).*, session.agent(id).*, *.events.*)
   ↓ single-object params, zod-validated
contract (procedure schemas, shared by all transports)
   ↓
KlientChannel { call, listen }   ← the only transport SPI
   ↓
http │ ipc │ memory
  • Facade — aggregated methods, no engine service tokens, no onDid*/onWill* event names. There is no escape hatch to raw services: the facade is the public contract.
    • klient.global.*sessions.* (incl. create), workspaces.*, config.*, providers.*, models.*, catalog.*, auth.*, flags.*, plugins.*, hostFs.*, env().
    • klient.session(id).*get/setTitle/update/status/close/archive/ restore/fork/createChild, approvals.*, questions.*, interactions.*, agents().
    • session.agent(id).*prompt/steer/cancel/runShellCommand/ cancelShellCommand/getModel/setModel/setPermission/getUsage/getContext/ getPlan*/getTasks*/stopTask/getTaskOutput.
  • Contract — every method has a zod input tuple + output schema, validated on the client before send / after receive (default on; validate: false to disable). Validation is sub-µs for typical payloads — cheaper than the JSON serialization the wire already pays.
  • Eventsklient.events.on(...) for the global bus (config.changed, models.changed, session.archived, …), session(id).events.on('metadata.changed' | 'interactions.changed' | 'interactions.resolved'), and agent(id).events.on('turn.started' | 'assistant.delta' | 'tool.call.started' | 'prompt.completed' | …). Underlying subscriptions are shared and ref-counted; payloads are validated; bad payloads drop to events.onError.

Transports

entry options events
@moonshot-ai/klient/http { url, token?, fetch?, WebSocketImpl? } lazily opened WS, transparent
@moonshot-ai/klient/ipc { socketPath, token? } same socket
@moonshot-ai/klient/memory { scope } (a bootstrapped engine app scope) direct emitter/bus subscription

ipc and memory share one in-process dispatcher, so they behave identically by construction; memory additionally JSON round-trips every value so results match the networked transports byte-for-byte. The IPC host ships with the transport: serveKlientIpc({ scope, socketPath }).

The same conformance suite runs against all three transports in this package's tests (test/helpers/conformance.ts — one test file per transport; the http leg boots an in-process kap-server).

This package also hosts the e2e suites (the retired server-e2e package was folded in here):

  • test/e2e/dual/ — session/agent suites that run the exact same body against an in-memory engine and an in-process kap-server (test/helpers/dual.ts). Model-requiring suites skip unless KIMI_E2E_MODEL + KIMI_E2E_API_KEY (optional KIMI_E2E_BASE_URL, KIMI_E2E_PROTOCOL) are set; the model is seeded into each backend's temp home through the facade itself.
  • test/e2e/v2//api/v2 wire tests booting kap-server in-process.
  • test/e2e/legacy/ + test/e2e/harness/ — the legacy /api/v1 live suites and their client harness (skip unless KIMI_SERVER_URL is set; the v1 surface has no in-memory equivalent, so these stay http-only).

The docker e2e runner (pnpm docker:e2e) runs this whole vitest suite inside a container against a container-local server. See AGENTS.md for the testing rules.

Scope

The facade covers the global (app), session, and agent surfaces shown above. What it deliberately leaves out (for now): onWill/hook-style interception (engine hooks are in-process OrderedHookSlots and not wire-exposable), file upload (v1 multipart REST only), and the terminal surface (v1 REST + WS only).

Real-server smoke check

KIMI_SERVER_URL=http://127.0.0.1:58627 \
KIMI_SERVER_TOKEN=YOUR_SERVER_TOKEN \
pnpm -C packages/klient smoke

Omit KIMI_SERVER_TOKEN only for a server started with authentication bypassed. examples/basic.ts is a shorter narrated tour.