gestalt-mobile 0.35.5 → 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 (47) 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 +158 -6
  11. package/dist/server/server/composition.js +266 -56
  12. package/dist/server/server/features/autopilot/register-routes.js +2 -2
  13. package/dist/server/server/features/autopilot/toggle/endpoint.js +5 -1
  14. package/dist/server/server/features/catalog/get-bootstrap/use-case.js +5 -3
  15. package/dist/server/server/features/sessions/get-session/endpoint.js +1 -1
  16. package/dist/server/server/features/sessions/list-models/endpoint.js +16 -0
  17. package/dist/server/server/features/sessions/list-recent-threads/endpoint.js +8 -3
  18. package/dist/server/server/features/sessions/list-sessions/endpoint.js +1 -1
  19. package/dist/server/server/features/sessions/model/relay-session.js +2 -0
  20. package/dist/server/server/features/sessions/promote-recent-thread/endpoint.js +6 -2
  21. package/dist/server/server/features/sessions/promote-recent-thread/use-case.js +7 -2
  22. package/dist/server/server/features/sessions/register-routes.js +8 -3
  23. package/dist/server/server/features/sessions/respond-interaction/endpoint.js +1 -1
  24. package/dist/server/server/features/sessions/select-model/endpoint.js +1 -1
  25. package/dist/server/server/features/sessions/start-session/request.js +1 -0
  26. package/dist/server/server/features/sessions/start-session/use-case.js +21 -7
  27. package/dist/server/server/features/skills/list-available/endpoint.js +4 -2
  28. package/dist/server/server/platform/catalog/provider-model-catalog.js +15 -0
  29. package/dist/server/server/platform/kimi/kimi-activity-facts.js +88 -0
  30. package/dist/server/server/platform/kimi/kimi-errors.js +30 -0
  31. package/dist/server/server/platform/kimi/kimi-model-catalog.js +63 -0
  32. package/dist/server/server/platform/kimi/kimi-normalizer.js +196 -0
  33. package/dist/server/server/platform/kimi/kimi-recent-session-lister.js +68 -0
  34. package/dist/server/server/platform/kimi/kimi-server-request.js +143 -0
  35. package/dist/server/server/platform/kimi/kimi-session-runtime.js +653 -0
  36. package/dist/server/server/platform/kimi/kimi-share-dir.js +65 -0
  37. package/dist/server/server/platform/kimi/kimi-skill-catalog.js +79 -0
  38. package/dist/server/server/platform/kimi/kimi-skills-materializer.js +41 -0
  39. package/dist/server/server/platform/kimi/kimi-web-client.js +63 -0
  40. package/dist/server/server/platform/kimi/kimi-web-server-manager.js +215 -0
  41. package/dist/server/server/platform/kimi/kimi-ws-client.js +154 -0
  42. package/dist/server/server/platform/persistence/migrate.js +2 -0
  43. package/dist/server/server/platform/persistence/sqlite-session-repository.js +3 -2
  44. package/dist/server/server/platform/skills/cached-skill-catalog.js +0 -0
  45. package/dist/server/shared/contracts/llm-provider.js +6 -0
  46. package/package.json +4 -3
  47. package/dist/client/assets/index-SZM7lpix.js +0 -38
@@ -8,13 +8,17 @@ import { RecentThreadHistoryUnavailable } from './use-case.js';
8
8
  const requestSchema = z.object({
9
9
  threadId: z.string().min(1),
10
10
  cwd: z.string().startsWith('/'),
11
+ // Requests written before provider support remain Codex-compatible.
12
+ provider: z.enum(['codex', 'kimi']).optional().default('codex'),
11
13
  });
12
14
  export function registerPromoteRecentThread(app, deps) {
13
15
  app.post('/api/sessions/recent-threads/open', { bodyLimit: 8 * 1024 }, async (request, reply) => {
14
16
  const parsed = requestSchema.safeParse(request.body);
15
17
  if (!parsed.success)
16
18
  return reply.code(400).send({ code: 'INVALID_RECENT_THREAD' });
17
- const thread = (await deps.list()).find((candidate) => candidate.id === parsed.data.threadId && candidate.cwd === parsed.data.cwd);
19
+ const thread = (await deps.list()).find((candidate) => candidate.id === parsed.data.threadId &&
20
+ candidate.cwd === parsed.data.cwd &&
21
+ (candidate.provider ?? 'codex') === parsed.data.provider);
18
22
  if (!thread)
19
23
  return reply.code(404).send({ code: 'RECENT_THREAD_NOT_FOUND' });
20
24
  try {
@@ -27,7 +31,7 @@ export function registerPromoteRecentThread(app, deps) {
27
31
  ? 'RECENT_THREAD_HISTORY_UNAVAILABLE'
28
32
  : 'RECENT_THREAD_OPEN_FAILED',
29
33
  detail: historyUnavailable
30
- ? 'The selected thread history is currently unavailable. Retry after Codex is available.'
34
+ ? 'The selected thread history is currently unavailable. Retry after its provider is available.'
31
35
  : 'The selected thread could not be opened. Retry shortly.',
32
36
  });
33
37
  }
@@ -11,7 +11,8 @@ export class RecentThreadHistoryUnavailable extends Error {
11
11
  }
12
12
  const inFlightPromotions = new Map();
13
13
  export async function promoteRecentThread(thread, deps) {
14
- const key = `${thread.profile}:${thread.cwd}:${thread.id}`;
14
+ const provider = thread.provider ?? 'codex';
15
+ const key = `${provider}:${thread.profile}:${thread.cwd}:${thread.id}`;
15
16
  const ongoing = inFlightPromotions.get(key);
16
17
  if (ongoing)
17
18
  return ongoing;
@@ -26,7 +27,10 @@ export async function promoteRecentThread(thread, deps) {
26
27
  }
27
28
  }
28
29
  async function promote(thread, deps) {
29
- const existing = deps.list().find((session) => session.threadId === thread.id);
30
+ const provider = thread.provider ?? 'codex';
31
+ const existing = deps
32
+ .list()
33
+ .find((session) => session.threadId === thread.id && (session.provider ?? 'codex') === provider);
30
34
  if (existing) {
31
35
  const executionPolicy = existing.executionPolicy
32
36
  ? undefined
@@ -51,6 +55,7 @@ async function promote(thread, deps) {
51
55
  id: deps.createId(),
52
56
  workspaceId: thread.cwd,
53
57
  workspacePath: thread.cwd,
58
+ provider,
54
59
  profile: thread.profile,
55
60
  threadId: thread.id,
56
61
  ...(executionPolicy === undefined ? {} : { executionPolicy }),
@@ -17,6 +17,7 @@ import { registerReleaseSession } from './release-session/endpoint.js';
17
17
  import { registerRestoreSession } from './restore-session/endpoint.js';
18
18
  import { registerRespondInteraction } from './respond-interaction/endpoint.js';
19
19
  import { registerSelectModel } from './select-model/endpoint.js';
20
+ import { registerListSessionModels } from './list-models/endpoint.js';
20
21
  import { registerSessionEvents } from './session-events/endpoint.js';
21
22
  import { registerStartSession } from './start-session/endpoint.js';
22
23
  import { registerStartTurn } from './start-turn/endpoint.js';
@@ -27,8 +28,10 @@ export function registerSessionRoutes(app, deps) {
27
28
  if (deps.recentThreads) {
28
29
  registerListRecentThreads(app, {
29
30
  ...deps.recentThreads,
30
- metadata: (threadId) => {
31
- const session = sessions?.list?.().find((candidate) => candidate.threadId === threadId);
31
+ metadata: (threadId, provider) => {
32
+ const session = sessions
33
+ ?.list?.()
34
+ .find((candidate) => candidate.threadId === threadId && (candidate.provider ?? 'codex') === provider);
32
35
  if (!session)
33
36
  return null;
34
37
  return {
@@ -44,6 +47,8 @@ export function registerSessionRoutes(app, deps) {
44
47
  });
45
48
  }
46
49
  if (sessions) {
50
+ if (sessions.sessionModels)
51
+ registerListSessionModels(app, sessions.sessionModels);
47
52
  registerStartSession(app, {
48
53
  ...sessions,
49
54
  reportFailure: (operation, error) => deps.logger.error(`${operation} failed: ${safeErrorLabel(error)}`),
@@ -84,7 +89,7 @@ export function registerSessionRoutes(app, deps) {
84
89
  if (sessions.models)
85
90
  registerSelectModel(app, {
86
91
  find: sessions.find,
87
- models: () => sessions.models.list(),
92
+ models: (provider) => sessions.models.list(provider),
88
93
  now: sessions.now,
89
94
  save: sessions.save,
90
95
  });
@@ -18,7 +18,7 @@ export function registerRespondInteraction(app, deps) {
18
18
  return reply.code(409).send({ code: 'INTERACTION_NOT_PENDING' });
19
19
  if (deps.validate && !deps.validate(id, requestId, value))
20
20
  return reply.code(400).send({ code: 'INTERACTION_RESPONSE_INVALID' });
21
- const delivery = deps.reply(id, requestId, value);
21
+ const delivery = await deps.reply(id, requestId, value);
22
22
  if (delivery === false || delivery === 'unavailable')
23
23
  return reply.code(409).send({ code: 'INTERACTION_DELIVERY_UNAVAILABLE' });
24
24
  if (delivery === 'cleared') {
@@ -12,7 +12,7 @@ export function registerSelectModel(app, deps) {
12
12
  const model = request.body.model;
13
13
  if (typeof model !== 'string')
14
14
  return reply.code(400).send({ code: 'MODEL_REQUIRED' });
15
- if (!(await deps.models()).includes(model))
15
+ if (!(await deps.models(session.provider)).includes(model))
16
16
  return reply.code(400).send({ code: 'MODEL_UNAVAILABLE' });
17
17
  if (session.state !== 'ready')
18
18
  return reply.code(409).send({ code: 'SESSION_NOT_READY' });
@@ -6,6 +6,7 @@
6
6
  import { z } from 'zod';
7
7
  const schema = z.object({
8
8
  workspaceId: z.string().min(1),
9
+ provider: z.enum(['codex', 'kimi']),
9
10
  profile: z.string().min(1),
10
11
  skillProfile: z.string().trim().min(1).optional(),
11
12
  model: z
@@ -8,15 +8,28 @@ import { DEFAULT_SESSION_MODEL } from '../application/start-settings.js';
8
8
  import { reconcileSkillSelectionSnapshot, } from '../../skills/model/skill-profile.js';
9
9
  import { SkillProfileError } from '../../skills/model/errors.js';
10
10
  export async function startSession(input, deps) {
11
- const model = input.model ?? DEFAULT_SESSION_MODEL;
11
+ // Codex launcher profiles select the app-server skill directory. Kimi owns
12
+ // its isolated profile lifecycle, so it must not require a Codex launcher
13
+ // profile before its own catalog/runtime can be selected.
12
14
  const [workspace] = await Promise.all([
13
15
  deps.workspaces.resolve(input.workspaceId),
14
- deps.profiles.require(input.profile),
16
+ ...(input.provider === 'codex' ? [deps.profiles.require(input.profile)] : []),
15
17
  ]);
16
- if (deps.models) {
17
- const models = await deps.models.list();
18
+ let model = input.model;
19
+ const modelCatalog = deps.sessionModels ?? deps.models;
20
+ if (modelCatalog) {
21
+ const models = await modelCatalog.list(input.provider);
22
+ // An omitted model resolves to the provider's own default: the relay-wide
23
+ // default when that provider serves it, otherwise the provider's first
24
+ // available model, so a kimi request never inherits the codex default.
25
+ model ??= models.includes(DEFAULT_SESSION_MODEL)
26
+ ? DEFAULT_SESSION_MODEL
27
+ : (models[0] ?? DEFAULT_SESSION_MODEL);
18
28
  if (!models.includes(model))
19
- throw new Error('CODEX_MODEL_UNAVAILABLE');
29
+ throw new Error(input.provider === 'codex' ? 'CODEX_MODEL_UNAVAILABLE' : 'KIMI_MODEL_UNAVAILABLE');
30
+ }
31
+ else {
32
+ model ??= DEFAULT_SESSION_MODEL;
20
33
  }
21
34
  const selectedProfile = input.skillProfile
22
35
  ? await deps.skillProfiles.readGlobalProfile(input.skillProfile)
@@ -25,7 +38,7 @@ export async function startSession(input, deps) {
25
38
  throw new SkillProfileError('UNKNOWN_SKILL_PROFILE', 'The selected skill profile does not exist.');
26
39
  const [projectProfile, catalog] = await Promise.all([
27
40
  deps.skillProfiles.readWorkspaceDefault(workspace.realPath),
28
- deps.skillCatalog(input.profile).list(workspace.realPath),
41
+ deps.skillCatalog(input.provider, input.profile).list(workspace.realPath),
29
42
  ]);
30
43
  const sourceProfile = selectedProfile ?? projectProfile;
31
44
  const reconciledSkills = reconcileSkillSelectionSnapshot(catalog.skills, sourceProfile?.skills);
@@ -46,6 +59,7 @@ export async function startSession(input, deps) {
46
59
  id: deps.createId(),
47
60
  workspaceId: workspace.id,
48
61
  workspacePath: workspace.realPath,
62
+ provider: input.provider,
49
63
  profile: input.profile,
50
64
  model,
51
65
  ...(branch ? { branch } : {}),
@@ -57,7 +71,7 @@ export async function startSession(input, deps) {
57
71
  deps.save(session);
58
72
  if (!deps.activate)
59
73
  return session;
60
- const active = await deps.activate(session, {});
74
+ const active = await deps.activate(session, { provider: session.provider });
61
75
  deps.save(active);
62
76
  return active;
63
77
  }
@@ -11,6 +11,7 @@ const querySchema = z
11
11
  .object({
12
12
  workspaceId: z.string().min(1),
13
13
  profile: z.string().min(1),
14
+ provider: z.enum(['codex', 'kimi']).optional(),
14
15
  refresh: z.enum(['true']).optional(),
15
16
  })
16
17
  .strict();
@@ -28,10 +29,11 @@ export function registerListAvailableSkills(app, deps) {
28
29
  deps.workspaces.resolve(parsed.data.workspaceId),
29
30
  deps.profiles.require(parsed.data.profile),
30
31
  ]);
32
+ const provider = parsed.data.provider ?? 'codex';
31
33
  const [discovered, project] = await Promise.all([
32
34
  parsed.data.refresh
33
- ? deps.catalog.refresh(profile.name, workspace.realPath)
34
- : deps.catalog.list(profile.name, workspace.realPath),
35
+ ? deps.catalog.refresh(provider, profile.name, workspace.realPath)
36
+ : deps.catalog.list(provider, profile.name, workspace.realPath),
35
37
  deps.selections.readWorkspaceDefault(workspace.realPath),
36
38
  ]);
37
39
  const skills = applySkillSelectionSnapshot(discovered.skills, project?.skills);
@@ -0,0 +1,15 @@
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
+ /** Routes each provider's model query to that provider's own catalog adapter. */
7
+ export class ProviderModelCatalog {
8
+ catalogs;
9
+ constructor(catalogs) {
10
+ this.catalogs = catalogs;
11
+ }
12
+ list(provider) {
13
+ return this.catalogs[provider].list();
14
+ }
15
+ }
@@ -0,0 +1,88 @@
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
+ /**
7
+ * Decodes bounded activity evidence from kimi web events. Turn lifecycle is
8
+ * prompt-based; `agent.status.updated` supplies context usage and
9
+ * awaiting-human transitions (kimi 2.0.2 has no dedicated interaction events —
10
+ * pending approvals/questions arrive through status changes plus REST lists).
11
+ */
12
+ export function decodeKimiActivityFacts(sessionId, occurredAt, event, context) {
13
+ const payload = record(event.payload);
14
+ if (!payload || typeof payload.type !== 'string')
15
+ return [];
16
+ switch (payload.type) {
17
+ case 'turn.started': {
18
+ const agentId = safeId(payload.agentId);
19
+ const turnNumber = safeTurn(payload.turnId);
20
+ if (!agentId || turnNumber === null)
21
+ return [];
22
+ const turnId = context.resolveTurnId(agentId, turnNumber);
23
+ return [
24
+ {
25
+ sessionId,
26
+ occurredAt,
27
+ kind: 'turnStarted',
28
+ ...(event.session_id ? { threadId: event.session_id } : {}),
29
+ ...(turnId ? { turnId } : {}),
30
+ ...(context.isChildAgent(agentId) ? { childThreadId: agentId } : {}),
31
+ },
32
+ ];
33
+ }
34
+ case 'prompt.completed':
35
+ case 'prompt.aborted': {
36
+ const agentId = safeId(payload.agentId);
37
+ const promptId = safeId(payload.promptId);
38
+ if (!agentId || !promptId || context.isChildAgent(agentId))
39
+ return [];
40
+ return [
41
+ {
42
+ sessionId,
43
+ occurredAt,
44
+ kind: 'turnCompleted',
45
+ ...(event.session_id ? { threadId: event.session_id } : {}),
46
+ turnId: promptId,
47
+ },
48
+ ];
49
+ }
50
+ case 'agent.status.updated': {
51
+ const status = typeof payload.status === 'string' ? payload.status : null;
52
+ const facts = [];
53
+ const usage = typeof payload.contextUsage === 'number' ? payload.contextUsage : null;
54
+ if (usage !== null && usage >= 0) {
55
+ facts.push({
56
+ sessionId,
57
+ occurredAt,
58
+ kind: 'contextUsage',
59
+ ...(event.session_id ? { threadId: event.session_id } : {}),
60
+ contextUsedPercent: usage <= 1 ? Math.round(usage * 100) : Math.min(100, Math.round(usage)),
61
+ });
62
+ }
63
+ if (status === 'awaiting_approval' || status === 'awaiting_question') {
64
+ facts.push({
65
+ sessionId,
66
+ occurredAt,
67
+ kind: 'interactionPending',
68
+ ...(event.session_id ? { threadId: event.session_id } : {}),
69
+ attentionReason: status === 'awaiting_approval' ? 'permissionRequired' : 'materialAmbiguity',
70
+ });
71
+ }
72
+ return facts;
73
+ }
74
+ default:
75
+ return [];
76
+ }
77
+ }
78
+ function record(value) {
79
+ return value && typeof value === 'object' && !Array.isArray(value)
80
+ ? value
81
+ : null;
82
+ }
83
+ function safeId(value) {
84
+ return typeof value === 'string' && value.length > 0 && value.length <= 256 ? value : null;
85
+ }
86
+ function safeTurn(value) {
87
+ return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0 ? value : null;
88
+ }
@@ -0,0 +1,30 @@
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
+ /**
7
+ * Error surfaced by the kimi web client. `code` is the protocol business code
8
+ * (0 means success on the wire, so a thrown error always has code !== 0);
9
+ * HTTP-level failures are normalized to code -1 with the transport message.
10
+ */
11
+ export class KimiWebError extends Error {
12
+ code;
13
+ requestId;
14
+ constructor(code, message, requestId) {
15
+ super(boundMessage(message));
16
+ this.code = code;
17
+ this.requestId = requestId;
18
+ this.name = 'KimiWebError';
19
+ }
20
+ }
21
+ export function isKimiWebError(error) {
22
+ return error instanceof KimiWebError;
23
+ }
24
+ /** Caps and redacts wire messages before they enter logs or client payloads. */
25
+ export function boundMessage(message) {
26
+ return message
27
+ .replace(/((?:authorization|token|api[_ -]?key|password)\s*[:=]\s*)\S+/gi, '$1[REDACTED]')
28
+ .replace(/(authentication failed:\s*).*/i, '$1[REDACTED]')
29
+ .slice(0, 256);
30
+ }
@@ -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
+ const MODELS_TIMEOUT_MS = 5_000;
7
+ /**
8
+ * Lists kimi model aliases from a running gestalt-owned kimi web server
9
+ * (`GET /api/v1/models`) when a Kimi profile server is already running.
10
+ * Discovery is deliberately read-only: bootstrap must not spawn or wait for
11
+ * Kimi on behalf of an otherwise ordinary Codex client. Explicit Kimi session
12
+ * activation owns server startup; any discovery failure degrades to an empty
13
+ * list, matching the catalog contract.
14
+ */
15
+ export class KimiModelCatalog {
16
+ servers;
17
+ available;
18
+ constructor(servers, available) {
19
+ this.servers = servers;
20
+ this.available = available;
21
+ }
22
+ async list() {
23
+ if (!this.available || !this.servers)
24
+ return [];
25
+ try {
26
+ const handle = this.servers.list()[0];
27
+ if (!handle)
28
+ return [];
29
+ return await this.read(handle);
30
+ }
31
+ catch {
32
+ return [];
33
+ }
34
+ }
35
+ /** Explicit Kimi session setup may start its isolated default server. */
36
+ async listForSession() {
37
+ if (!this.available || !this.servers)
38
+ return [];
39
+ try {
40
+ return await this.read(await this.servers.ensure('default', []));
41
+ }
42
+ catch {
43
+ return [];
44
+ }
45
+ }
46
+ async read(handle) {
47
+ const data = await withTimeout(handle.client.get('/api/v1/models'), MODELS_TIMEOUT_MS, 'kimi model list timed out');
48
+ const items = Array.isArray(data?.items)
49
+ ? data.items
50
+ : [];
51
+ return [
52
+ ...new Set(items.flatMap((item) => typeof item.model === 'string' && item.model.length > 0 && item.model.length <= 128
53
+ ? [item.model]
54
+ : [])),
55
+ ].sort();
56
+ }
57
+ }
58
+ function withTimeout(promise, timeoutMs, message) {
59
+ return Promise.race([
60
+ promise,
61
+ new Promise((_resolve, reject) => setTimeout(() => reject(new Error(message)), timeoutMs).unref()),
62
+ ]);
63
+ }
@@ -0,0 +1,196 @@
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
+ /**
7
+ * Maps kimi web session events onto the relay's provider-neutral event types.
8
+ * The relay's turn lifecycle is prompt-based (`prompt.completed`/`aborted`),
9
+ * because a gestalt turn id is the kimi prompt id; `turn.started` exists only
10
+ * to bind kimi per-agent turn numbers to prompt ids. Unknown future event
11
+ * types deliberately become `null`, mirroring the codex normalizer.
12
+ */
13
+ export function normalizeKimiEvent(sessionId, sequence, occurredAt, event, context = {
14
+ resolveTurnId: () => null,
15
+ isChildAgent: () => false,
16
+ }) {
17
+ const payload = record(event.payload);
18
+ if (!payload || typeof payload.type !== 'string')
19
+ return null;
20
+ switch (payload.type) {
21
+ case 'assistant.delta': {
22
+ const agentId = safeId(payload.agentId);
23
+ const turnNumber = safeTurn(payload.turnId);
24
+ const delta = bounded(payload.delta, 64_000);
25
+ if (!agentId || turnNumber === null || delta === null)
26
+ return null;
27
+ if (context.isChildAgent(agentId))
28
+ return null;
29
+ const turnId = context.resolveTurnId(agentId, turnNumber) ?? `kimi:${agentId}:${turnNumber}`;
30
+ return {
31
+ sessionId,
32
+ sequence,
33
+ occurredAt,
34
+ type: 'agentMessageDelta',
35
+ payload: { text: delta, itemId: `kimi:${agentId}:${turnNumber}`, turnId },
36
+ };
37
+ }
38
+ case 'tool.call.started': {
39
+ const toolCallId = safeId(payload.toolCallId);
40
+ const agentId = safeId(payload.agentId) ?? 'main';
41
+ const turnNumber = safeTurn(payload.turnId);
42
+ if (!toolCallId || turnNumber === null)
43
+ return null;
44
+ const display = record(payload.display);
45
+ const kind = typeof display?.kind === 'string' ? display.kind : 'generic';
46
+ const name = safeId(payload.name);
47
+ const description = bounded(payload.description, 500);
48
+ const detail = (typeof display?.command === 'string' ? bounded(display.command, 500) : null) ??
49
+ (typeof display?.path === 'string' ? bounded(display.path, 500) : null) ??
50
+ description ??
51
+ name ??
52
+ kind;
53
+ const label = kind === 'command' || kind === 'bash'
54
+ ? 'Command'
55
+ : kind === 'file_io'
56
+ ? 'File'
57
+ : `Tool · ${name ?? kind}`;
58
+ const owner = ownership(agentId, turnNumber, context);
59
+ return {
60
+ sessionId,
61
+ sequence,
62
+ occurredAt,
63
+ type: 'activity.updated',
64
+ payload: { id: toolCallId, label, detail, ...owner },
65
+ };
66
+ }
67
+ case 'tool.result': {
68
+ const toolCallId = safeId(payload.toolCallId);
69
+ const agentId = safeId(payload.agentId) ?? 'main';
70
+ const turnNumber = safeTurn(payload.turnId);
71
+ if (!toolCallId || turnNumber === null)
72
+ return null;
73
+ const isError = payload.isError === true;
74
+ const owner = ownership(agentId, turnNumber, context);
75
+ return {
76
+ sessionId,
77
+ sequence,
78
+ occurredAt,
79
+ type: 'activity.updated',
80
+ payload: {
81
+ id: toolCallId,
82
+ label: isError ? 'Tool error' : 'Tool result',
83
+ detail: bounded(safeJson(payload.output), 400) ?? (isError ? 'failed' : 'done'),
84
+ ...owner,
85
+ },
86
+ };
87
+ }
88
+ case 'subagent.spawned': {
89
+ const subagentId = safeId(payload.subagentId);
90
+ if (!subagentId)
91
+ return null;
92
+ const name = bounded(payload.subagentName, 128);
93
+ const description = bounded(payload.description, 300);
94
+ return {
95
+ sessionId,
96
+ sequence,
97
+ occurredAt,
98
+ type: 'activity.updated',
99
+ payload: {
100
+ id: subagentId,
101
+ label: 'Subagent',
102
+ detail: [name, description].filter(Boolean).join(' · '),
103
+ ...(context.activeTurnId ? { turnId: context.activeTurnId } : {}),
104
+ },
105
+ };
106
+ }
107
+ case 'subagent.completed':
108
+ case 'subagent.failed': {
109
+ const subagentId = safeId(payload.subagentId);
110
+ if (!subagentId)
111
+ return null;
112
+ const failed = payload.type === 'subagent.failed';
113
+ return {
114
+ sessionId,
115
+ sequence,
116
+ occurredAt,
117
+ type: 'activity.updated',
118
+ payload: {
119
+ id: subagentId,
120
+ label: failed ? 'Subagent failed' : 'Subagent completed',
121
+ detail: bounded(payload.resultSummary, 500) ?? '',
122
+ ...(context.activeTurnId ? { turnId: context.activeTurnId } : {}),
123
+ },
124
+ };
125
+ }
126
+ case 'prompt.completed': {
127
+ const agentId = safeId(payload.agentId);
128
+ const promptId = safeId(payload.promptId);
129
+ if (!agentId || !promptId || context.isChildAgent(agentId))
130
+ return null;
131
+ return {
132
+ sessionId,
133
+ sequence,
134
+ occurredAt,
135
+ type: 'turnCompleted',
136
+ payload: {
137
+ turn: {
138
+ id: promptId,
139
+ ...(typeof payload.reason === 'string' ? { status: bounded(payload.reason, 64) } : {}),
140
+ },
141
+ },
142
+ };
143
+ }
144
+ case 'prompt.aborted': {
145
+ const agentId = safeId(payload.agentId);
146
+ const promptId = safeId(payload.promptId);
147
+ if (!agentId || !promptId || context.isChildAgent(agentId))
148
+ return null;
149
+ return {
150
+ sessionId,
151
+ sequence,
152
+ occurredAt,
153
+ type: 'turnInterrupted',
154
+ payload: { turn: { id: promptId } },
155
+ };
156
+ }
157
+ default:
158
+ return null;
159
+ }
160
+ }
161
+ function ownership(agentId, turnNumber, context) {
162
+ if (context.isChildAgent(agentId)) {
163
+ return {
164
+ ...(context.activeTurnId ? { turnId: context.activeTurnId } : {}),
165
+ actorTurnId: `${agentId}:${turnNumber}`,
166
+ };
167
+ }
168
+ const turnId = context.resolveTurnId(agentId, turnNumber);
169
+ return turnId ? { turnId } : context.activeTurnId ? { turnId: context.activeTurnId } : {};
170
+ }
171
+ function record(value) {
172
+ return value && typeof value === 'object' && !Array.isArray(value)
173
+ ? value
174
+ : null;
175
+ }
176
+ function safeId(value) {
177
+ return typeof value === 'string' && value.length > 0 && value.length <= 256 ? value : null;
178
+ }
179
+ function safeTurn(value) {
180
+ return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0 ? value : null;
181
+ }
182
+ function bounded(value, max) {
183
+ return typeof value === 'string' ? value.slice(0, max) : null;
184
+ }
185
+ function safeJson(value) {
186
+ if (value === undefined || value === null)
187
+ return null;
188
+ if (typeof value === 'string')
189
+ return value.slice(0, 400);
190
+ try {
191
+ return JSON.stringify(value).slice(0, 400);
192
+ }
193
+ catch {
194
+ return null;
195
+ }
196
+ }
@@ -0,0 +1,68 @@
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
+ const LIST_PATH = '/api/v2/sessions?page_size=50&sort=meta.updated_at_desc';
7
+ const LIST_TIMEOUT_MS = 5_000;
8
+ /**
9
+ * Lists recent kimi web sessions across every running gestalt-owned server,
10
+ * for parity with the codex recent-thread lister. Each skill profile owns an
11
+ * isolated `kimi web` process and state directory, so a profile's sessions
12
+ * only exist on that profile's server; only already-running servers are
13
+ * queried, never spawned just to list. kimi timestamps arrive as epoch
14
+ * seconds (or, defensively, epoch millis), normalized to seconds.
15
+ */
16
+ export function createKimiRecentSessionLister(deps) {
17
+ return {
18
+ async list() {
19
+ if (!deps.available || !deps.servers)
20
+ return [];
21
+ const listings = await Promise.all(deps.servers.list().map(listFromServer));
22
+ return listings.flat().sort((a, b) => (b.recencyAt ?? 0) - (a.recencyAt ?? 0));
23
+ },
24
+ };
25
+ }
26
+ async function listFromServer(handle) {
27
+ try {
28
+ const data = (await withTimeout(handle.client.get(LIST_PATH), LIST_TIMEOUT_MS, 'kimi session list timed out'));
29
+ const items = Array.isArray(data.items) ? data.items : [];
30
+ return items.flatMap((item) => {
31
+ if (!item || typeof item !== 'object')
32
+ return [];
33
+ const workspace = item.workspace;
34
+ const cwd = workspace && typeof workspace === 'object'
35
+ ? workspace.cwd
36
+ : undefined;
37
+ if (typeof item.id !== 'string' || typeof cwd !== 'string' || !cwd.startsWith('/'))
38
+ return [];
39
+ const updatedAt = item.meta && typeof item.meta === 'object'
40
+ ? item.meta.updated_at
41
+ : undefined;
42
+ return [
43
+ {
44
+ id: item.id,
45
+ cwd,
46
+ profile: handle.profileKey,
47
+ recencyAt: toEpochSeconds(updatedAt),
48
+ provider: 'kimi',
49
+ },
50
+ ];
51
+ });
52
+ }
53
+ catch {
54
+ return [];
55
+ }
56
+ }
57
+ function toEpochSeconds(value) {
58
+ if (typeof value !== 'number' || !Number.isFinite(value))
59
+ return null;
60
+ // Epoch millis are roughly 1e12+ today; seconds stay below 1e11 for decades.
61
+ return value > 1e11 ? Math.floor(value / 1000) : Math.floor(value);
62
+ }
63
+ function withTimeout(promise, timeoutMs, message) {
64
+ return Promise.race([
65
+ promise,
66
+ new Promise((_resolve, reject) => setTimeout(() => reject(new Error(message)), timeoutMs).unref()),
67
+ ]);
68
+ }