gestalt-mobile 0.35.4 → 0.36.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/README.md +60 -3
  2. package/dist/client/assets/index-9XMv2BvD.js +38 -0
  3. package/dist/client/assets/{index-CPQBJDsg.css → index-Bxv5Z2w4.css} +1 -1
  4. package/dist/client/index.html +2 -2
  5. package/dist/server/client/features/chat/activity-summary.js +71 -0
  6. package/dist/server/client/features/chat/chat-projection.js +722 -0
  7. package/dist/server/client/features/chat/message-store.js +30 -0
  8. package/dist/server/server/app.js +1 -1
  9. package/dist/server/server/cli.js +8 -0
  10. package/dist/server/server/composition.contracts.js +191 -8
  11. package/dist/server/server/composition.js +266 -56
  12. package/dist/server/server/features/autopilot/application/policy.js +2 -2
  13. package/dist/server/server/features/autopilot/register-routes.js +2 -2
  14. package/dist/server/server/features/autopilot/toggle/endpoint.js +5 -1
  15. package/dist/server/server/features/catalog/get-bootstrap/use-case.js +5 -3
  16. package/dist/server/server/features/plans/application/parse-supervised-plan.js +33 -8
  17. package/dist/server/server/features/sessions/get-session/endpoint.js +1 -1
  18. package/dist/server/server/features/sessions/list-models/endpoint.js +16 -0
  19. package/dist/server/server/features/sessions/list-recent-threads/endpoint.js +8 -3
  20. package/dist/server/server/features/sessions/list-sessions/endpoint.js +1 -1
  21. package/dist/server/server/features/sessions/model/relay-session.js +2 -0
  22. package/dist/server/server/features/sessions/promote-recent-thread/endpoint.js +6 -2
  23. package/dist/server/server/features/sessions/promote-recent-thread/use-case.js +7 -2
  24. package/dist/server/server/features/sessions/register-routes.js +8 -3
  25. package/dist/server/server/features/sessions/respond-interaction/endpoint.js +1 -1
  26. package/dist/server/server/features/sessions/select-model/endpoint.js +1 -1
  27. package/dist/server/server/features/sessions/start-session/request.js +1 -0
  28. package/dist/server/server/features/sessions/start-session/use-case.js +21 -7
  29. package/dist/server/server/features/skills/list-available/endpoint.js +4 -2
  30. package/dist/server/server/platform/catalog/provider-model-catalog.js +15 -0
  31. package/dist/server/server/platform/kimi/kimi-activity-facts.js +88 -0
  32. package/dist/server/server/platform/kimi/kimi-errors.js +30 -0
  33. package/dist/server/server/platform/kimi/kimi-model-catalog.js +63 -0
  34. package/dist/server/server/platform/kimi/kimi-normalizer.js +196 -0
  35. package/dist/server/server/platform/kimi/kimi-recent-session-lister.js +68 -0
  36. package/dist/server/server/platform/kimi/kimi-server-request.js +143 -0
  37. package/dist/server/server/platform/kimi/kimi-session-runtime.js +653 -0
  38. package/dist/server/server/platform/kimi/kimi-share-dir.js +65 -0
  39. package/dist/server/server/platform/kimi/kimi-skill-catalog.js +79 -0
  40. package/dist/server/server/platform/kimi/kimi-skills-materializer.js +41 -0
  41. package/dist/server/server/platform/kimi/kimi-web-client.js +63 -0
  42. package/dist/server/server/platform/kimi/kimi-web-server-manager.js +215 -0
  43. package/dist/server/server/platform/kimi/kimi-ws-client.js +154 -0
  44. package/dist/server/server/platform/persistence/migrate.js +2 -0
  45. package/dist/server/server/platform/persistence/sqlite-session-repository.js +3 -2
  46. package/dist/server/server/platform/skills/cached-skill-catalog.js +0 -0
  47. package/dist/server/shared/contracts/llm-provider.js +6 -0
  48. package/package.json +4 -3
  49. package/dist/client/assets/index-SZM7lpix.js +0 -38
@@ -0,0 +1,65 @@
1
+ /*
2
+ * Copyright (C) 2026 Dyne.org foundation
3
+ * Designed by Denis Roio <jaromil@dyne.org>
4
+ * SPDX-License-Identifier: AGPL-3.0-or-later
5
+ */
6
+ import { mkdirSync, symlinkSync, existsSync, statSync, copyFileSync, rmSync, lstatSync, } from 'node:fs';
7
+ import { join, resolve } from 'node:path';
8
+ import { normalizeSkillProfileName } from '../../features/skills/model/skill-profile.js';
9
+ /**
10
+ * Base directory holding gestalt-owned kimi web state:
11
+ * `<home>/.codex-gestalt/gestalt-mobile/kimi/<profileKey>/`.
12
+ */
13
+ export function kimiStateBase(homeDirectory) {
14
+ return join(resolve(homeDirectory), '.codex-gestalt', 'gestalt-mobile', 'kimi');
15
+ }
16
+ const AUTH_LINKS = ['credentials', 'oauth', 'device_id', 'region'];
17
+ /**
18
+ * Builds one isolated share dir per skill-profile key. Auth material is
19
+ * symlinked from the user's real `~/.kimi-code` home so every gestalt-owned
20
+ * kimi web instance shares the user's login; `config.toml` is copied (never
21
+ * symlinked) so gestalt can patch it without mutating user configuration.
22
+ */
23
+ export function prepareKimiShareDir(input) {
24
+ const profileKey = normalizeSkillProfileName(input.profileKey);
25
+ const profileDir = join(input.stateBase, profileKey);
26
+ const shareDir = join(profileDir, 'share');
27
+ const skillsDir = join(profileDir, 'skills');
28
+ mkdirSync(shareDir, { recursive: true, mode: 0o700 });
29
+ mkdirSync(skillsDir, { recursive: true, mode: 0o700 });
30
+ for (const name of AUTH_LINKS) {
31
+ const source = join(input.sourceShareDir, name);
32
+ if (!existsSync(source))
33
+ continue;
34
+ const link = join(shareDir, name);
35
+ removeIfPresent(link);
36
+ symlinkSync(source, link);
37
+ }
38
+ copyConfig(sourceConfig(input.sourceShareDir), join(shareDir, 'config.toml'));
39
+ return { shareDir, skillsDir, profileDir };
40
+ }
41
+ function sourceConfig(sourceShareDir) {
42
+ const candidate = join(sourceShareDir, 'config.toml');
43
+ return existsSync(candidate) ? candidate : null;
44
+ }
45
+ /** Refreshes the copied config when the user's own config is newer. */
46
+ function copyConfig(source, destination) {
47
+ if (!source) {
48
+ rmSync(destination, { force: true });
49
+ return;
50
+ }
51
+ if (existsSync(destination) && statSync(destination).mtimeMs >= statSync(source).mtimeMs) {
52
+ return;
53
+ }
54
+ copyFileSync(source, destination);
55
+ }
56
+ /** Removes a path if it exists, following lstat so broken symlinks count too. */
57
+ function removeIfPresent(path) {
58
+ try {
59
+ lstatSync(path);
60
+ }
61
+ catch {
62
+ return;
63
+ }
64
+ rmSync(path, { recursive: true, force: true });
65
+ }
@@ -0,0 +1,79 @@
1
+ /*
2
+ * Copyright (C) 2026 Dyne.org foundation
3
+ * Designed by Denis Roio <jaromil@dyne.org>
4
+ * SPDX-License-Identifier: AGPL-3.0-or-later
5
+ */
6
+ import { isAbsolute, resolve } from 'node:path';
7
+ import { availableSkillSchema, } from '../../features/skills/model/skill-profile.js';
8
+ const DISCOVERY_TIMEOUT_MS = 5_000;
9
+ /**
10
+ * Discovers kimi skills through a gestalt-owned kimi web server. The gestalt
11
+ * workspace path is matched against kimi's own workspace registry (`root`),
12
+ * then kimi's workspace-scoped skill list is mapped onto the provider-neutral
13
+ * catalog contract. kimi reports discovered skills only, so every entry is
14
+ * advertised as enabled; per-profile availability is gestalt-side state that
15
+ * materialization enforces when the profile's server starts.
16
+ */
17
+ export class KimiSkillCatalog {
18
+ servers;
19
+ available;
20
+ constructor(servers, available) {
21
+ this.servers = servers;
22
+ this.available = available;
23
+ }
24
+ async list(workspace) {
25
+ if (!this.available || !this.servers)
26
+ return { skills: [], errors: [] };
27
+ try {
28
+ const handle = await this.servers.ensure('default', []);
29
+ const canonicalWorkspace = resolve(workspace);
30
+ const workspaces = (await withTimeout(handle.client.get('/api/v1/workspaces'), DISCOVERY_TIMEOUT_MS, 'kimi workspace list timed out'));
31
+ const entries = Array.isArray(workspaces.items) ? workspaces.items : [];
32
+ const entry = entries.find((candidate) => !!candidate &&
33
+ typeof candidate === 'object' &&
34
+ candidate.root === canonicalWorkspace);
35
+ if (!entry || typeof entry.id !== 'string' || !entry.id)
36
+ return {
37
+ skills: [],
38
+ errors: [{ message: 'Kimi has no registered workspace for this path yet.' }],
39
+ };
40
+ const data = (await withTimeout(handle.client.get(`/api/v1/workspaces/${entry.id}/skills`), DISCOVERY_TIMEOUT_MS, 'kimi skill list timed out'));
41
+ const wireSkills = Array.isArray(data.skills) ? data.skills : [];
42
+ const skills = [];
43
+ const errors = [];
44
+ for (const wire of wireSkills) {
45
+ const skill = wire;
46
+ if (!skill || typeof skill !== 'object' || typeof skill.name !== 'string')
47
+ continue;
48
+ // The shared catalog contract canonicalizes skill paths as absolute
49
+ // filesystem paths (profile snapshots and overrides resolve against
50
+ // them). kimi built-ins report no path and cannot be materialized per
51
+ // profile, so they are omitted rather than advertised with a
52
+ // non-filesystem fallback that would fail validation downstream.
53
+ if (typeof skill.path !== 'string' || !isAbsolute(skill.path))
54
+ continue;
55
+ const parsed = availableSkillSchema.safeParse({
56
+ name: skill.name,
57
+ path: skill.path,
58
+ enabled: true,
59
+ ...(typeof skill.description === 'string' ? { description: skill.description } : {}),
60
+ ...(typeof skill.source === 'string' ? { scope: `kimi:${skill.source}` } : {}),
61
+ });
62
+ if (parsed.success)
63
+ skills.push(parsed.data);
64
+ else
65
+ errors.push({ message: `Invalid Kimi skill metadata for "${skill.name}".` });
66
+ }
67
+ return { skills, errors };
68
+ }
69
+ catch {
70
+ return { skills: [], errors: [{ message: 'Kimi skill discovery failed.' }] };
71
+ }
72
+ }
73
+ }
74
+ function withTimeout(promise, timeoutMs, message) {
75
+ return Promise.race([
76
+ promise,
77
+ new Promise((_resolve, reject) => setTimeout(() => reject(new Error(message)), timeoutMs).unref()),
78
+ ]);
79
+ }
@@ -0,0 +1,41 @@
1
+ /*
2
+ * Copyright (C) 2026 Dyne.org foundation
3
+ * Designed by Denis Roio <jaromil@dyne.org>
4
+ * SPDX-License-Identifier: AGPL-3.0-or-later
5
+ */
6
+ import { mkdirSync, readdirSync, rmSync, symlinkSync } from 'node:fs';
7
+ import { basename, isAbsolute, join } from 'node:path';
8
+ /**
9
+ * Materializes a kimi skills directory from a gestalt skill selection: one
10
+ * symlink per enabled entry, named by the entry's basename. kimi discovers
11
+ * both directory skills (a folder holding `SKILL.md`) and flat `.md` skills
12
+ * in an `extra_skill_dirs` entry, so linking the canonical path as-is
13
+ * preserves either shape.
14
+ *
15
+ * Note: `extra_skill_dirs` only *adds* directories on top of kimi's automatic
16
+ * user/project discovery — unlike codex's `skills.config` override, kimi web
17
+ * offers no way to hide auto-discovered skills. A profile therefore narrows
18
+ * availability by adding, never by removing.
19
+ */
20
+ export function materializeKimiSkillsDir(skillsDir, selection) {
21
+ rmSync(skillsDir, { recursive: true, force: true });
22
+ mkdirSync(skillsDir, { recursive: true, mode: 0o700 });
23
+ const linked = [];
24
+ const skipped = [];
25
+ const taken = new Set(readdirSync(skillsDir));
26
+ for (const entry of selection) {
27
+ if (!entry.enabled)
28
+ continue;
29
+ if (!isAbsolute(entry.path))
30
+ continue;
31
+ const name = basename(entry.path);
32
+ if (taken.has(name)) {
33
+ skipped.push(entry.path);
34
+ continue;
35
+ }
36
+ symlinkSync(entry.path, join(skillsDir, name));
37
+ taken.add(name);
38
+ linked.push(entry.path);
39
+ }
40
+ return { skillsDir, linked, skipped };
41
+ }
@@ -0,0 +1,63 @@
1
+ /*
2
+ * Copyright (C) 2026 Dyne.org foundation
3
+ * Designed by Denis Roio <jaromil@dyne.org>
4
+ * SPDX-License-Identifier: AGPL-3.0-or-later
5
+ */
6
+ import { KimiWebError } from './kimi-errors.js';
7
+ /**
8
+ * Minimal REST client for the local kimi web Server API (`kimi web`).
9
+ * Every response is a uniform envelope where the business outcome lives in
10
+ * `code` — HTTP status alone never reports success or failure.
11
+ */
12
+ export class KimiWebClient {
13
+ baseUrl;
14
+ token;
15
+ fetchImpl;
16
+ timeoutMs;
17
+ constructor(baseUrl, token, fetchImpl = fetch, timeoutMs = 10_000) {
18
+ this.baseUrl = baseUrl;
19
+ this.token = token;
20
+ this.fetchImpl = fetchImpl;
21
+ this.timeoutMs = timeoutMs;
22
+ }
23
+ get(path) {
24
+ return this.request('GET', path);
25
+ }
26
+ post(path, body) {
27
+ return this.request('POST', path, body);
28
+ }
29
+ delete(path) {
30
+ return this.request('DELETE', path);
31
+ }
32
+ async request(method, path, body) {
33
+ let response;
34
+ try {
35
+ response = await this.fetchImpl(this.baseUrl + path, {
36
+ method,
37
+ headers: {
38
+ authorization: `Bearer ${this.token}`,
39
+ ...(body === undefined ? {} : { 'content-type': 'application/json' }),
40
+ },
41
+ ...(body === undefined ? {} : { body: JSON.stringify(body) }),
42
+ signal: AbortSignal.timeout(this.timeoutMs),
43
+ });
44
+ }
45
+ catch (error) {
46
+ throw new KimiWebError(-1, error instanceof Error ? error.message : String(error));
47
+ }
48
+ let envelope;
49
+ try {
50
+ envelope = (await response.json());
51
+ }
52
+ catch {
53
+ throw new KimiWebError(-1, `kimi web returned an unreadable response (HTTP ${response.status})`);
54
+ }
55
+ if (typeof envelope.code !== 'number') {
56
+ throw new KimiWebError(-1, `kimi web returned a malformed envelope (HTTP ${response.status})`);
57
+ }
58
+ if (envelope.code !== 0) {
59
+ throw new KimiWebError(envelope.code, envelope.msg ?? 'kimi web request failed', envelope.request_id);
60
+ }
61
+ return envelope.data;
62
+ }
63
+ }
@@ -0,0 +1,215 @@
1
+ /*
2
+ * Copyright (C) 2026 Dyne.org foundation
3
+ * Designed by Denis Roio <jaromil@dyne.org>
4
+ * SPDX-License-Identifier: AGPL-3.0-or-later
5
+ */
6
+ import { spawn } from 'node:child_process';
7
+ import { createServer } from 'node:net';
8
+ import { KimiWebClient } from './kimi-web-client.js';
9
+ import { boundMessage } from './kimi-errors.js';
10
+ import { prepareKimiShareDir } from './kimi-share-dir.js';
11
+ import { materializeKimiSkillsDir } from './kimi-skills-materializer.js';
12
+ const READINESS_TIMEOUT_MS = 15_000;
13
+ const READINESS_POLL_MS = 100;
14
+ const LOG_TAIL_BYTES = 4 * 1024;
15
+ const BASE_PORT = 58627;
16
+ /**
17
+ * Owns one gestalt-dedicated `kimi web` process per skill profile. Each
18
+ * process gets an isolated `KIMI_SHARE_DIR` (auth material symlinked from the
19
+ * user's real kimi home) whose copied `config.toml` gestalt patches with the
20
+ * profile's materialized skills directory via `POST /api/v1/config`.
21
+ */
22
+ export class KimiWebServerManager {
23
+ input;
24
+ servers = new Map();
25
+ ensureInFlight = new Map();
26
+ constructor(input) {
27
+ this.input = input;
28
+ }
29
+ /** Returns the running (or freshly started) server for a profile key. */
30
+ async ensure(profileKey, selection) {
31
+ const fingerprint = JSON.stringify(selection);
32
+ const existing = this.servers.get(profileKey);
33
+ if (existing && !existing.exited && existing.fingerprint === fingerprint) {
34
+ return existing.handle;
35
+ }
36
+ // Serialize starts per profile key: without this, concurrent callers can
37
+ // both observe no server, spawn two children, and leave the first handle
38
+ // pointing at a process stopAll() no longer tracks.
39
+ const pending = this.ensureInFlight.get(profileKey);
40
+ if (pending) {
41
+ await pending.then(() => { }, () => { });
42
+ return this.ensure(profileKey, selection);
43
+ }
44
+ const start = (async () => {
45
+ const current = this.servers.get(profileKey);
46
+ if (current)
47
+ await this.stop(profileKey);
48
+ return this.spawn(profileKey, fingerprint, selection);
49
+ })();
50
+ this.ensureInFlight.set(profileKey, start);
51
+ try {
52
+ return await start;
53
+ }
54
+ finally {
55
+ if (this.ensureInFlight.get(profileKey) === start)
56
+ this.ensureInFlight.delete(profileKey);
57
+ }
58
+ }
59
+ get(profileKey) {
60
+ const server = this.servers.get(profileKey);
61
+ return server && !server.exited ? server.handle : null;
62
+ }
63
+ /** Returns handles for every currently running profile server, unsorted. */
64
+ list() {
65
+ return [...this.servers.values()]
66
+ .filter((server) => !server.exited)
67
+ .map((server) => server.handle);
68
+ }
69
+ onServerExit(profileKey, listener) {
70
+ const server = this.servers.get(profileKey);
71
+ if (!server)
72
+ return () => { };
73
+ server.exitListeners.add(listener);
74
+ return () => server.exitListeners.delete(listener);
75
+ }
76
+ async stop(profileKey) {
77
+ const server = this.servers.get(profileKey);
78
+ if (!server)
79
+ return;
80
+ this.servers.delete(profileKey);
81
+ if (!server.exited) {
82
+ server.child.kill('SIGTERM');
83
+ await waitForExit(server.child, 3_000);
84
+ }
85
+ for (const listener of server.exitListeners)
86
+ listener();
87
+ }
88
+ async stopAll() {
89
+ await Promise.all([...this.servers.keys()].map((key) => this.stop(key)));
90
+ }
91
+ async spawn(profileKey, fingerprint, selection) {
92
+ const { shareDir, skillsDir } = prepareKimiShareDir({
93
+ stateBase: this.input.stateBase,
94
+ profileKey,
95
+ sourceShareDir: this.input.sourceShareDir,
96
+ });
97
+ materializeKimiSkillsDir(skillsDir, selection);
98
+ const port = await pickFreePort(this.input.startPort ?? BASE_PORT);
99
+ const baseUrl = `http://127.0.0.1:${port}`;
100
+ const child = (this.input.spawnImpl ?? spawn)(this.input.command ?? 'kimi', ['web', '--no-open', '--port', String(port)], {
101
+ cwd: shareDir,
102
+ env: kimiChildEnvironment({ KIMI_SHARE_DIR: shareDir }),
103
+ shell: false,
104
+ stdio: 'pipe',
105
+ });
106
+ const server = {
107
+ profileKey,
108
+ fingerprint,
109
+ child,
110
+ handle: {
111
+ profileKey,
112
+ baseUrl,
113
+ token: '',
114
+ shareDir,
115
+ skillsDir,
116
+ client: new KimiWebClient(baseUrl, '', this.input.fetchImpl),
117
+ },
118
+ exitListeners: new Set(),
119
+ exited: false,
120
+ logTail: '',
121
+ };
122
+ this.servers.set(profileKey, server);
123
+ try {
124
+ server.handle.token = await waitForServerReady(server);
125
+ server.handle.client = new KimiWebClient(baseUrl, server.handle.token, this.input.fetchImpl);
126
+ // Publish the profile's materialized skills dir to this isolated server.
127
+ await server.handle.client.post('/api/v1/config', { extra_skill_dirs: [skillsDir] });
128
+ }
129
+ catch (error) {
130
+ await this.stop(profileKey);
131
+ throw error;
132
+ }
133
+ child.once('exit', () => {
134
+ server.exited = true;
135
+ if (this.servers.get(profileKey) === server)
136
+ this.servers.delete(profileKey);
137
+ for (const listener of server.exitListeners)
138
+ listener();
139
+ });
140
+ return server.handle;
141
+ }
142
+ }
143
+ /** Prevents relay state from leaking into kimi web children, and pins KIMI_SHARE_DIR. */
144
+ export function kimiChildEnvironment(overrides) {
145
+ const inherited = { ...process.env };
146
+ delete inherited.GESTALT_MOBILE_ORG_PLAN_STATUS_FILE;
147
+ delete inherited.GESTALT_MOBILE_ORG_PLAN_STATUS_DIRECTORY;
148
+ delete inherited.GESTALT_MOBILE_ORG_PLAN_MEASUREMENT_URL;
149
+ delete inherited.GESTALT_MOBILE_ORG_PLAN_MEASUREMENT_TOKEN;
150
+ delete inherited.KIMI_SHARE_DIR;
151
+ return { ...inherited, ...overrides };
152
+ }
153
+ async function pickFreePort(preferred) {
154
+ const probe = createServer();
155
+ try {
156
+ await new Promise((resolve) => probe.listen(0, '127.0.0.1', resolve));
157
+ const address = probe.address();
158
+ if (address && typeof address === 'object')
159
+ return address.port;
160
+ }
161
+ finally {
162
+ await new Promise((resolve) => probe.close(() => resolve()));
163
+ }
164
+ return preferred;
165
+ }
166
+ /** Watches the child until its token is printed and `/api/v1/meta` answers. */
167
+ async function waitForServerReady(server) {
168
+ const tokenPromise = new Promise((resolve, reject) => {
169
+ server.child.stdout.on('data', (chunk) => {
170
+ server.logTail = (server.logTail + chunk.toString('utf8')).slice(-LOG_TAIL_BYTES);
171
+ const match = /^Token:\s+(\S+)\s*$/m.exec(server.logTail);
172
+ if (match)
173
+ resolve(match[1]);
174
+ });
175
+ server.child.once('exit', (code) => {
176
+ reject(new Error(`kimi web exited before becoming ready (code ${code ?? 'signal'}): ${boundMessage(server.logTail).slice(-200)}`));
177
+ });
178
+ });
179
+ const timeout = setTimeout(() => {
180
+ server.child.kill('SIGTERM');
181
+ }, READINESS_TIMEOUT_MS);
182
+ try {
183
+ const token = await tokenPromise;
184
+ const client = new KimiWebClient(server.handle.baseUrl, token);
185
+ const deadline = Date.now() + READINESS_TIMEOUT_MS;
186
+ for (;;) {
187
+ try {
188
+ await client.get('/api/v1/meta');
189
+ return token;
190
+ }
191
+ catch (error) {
192
+ if (Date.now() > deadline)
193
+ throw error;
194
+ await delay(READINESS_POLL_MS);
195
+ }
196
+ }
197
+ }
198
+ finally {
199
+ clearTimeout(timeout);
200
+ }
201
+ }
202
+ function waitForExit(child, timeoutMs) {
203
+ if (child.exitCode !== null || child.signalCode !== null)
204
+ return Promise.resolve();
205
+ return new Promise((resolve) => {
206
+ const timer = setTimeout(resolve, timeoutMs);
207
+ child.once('exit', () => {
208
+ clearTimeout(timer);
209
+ resolve();
210
+ });
211
+ });
212
+ }
213
+ function delay(ms) {
214
+ return new Promise((resolve) => setTimeout(resolve, ms));
215
+ }
@@ -0,0 +1,154 @@
1
+ /*
2
+ * Copyright (C) 2026 Dyne.org foundation
3
+ * Designed by Denis Roio <jaromil@dyne.org>
4
+ * SPDX-License-Identifier: AGPL-3.0-or-later
5
+ */
6
+ import WebSocket from 'ws';
7
+ import { boundMessage } from './kimi-errors.js';
8
+ /**
9
+ * WebSocket client for the kimi web Server API. Client frames carry a
10
+ * monotonically increasing id and are acknowledged with `{type:'ack'}`;
11
+ * every other frame is an event whose top-level `type` is the event type
12
+ * (`assistant.delta`, `turn.started`, ...). Durable missed events are
13
+ * replayed by the server when `subscribe` carries per-session cursors.
14
+ */
15
+ export class KimiWsClient {
16
+ baseUrl;
17
+ token;
18
+ socket = null;
19
+ sequence = 0;
20
+ pending = new Map();
21
+ eventListeners = new Set();
22
+ closeListeners = new Set();
23
+ connectResolve = null;
24
+ connectReject = null;
25
+ constructor(baseUrl, token) {
26
+ this.baseUrl = baseUrl;
27
+ this.token = token;
28
+ }
29
+ connect() {
30
+ if (this.socket)
31
+ return Promise.resolve();
32
+ const wsBase = this.baseUrl.replace(/^http/, 'ws');
33
+ const socket = new WebSocket(`${wsBase}/api/v1/ws`, [`kimi-code.bearer.${this.token}`]);
34
+ this.socket = socket;
35
+ socket.on('message', (data) => this.onMessage(data));
36
+ socket.on('close', () => this.onSocketClosed());
37
+ socket.on('error', () => {
38
+ /* surface errors through connect() or the pending acks only */
39
+ });
40
+ return new Promise((resolve, reject) => {
41
+ this.connectResolve = resolve;
42
+ this.connectReject = reject;
43
+ socket.once('open', () => {
44
+ this.send({ type: 'client_hello', payload: { client_id: 'gestalt-mobile' } });
45
+ });
46
+ // `server_hello` resolves connect(); guard against a silent hang.
47
+ setTimeout(() => {
48
+ if (this.connectReject) {
49
+ const reject = this.connectReject;
50
+ this.connectReject = null;
51
+ reject(new Error('timed out waiting for kimi web server_hello'));
52
+ }
53
+ }, 10_000).unref();
54
+ });
55
+ }
56
+ onEvent(listener) {
57
+ this.eventListeners.add(listener);
58
+ return () => this.eventListeners.delete(listener);
59
+ }
60
+ onClose(listener) {
61
+ this.closeListeners.add(listener);
62
+ return () => this.closeListeners.delete(listener);
63
+ }
64
+ subscribe(sessionIds, cursors) {
65
+ return this.request('subscribe', { session_ids: sessionIds, ...(cursors ? { cursors } : {}) });
66
+ }
67
+ unsubscribe(sessionIds) {
68
+ return this.request('unsubscribe', { session_ids: sessionIds });
69
+ }
70
+ abort(sessionId, promptId) {
71
+ return this.request('abort', { session_id: sessionId, prompt_id: promptId });
72
+ }
73
+ close() {
74
+ const socket = this.socket;
75
+ this.socket = null;
76
+ if (socket && socket.readyState === WebSocket.OPEN)
77
+ socket.close();
78
+ this.failPending(new Error('kimi web socket closed'));
79
+ }
80
+ request(type, payload) {
81
+ const socket = this.socket;
82
+ if (!socket || socket.readyState !== WebSocket.OPEN) {
83
+ return Promise.reject(new Error(`kimi web socket is not open (frame ${type})`));
84
+ }
85
+ const id = `g${++this.sequence}`;
86
+ return new Promise((resolve, reject) => {
87
+ this.pending.set(id, { resolve, reject });
88
+ this.send({ type, id, payload });
89
+ });
90
+ }
91
+ send(frame) {
92
+ this.socket?.send(JSON.stringify(frame));
93
+ }
94
+ onMessage(data) {
95
+ let frame;
96
+ try {
97
+ frame = JSON.parse(String(data));
98
+ }
99
+ catch {
100
+ return;
101
+ }
102
+ if (frame.type === 'server_hello') {
103
+ const resolve = this.connectResolve;
104
+ this.connectResolve = null;
105
+ this.connectReject = null;
106
+ resolve?.();
107
+ return;
108
+ }
109
+ if (frame.type === 'ack') {
110
+ const id = typeof frame.id === 'string' ? frame.id : undefined;
111
+ const pending = id ? this.pending.get(id) : undefined;
112
+ if (pending) {
113
+ this.pending.delete(id);
114
+ const ack = {
115
+ type: 'ack',
116
+ id,
117
+ code: typeof frame.code === 'number' ? frame.code : -1,
118
+ ...(typeof frame.msg === 'string' ? { msg: boundMessage(frame.msg) } : {}),
119
+ };
120
+ pending.resolve(ack);
121
+ }
122
+ return;
123
+ }
124
+ // Event frames carry the event type at the top level (for example
125
+ // `{type:'assistant.delta', payload:{delta}}`). The gestalt event
126
+ // contract keeps the type inside the payload, so it is folded in here;
127
+ // any non-hello, non-ack frame is an event.
128
+ if (typeof frame.type === 'string') {
129
+ const payload = frame.payload && typeof frame.payload === 'object' ? frame.payload : {};
130
+ const event = {
131
+ ...frame,
132
+ payload: { ...payload, type: frame.type },
133
+ };
134
+ for (const listener of this.eventListeners)
135
+ listener(event);
136
+ }
137
+ }
138
+ onSocketClosed() {
139
+ this.socket = null;
140
+ const reject = this.connectReject;
141
+ this.connectReject = null;
142
+ this.connectResolve = null;
143
+ reject?.(new Error('kimi web socket closed before server_hello'));
144
+ this.failPending(new Error('kimi web socket closed'));
145
+ for (const listener of this.closeListeners)
146
+ listener();
147
+ }
148
+ failPending(reason) {
149
+ const pending = [...this.pending.values()];
150
+ this.pending.clear();
151
+ for (const entry of pending)
152
+ entry.reject(reason);
153
+ }
154
+ }
@@ -22,6 +22,8 @@ export function migrate(database) {
22
22
  database.exec('ALTER TABLE relay_sessions ADD COLUMN sandbox TEXT');
23
23
  if (!columns.some((column) => column.name === 'approval_policy'))
24
24
  database.exec('ALTER TABLE relay_sessions ADD COLUMN approval_policy TEXT');
25
+ if (!columns.some((column) => column.name === 'provider'))
26
+ database.exec("ALTER TABLE relay_sessions ADD COLUMN provider TEXT NOT NULL DEFAULT 'codex'");
25
27
  const autopilotColumns = database
26
28
  .prepare('PRAGMA table_info(autopilot_sessions)')
27
29
  .all();
@@ -11,8 +11,8 @@ export class SqliteSessionRepository {
11
11
  }
12
12
  save(session) {
13
13
  this.db
14
- .prepare('INSERT INTO relay_sessions (id,workspace_id,workspace_path,profile,model,branch,sandbox,approval_policy,thread_id,state,desired_state,active_turn_id,protocol_version,attention_tool_capability,failure_count,effective_skill_selection_json,last_org_plan_json,created_at,updated_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) ON CONFLICT(id) DO UPDATE SET workspace_id=excluded.workspace_id,workspace_path=excluded.workspace_path,profile=excluded.profile,model=excluded.model,branch=excluded.branch,sandbox=excluded.sandbox,approval_policy=excluded.approval_policy,thread_id=excluded.thread_id,state=excluded.state,desired_state=excluded.desired_state,active_turn_id=excluded.active_turn_id,protocol_version=excluded.protocol_version,attention_tool_capability=excluded.attention_tool_capability,failure_count=excluded.failure_count,effective_skill_selection_json=excluded.effective_skill_selection_json,last_org_plan_json=excluded.last_org_plan_json,updated_at=excluded.updated_at')
15
- .run(session.id, session.workspaceId, session.workspacePath, session.profile, session.model ?? null, session.branch ?? null, session.executionPolicy?.sandbox ?? null, session.executionPolicy?.approvalPolicy ?? null, session.threadId, session.state, session.desiredState, session.activeTurnId, session.protocolVersion, session.attentionToolCapability ?? null, session.failureCount, session.effectiveSkillSelection === undefined
14
+ .prepare('INSERT INTO relay_sessions (id,workspace_id,workspace_path,provider,profile,model,branch,sandbox,approval_policy,thread_id,state,desired_state,active_turn_id,protocol_version,attention_tool_capability,failure_count,effective_skill_selection_json,last_org_plan_json,created_at,updated_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) ON CONFLICT(id) DO UPDATE SET workspace_id=excluded.workspace_id,workspace_path=excluded.workspace_path,provider=excluded.provider,profile=excluded.profile,model=excluded.model,branch=excluded.branch,sandbox=excluded.sandbox,approval_policy=excluded.approval_policy,thread_id=excluded.thread_id,state=excluded.state,desired_state=excluded.desired_state,active_turn_id=excluded.active_turn_id,protocol_version=excluded.protocol_version,attention_tool_capability=excluded.attention_tool_capability,failure_count=excluded.failure_count,effective_skill_selection_json=excluded.effective_skill_selection_json,last_org_plan_json=excluded.last_org_plan_json,updated_at=excluded.updated_at')
15
+ .run(session.id, session.workspaceId, session.workspacePath, session.provider, session.profile, session.model ?? null, session.branch ?? null, session.executionPolicy?.sandbox ?? null, session.executionPolicy?.approvalPolicy ?? null, session.threadId, session.state, session.desiredState, session.activeTurnId, session.protocolVersion, session.attentionToolCapability ?? null, session.failureCount, session.effectiveSkillSelection === undefined
16
16
  ? null
17
17
  : JSON.stringify(session.effectiveSkillSelection), session.lastOrgPlan === undefined ? null : JSON.stringify(session.lastOrgPlan), session.createdAt, session.updatedAt);
18
18
  }
@@ -45,6 +45,7 @@ function map(row) {
45
45
  id: row.id,
46
46
  workspaceId: row.workspace_id,
47
47
  workspacePath: row.workspace_path,
48
+ provider: row.provider === 'kimi' ? 'kimi' : 'codex',
48
49
  profile: row.profile,
49
50
  ...(row.model === null ? {} : { model: row.model }),
50
51
  ...(row.branch === null ? {} : { branch: row.branch }),
@@ -0,0 +1,6 @@
1
+ /*
2
+ * Copyright (C) 2026 Dyne.org foundation
3
+ * Designed by Denis Roio <jaromil@dyne.org>
4
+ * SPDX-License-Identifier: AGPL-3.0-or-later
5
+ */
6
+ export const llmProviders = ['codex', 'kimi'];