kimi-code/packages/klient
liruifengv 43c68f58f5
feat(agent-core-v2): keep session updatedAt stable across meta management writes (#2815)
* feat(agent-core-v2): keep session updatedAt stable across meta management writes

Rename, archive/restore, and fork no longer bump a session's updatedAt,
so recency-sorted session lists stop reshuffling on management actions:

- setTitle/setArchived pass touchUpdatedAt: false; an explicit
  patch.updatedAt always wins (fork inherits the source's recency, so a
  fork lands next to the source instead of floating to the top)
- new SessionMeta.archivedAt records the archive moment (cleared on
  restore) and is surfaced through the session index, the v1/v2 session
  routes (archived_at), and the klient contract, so the archived list
  keeps an accurate archive time without relying on the updatedAt bump

* fix(agent-core-v2): normalize a legacy ISO-string updatedAt when forking a cold session

A cold legacy/v1 state.json read from disk can still carry an ISO-string
updatedAt; passing it through as the fork's explicit patch.updatedAt
would persist a string into the v2 metadata. Normalize with toEpochMs
(falling back to now when absent/unparseable).

* fix(agent-core-v2): write fork metadata after agent recreation

Registering each copied agent during fork is an ordinary metadata write
that bumps updatedAt, which overwrote the inherited source recency and
still floated normal forks (sessions with agents) to the top. Move the
fork's metadata update after the agent recreation loop so the inherited
updatedAt is the final write.

* fix(agent-core-v2): preserve persisted recency when restoring a cold session

Resume creates the main agent for a cold session that has no persisted
agents.main entry (e.g. an empty session), and that registration bumps
updatedAt — so unarchiving an empty session still floated it to the
top. Capture the index summary's updatedAt before resume and re-apply
it in the restore write (archived:false, archivedAt cleared, explicit
updatedAt wins over the bump).

* fix(agent-core-v2): make agent registration non-touching for recency

Registering an agent is a structural write, not content activity — but
it went through an ordinary metadata update that bumped updatedAt. That
reordered recency-sorted listings whenever materialization created an
agent: resume of a cold session without a persisted agents.main (so
archive-via-resume and restore of empty sessions still floated), and
runtime subagent registration mid-turn. registerAgent now passes
touchUpdatedAt: false; restore goes back to the plain unarchive write
and no longer needs the capture/reapply workaround.

* fix(agent-core-v2): duplicate cron tasks only after the fork metadata is durable

With the metadata write moved after agent recreation, cron duplication
ran before it — a rejected metadata update left cloned cron records
pointing at a fork whose directory the catch block just removed. Keep
cron duplication after the durable metadata write.

* style(agent-core-v2): fold new invariants into module headers

The package convention keeps comments in the top-of-file block only —
move the touchUpdatedAt precedence, non-touching registration, and fork
ordering notes out of statement-level positions into the respective
module headers.

* chore: scope the changeset to agent-core-v2
2026-08-12 13:40:26 +08:00
..
examples feat: unify the host identity across OAuth, telemetry, and kap-server (#2382) 2026-07-30 13:45:41 +08:00
scripts feat(klient): contract-driven facade with http/ipc/memory transports (#1768) 2026-07-16 16:43:09 +08:00
src feat(agent-core-v2): keep session updatedAt stable across meta management writes (#2815) 2026-08-12 13:40:26 +08:00
test feat(agent-core-v2): add the L3 unit layer and the Feature seam (#2678) 2026-08-06 18:22:36 +08:00
AGENTS.md refactor(agent-core-v2): rebuild the model wire layer on the kosong architecture (#1970) 2026-07-21 13:03:54 +08:00
CHANGELOG.md ci: release packages (#2342) 2026-07-30 14:56: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 ci: release packages (#2342) 2026-07-30 14:56:30 +08:00
README.md feat: agent-core-v2 permission/workspace refactors and transcript durability (#2021) 2026-07-22 19:21:56 +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(transcript): add unified transcript layer, drop the /api/v2 RPC surface (#1888) 2026-07-20 15:33:05 +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, two transports — you pick the transport once at creation; everything after that is byte-identical:

import { bootstrap, logSeed, resolveLoggingConfig } from '@moonshot-ai/agent-core-v2';
import { createKlient } from '@moonshot-ai/klient/memory';   // or '/ipc'

const { app } = bootstrap({ homeDir }, [
  ...logSeed(resolveLoggingConfig({ homeDir, env: process.env })),
]);
const klient = createKlient({ scope: app });

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
   ↓
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, kosong.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/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 cross the same JSON boundary a socket transport would impose. The IPC host ships with the transport: serveKlientIpc({ scope, socketPath }).

The same conformance suite runs against both transports in this package's tests (test/helpers/conformance.ts — one test file per transport).

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

  • 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 live-server-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).

Smoke check

pnpm -C packages/klient smoke

examples/smoke.ts boots an in-process engine (memory transport) and asserts the global facade end-to-end — no server needed. examples/basic.ts is a shorter narrated tour; examples/context-usage.ts traces context-size readings through a real prompt (requires KIMI_EXAMPLE_MODEL + KIMI_EXAMPLE_API_KEY).