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
@@ -13,9 +13,18 @@ import { FilesystemWorkspaceFiles } from './platform/filesystem/filesystem-works
13
13
  import { protocolCompatibility } from './platform/codex/protocol-compatibility.js';
14
14
  import { launchCodexAppServer } from './platform/codex/codex-process-launcher.js';
15
15
  import { CodexModelCatalog } from './platform/codex/codex-model-catalog.js';
16
+ import { ProviderModelCatalog } from './platform/catalog/provider-model-catalog.js';
16
17
  import { createRecentThreadLister } from './platform/codex/recent-thread-lister.js';
17
18
  import { CodexSessionRuntime } from './platform/codex/session-runtime.js';
18
19
  import { normalizeCodexNotification } from './platform/codex/normalizer.js';
20
+ import { KimiSessionRuntime } from './platform/kimi/kimi-session-runtime.js';
21
+ import { KimiWebServerManager } from './platform/kimi/kimi-web-server-manager.js';
22
+ import { KimiModelCatalog } from './platform/kimi/kimi-model-catalog.js';
23
+ import { KimiSkillCatalog } from './platform/kimi/kimi-skill-catalog.js';
24
+ import { createKimiRecentSessionLister } from './platform/kimi/kimi-recent-session-lister.js';
25
+ import { kimiStateBase } from './platform/kimi/kimi-share-dir.js';
26
+ import { normalizeKimiEvent } from './platform/kimi/kimi-normalizer.js';
27
+ import { decodeKimiActivityFacts } from './platform/kimi/kimi-activity-facts.js';
19
28
  import { isMissingCodexThreadRollout } from './platform/codex/json-rpc-client.js';
20
29
  import { migrate } from './platform/persistence/migrate.js';
21
30
  import { openRelayDatabase } from './platform/persistence/sqlite.js';
@@ -139,7 +148,30 @@ export async function composeRelayApp(options) {
139
148
  ((sessionId, code) => console.warn(`agent activity ${code} session=${sessionId}`)),
140
149
  reconcile: async (sessionId) => {
141
150
  const session = sessions.find(sessionId);
142
- if (!session || !runtime)
151
+ if (!session)
152
+ return;
153
+ // Kimi sessions own no child-process projection; one detached history
154
+ // read supplies the whole reconciliation, mirroring the codex tail.
155
+ if (session.provider === 'kimi') {
156
+ if (!kimiRuntime)
157
+ return;
158
+ const history = await kimiRuntime.readHistory(session);
159
+ // Forget may commit while the detached history reader is still in
160
+ // flight. Revalidate ownership before handing its result to the
161
+ // registry, whose publisher intentionally keeps the journal strict.
162
+ if (!sessions.find(sessionId))
163
+ return;
164
+ const occurredAt = new Date().toISOString();
165
+ activity.observe({
166
+ sessionId,
167
+ occurredAt,
168
+ kind: history.activeTurnId ? 'turnStarted' : 'turnCompleted',
169
+ ...(session.threadId ? { threadId: session.threadId } : {}),
170
+ ...(history.activeTurnId ? { turnId: history.activeTurnId } : {}),
171
+ });
172
+ return;
173
+ }
174
+ if (!runtime)
143
175
  return;
144
176
  const history = await runtime.readHistory(session);
145
177
  // Forget may commit while the detached history reader is still in
@@ -327,13 +359,39 @@ export async function composeRelayApp(options) {
327
359
  options.onAutopilotCoordinator?.(autopilot);
328
360
  const workspaces = new FilesystemWorkspaceCatalog(root);
329
361
  const workspaceFiles = new FilesystemWorkspaceFiles();
330
- const models = new CodexModelCatalog(root, options.launchAppServer ?? launchCodexAppServer);
362
+ const homeDirectory = options.homeDirectory ?? homedir();
363
+ // Constructing the manager is cheap (no spawn until `ensure`); building it
364
+ // before the model catalog lets the catalog reuse the gestalt-owned servers.
365
+ const kimiManager = options.kimiServerManager ??
366
+ (options.installedKimiVersion != null && options.startAppServers
367
+ ? new KimiWebServerManager({
368
+ stateBase: kimiStateBase(homeDirectory),
369
+ sourceShareDir: join(homeDirectory, '.kimi-code'),
370
+ })
371
+ : undefined);
372
+ const kimiModels = new KimiModelCatalog(kimiManager ?? null, kimiManager != null);
373
+ const models = new ProviderModelCatalog({
374
+ codex: options.installedCodexVersion === null
375
+ ? { list: async () => [] }
376
+ : new CodexModelCatalog(root, options.launchAppServer ?? launchCodexAppServer),
377
+ kimi: kimiModels,
378
+ });
379
+ const sessionModels = options.sessionModelCatalog ?? {
380
+ list: (provider) => (provider === 'kimi' ? kimiModels.listForSession() : models.list(provider)),
381
+ };
331
382
  const skillProfiles = new FilesystemSkillProfileStore(options.homeDirectory ?? homedir());
332
- const skillCatalog = (profile) => new CodexSkillCatalog(profile, options.launchAppServer ?? launchCodexAppServer);
333
- const editorSkillCatalog = new CachedSkillCatalog((profile, workspace) => skillCatalog(profile).list(workspace));
383
+ // kimi discovery is workspace-scoped, not profile-scoped: the profile only
384
+ // decides which discovered skills a session's server materializes.
385
+ const kimiSkillCatalog = new KimiSkillCatalog(kimiManager ?? null, kimiManager != null);
386
+ const skillCatalog = (provider, profile) => provider === 'kimi'
387
+ ? kimiSkillCatalog
388
+ : new CodexSkillCatalog(profile, options.launchAppServer ?? launchCodexAppServer);
389
+ const editorSkillCatalog = new CachedSkillCatalog((provider, profile, workspace) => skillCatalog(provider, profile).list(workspace));
334
390
  const workspacePlanCatalog = new FilesystemWorkspacePlanCatalog();
335
391
  const resolveSkills = async (session) => {
336
- const catalog = await skillCatalog(session.profile).list(session.workspacePath);
392
+ const catalog = session.provider === 'kimi'
393
+ ? await kimiSkillCatalog.list(session.workspacePath)
394
+ : await skillCatalog('codex', session.profile).list(session.workspacePath);
337
395
  if (session.effectiveSkillSelection)
338
396
  return compileSkillOverride({
339
397
  discovered: catalog.skills,
@@ -353,6 +411,15 @@ export async function composeRelayApp(options) {
353
411
  profiles: options.profiles,
354
412
  launch: options.launchAppServer ?? launchCodexAppServer,
355
413
  });
414
+ const kimiRecentSessions = createKimiRecentSessionLister({
415
+ servers: kimiManager ?? null,
416
+ available: kimiManager != null,
417
+ });
418
+ // The relay's recent list spans both llm services; execution-policy metadata
419
+ // stays a codex rollout concern, so the codex lister remains the source.
420
+ const recentThreadsForRelay = {
421
+ list: async () => [...(await recentThreads.list()), ...(await kimiRecentSessions.list())],
422
+ };
356
423
  const recoverExecutionPolicy = async (session) => {
357
424
  if (session.executionPolicy || !session.threadId)
358
425
  return session;
@@ -362,9 +429,20 @@ export async function composeRelayApp(options) {
362
429
  : session;
363
430
  };
364
431
  const protocol = protocolCompatibility(options.installedCodexVersion, generatedProtocolVersion);
432
+ const providerAvailability = {
433
+ codex: {
434
+ available: options.installedCodexVersion !== null && protocol.compatible,
435
+ ...(options.installedCodexVersion === null ? {} : { version: options.installedCodexVersion }),
436
+ },
437
+ kimi: {
438
+ available: options.installedKimiVersion != null,
439
+ ...(options.installedKimiVersion ? { version: options.installedKimiVersion } : {}),
440
+ },
441
+ };
365
442
  const gitFetches = new GitFetchCoordinator(fetchUpstream);
366
443
  const gitSummaries = new GitSummaryCache(inspectGit);
367
444
  let recoverExitedSession = () => { };
445
+ let recoverExitedKimiSession = () => { };
368
446
  let scheduleAgentCapacityRecovery = () => false;
369
447
  let planMeasurementRefresh;
370
448
  const publishInteractionResolved = (sessionId, requestId, occurredAt, outcome) => {
@@ -486,6 +564,36 @@ export async function composeRelayApp(options) {
486
564
  };
487
565
  let closing = false;
488
566
  let runtime = null;
567
+ let kimiRuntime = null;
568
+ /**
569
+ * Shared acceptance path for a provider-neutral pending interaction: it
570
+ * enters the durable store, requests human attention, and notifies autopilot.
571
+ * Both the codex JSON-RPC handler and the kimi polling handler land here.
572
+ */
573
+ const acceptPendingInteraction = (sessionId, rawInteraction, session) => {
574
+ const interaction = {
575
+ ...rawInteraction,
576
+ turnId: session.activeTurnId,
577
+ requestedAt: new Date().toISOString(),
578
+ };
579
+ interactions.add(sessionId, interaction);
580
+ activity.observe({
581
+ sessionId,
582
+ occurredAt: interaction.requestedAt,
583
+ kind: 'interactionPending',
584
+ ...(interaction.kind === 'orgPlanAttention'
585
+ ? { attentionReason: interaction.payload.reason }
586
+ : {}),
587
+ });
588
+ autopilot.semanticEvent(sessionId, 'interactionChanged');
589
+ const updated = RelaySession.rehydrate(session).requestInteraction(interaction, new Date().toISOString()).snapshot;
590
+ sessions.save(updated);
591
+ events.publish(journal.append(sessionId, 'interaction.requested', interaction, updated.updatedAt));
592
+ if (interaction.kind === 'orgPlanAttention')
593
+ events.publish(journal.append(sessionId, 'org-plan.attention-required', interaction, updated.updatedAt));
594
+ autopilot.evaluate(sessionId);
595
+ return true;
596
+ };
489
597
  const acceptPlanUpdate = (sessionId, update) => {
490
598
  if (closing)
491
599
  return;
@@ -668,28 +776,7 @@ export async function composeRelayApp(options) {
668
776
  // is cancelled solely by the JSON-RPC write-settlement callback.
669
777
  return true;
670
778
  }
671
- const interaction = {
672
- ...rawInteraction,
673
- turnId: session.activeTurnId,
674
- requestedAt: new Date().toISOString(),
675
- };
676
- interactions.add(sessionId, interaction);
677
- activity.observe({
678
- sessionId,
679
- occurredAt: interaction.requestedAt,
680
- kind: 'interactionPending',
681
- ...(interaction.kind === 'orgPlanAttention'
682
- ? { attentionReason: interaction.payload.reason }
683
- : {}),
684
- });
685
- autopilot.semanticEvent(sessionId, 'interactionChanged');
686
- const updated = RelaySession.rehydrate(session).requestInteraction(interaction, new Date().toISOString()).snapshot;
687
- sessions.save(updated);
688
- events.publish(journal.append(sessionId, 'interaction.requested', interaction, updated.updatedAt));
689
- if (interaction.kind === 'orgPlanAttention')
690
- events.publish(journal.append(sessionId, 'org-plan.attention-required', interaction, updated.updatedAt));
691
- autopilot.evaluate(sessionId);
692
- return true;
779
+ return acceptPendingInteraction(sessionId, rawInteraction, session);
693
780
  }, (sessionId) => {
694
781
  activity.disconnected(sessionId, new Date().toISOString());
695
782
  failPendingCheckpointHandoffs(sessionId, false);
@@ -697,6 +784,59 @@ export async function composeRelayApp(options) {
697
784
  recoverExitedSession(sessionId);
698
785
  }, resolveSkills, planStatusSource, acceptPlanUpdate, options.planMeasurementBaseUrl, 30_000, 64, root)
699
786
  : null;
787
+ const handleKimiNotification = (sessionId, event) => {
788
+ const occurredAt = new Date().toISOString();
789
+ const session = sessions.find(sessionId);
790
+ const context = kimiRuntime?.eventContext(sessionId) ?? {
791
+ resolveTurnId: () => null,
792
+ isChildAgent: () => false,
793
+ };
794
+ for (const fact of decodeKimiActivityFacts(sessionId, occurredAt, event, context))
795
+ activity.observe(fact);
796
+ const normalized = normalizeKimiEvent(sessionId, 0, occurredAt, event, {
797
+ ...context,
798
+ workspacePath: session?.workspacePath,
799
+ activeTurnId: session?.activeTurnId,
800
+ });
801
+ if (!normalized)
802
+ return;
803
+ let completedSession;
804
+ if (normalized.type === 'turnCompleted' || normalized.type === 'turnInterrupted') {
805
+ const turnId = normalized.payload.turn?.id;
806
+ if (session && turnId && session.activeTurnId === turnId) {
807
+ completedSession = RelaySession.rehydrate(session).completeTurn(turnId, occurredAt).snapshot;
808
+ sessions.save(completedSession);
809
+ }
810
+ }
811
+ events.publish(journal.append(sessionId, normalized.type, normalized.payload, occurredAt));
812
+ if (completedSession)
813
+ events.publish(journal.append(sessionId, 'session.updated', completedSession, occurredAt));
814
+ };
815
+ const handleKimiServerRequest = (sessionId, request) => {
816
+ const rawInteraction = toPendingInteraction(request);
817
+ const session = withPendingInteractions(sessions.find(sessionId));
818
+ return Boolean(rawInteraction && session && acceptPendingInteraction(sessionId, rawInteraction, session));
819
+ };
820
+ kimiRuntime = kimiManager
821
+ ? new KimiSessionRuntime({
822
+ servers: kimiManager,
823
+ skillsFor: async (session) => {
824
+ const config = await resolveSkills(session);
825
+ return config?.map((entry) => ({
826
+ name: basename(entry.path),
827
+ path: entry.path,
828
+ enabled: entry.enabled,
829
+ }));
830
+ },
831
+ onNotification: (sessionId, event) => handleKimiNotification(sessionId, event),
832
+ onServerRequest: (sessionId, request) => handleKimiServerRequest(sessionId, request),
833
+ onExit: (sessionId) => {
834
+ activity.disconnected(sessionId, new Date().toISOString());
835
+ dismissPendingInteractions(sessionId, new Date().toISOString(), 'failed');
836
+ recoverExitedKimiSession(sessionId);
837
+ },
838
+ })
839
+ : null;
700
840
  runtime?.onServerResponseSettled((sessionId, requestId, outcome) => {
701
841
  const interaction = interactions.find(sessionId, requestId);
702
842
  if (interaction?.kind !== 'orgPlanCheckpoint')
@@ -726,13 +866,27 @@ export async function composeRelayApp(options) {
726
866
  prior.state !== 'ready' &&
727
867
  prior.state !== 'turnActive' &&
728
868
  (session.state === 'ready' || session.state === 'turnActive');
729
- if (runtime &&
869
+ if ((runtime || kimiRuntime) &&
730
870
  session.threadId &&
731
871
  (!prior || prior.threadId !== session.threadId || becameRuntimeReady))
732
872
  void activity.refresh(session.id);
733
873
  if (becameRuntimeReady)
734
874
  autopilot.restore(session.id);
735
875
  };
876
+ if (kimiRuntime) {
877
+ // A gestalt-owned kimi web process exited or its websocket dropped: stop
878
+ // the durable session like the codex supervisor does, without a resume
879
+ // retry (a later activation reattaches through the normal writer path).
880
+ recoverExitedKimiSession = (sessionId) => {
881
+ const session = sessions.find(sessionId);
882
+ if (session) {
883
+ autopilot.cancel(sessionId, 'sessionEnded');
884
+ saveSession(RelaySession.rehydrate(session).stop(new Date().toISOString()).snapshot);
885
+ }
886
+ };
887
+ }
888
+ /** Routes a session to the runtime owning its provider's llm service. */
889
+ const ownerRuntime = (session) => session.provider === 'kimi' ? kimiRuntime : runtime;
736
890
  if (runtime) {
737
891
  const capacityRecoveries = new Set();
738
892
  scheduleAgentCapacityRecovery = (sessionId, acknowledge) => {
@@ -842,6 +996,7 @@ export async function composeRelayApp(options) {
842
996
  protocolVersion: generatedProtocolVersion,
843
997
  compatible: protocol.compatible,
844
998
  },
999
+ providers: providerAvailability,
845
1000
  };
846
1001
  },
847
1002
  },
@@ -859,7 +1014,7 @@ export async function composeRelayApp(options) {
859
1014
  },
860
1015
  logger: console,
861
1016
  staticDir: options.staticDir,
862
- recentThreads,
1017
+ recentThreads: recentThreadsForRelay,
863
1018
  bootstrap: {
864
1019
  workspaces,
865
1020
  profiles: options.profiles,
@@ -875,6 +1030,7 @@ export async function composeRelayApp(options) {
875
1030
  },
876
1031
  versions: options.componentVersions,
877
1032
  protocolCompatible: protocol.compatible,
1033
+ providers: providerAvailability,
878
1034
  },
879
1035
  sessionRoutes: {
880
1036
  createId: randomUUID,
@@ -888,30 +1044,40 @@ export async function composeRelayApp(options) {
888
1044
  skillProfiles,
889
1045
  skillCatalog,
890
1046
  defaultSkillProfile: options.explicitSkillProfile,
891
- activate: runtime
1047
+ activate: runtime || kimiRuntime
892
1048
  ? async (session) => {
893
1049
  const now = new Date().toISOString();
894
1050
  dismissPendingInteractions(session.id, now);
895
- const started = await runtime.start(session, now);
896
- return started;
1051
+ const owner = ownerRuntime(session);
1052
+ if (!owner)
1053
+ throw new Error('LLM_RUNTIME_UNAVAILABLE');
1054
+ return owner.start(session, now);
897
1055
  }
898
1056
  : undefined,
899
- startTurn: runtime
1057
+ startTurn: runtime || kimiRuntime
900
1058
  ? async (session, text, clientUserMessageId) => {
901
1059
  autopilot.manualSend(session.id);
902
- return runtime.startTurn(session, text, clientUserMessageId, new Date().toISOString());
1060
+ const owner = ownerRuntime(session);
1061
+ if (!owner)
1062
+ throw new Error('LLM_RUNTIME_UNAVAILABLE');
1063
+ return owner.startTurn(session, text, clientUserMessageId, new Date().toISOString());
903
1064
  }
904
1065
  : undefined,
905
- ensureWriter: runtime
1066
+ ensureWriter: runtime || kimiRuntime
906
1067
  ? async (session) => {
907
1068
  dismissPendingInteractions(session.id, new Date().toISOString());
908
- return runtime.ensureWriter(await recoverExecutionPolicy(session), new Date().toISOString());
1069
+ const owner = ownerRuntime(session);
1070
+ if (!owner)
1071
+ throw new Error('LLM_RUNTIME_UNAVAILABLE');
1072
+ return owner.ensureWriter(await recoverExecutionPolicy(session), new Date().toISOString());
909
1073
  }
910
1074
  : undefined,
911
- releaseWriter: runtime
1075
+ releaseWriter: runtime || kimiRuntime
912
1076
  ? (id) => {
913
1077
  dismissPendingInteractions(id, new Date().toISOString());
914
- return runtime.release(id);
1078
+ const session = sessions.find(id);
1079
+ const owner = session ? ownerRuntime(session) : null;
1080
+ return owner ? owner.release(id) : Promise.resolve();
915
1081
  }
916
1082
  : undefined,
917
1083
  onTurnStarted: (session) => {
@@ -930,24 +1096,41 @@ export async function composeRelayApp(options) {
930
1096
  autopilotAudit: (id, limit) => journal.autopilotAuditTail(id, limit),
931
1097
  refreshActivity: (id) => activity.refresh(id),
932
1098
  models,
933
- readHistory: runtime ? (session) => runtime.readHistory(session) : undefined,
1099
+ sessionModels,
1100
+ readHistory: runtime || kimiRuntime
1101
+ ? (session) => {
1102
+ const owner = ownerRuntime(session);
1103
+ if (!owner)
1104
+ return Promise.reject(new Error('LLM_RUNTIME_UNAVAILABLE'));
1105
+ return owner.readHistory(session);
1106
+ }
1107
+ : undefined,
934
1108
  currentSequence: (sessionId) => journal.since(sessionId, 0).at(-1)?.sequence ?? 0,
935
1109
  activityHistory: (sessionId, throughSequence) => journal.since(sessionId, 0).filter((event) => event.sequence <= throughSequence),
936
- interruptTurn: runtime
937
- ? (session, turnId) => runtime.interruptTurn(session, turnId)
1110
+ interruptTurn: runtime || kimiRuntime
1111
+ ? (session, turnId) => {
1112
+ const owner = ownerRuntime(session);
1113
+ return owner ? owner.interruptTurn(session, turnId) : Promise.resolve();
1114
+ }
938
1115
  : undefined,
939
- queueTurnInput: runtime
1116
+ queueTurnInput: runtime || kimiRuntime
940
1117
  ? (session, turnId, text, clientUserMessageId) => {
941
1118
  autopilot.manualSend(session.id);
942
- return runtime.queueTurnInput(session, turnId, text, clientUserMessageId);
1119
+ const owner = ownerRuntime(session);
1120
+ if (!owner)
1121
+ return Promise.resolve();
1122
+ return owner.queueTurnInput(session, turnId, text, clientUserMessageId);
943
1123
  }
944
1124
  : undefined,
945
- restore: runtime
1125
+ restore: runtime || kimiRuntime
946
1126
  ? async (session) => {
947
- const restored = await runtime.restoreWithOutcome(await recoverExecutionPolicy(session), new Date().toISOString());
1127
+ const owner = ownerRuntime(session);
1128
+ if (!owner)
1129
+ throw new Error('LLM_RUNTIME_UNAVAILABLE');
1130
+ const restored = await owner.restoreWithOutcome(await recoverExecutionPolicy(session), new Date().toISOString());
948
1131
  // An exit can be reported while resume is resolving. Do not let the
949
1132
  // route persist a stale ready snapshot over that recovered exit.
950
- if (!runtime.ownsWriter(session.id))
1133
+ if (!owner.ownsWriter(session.id))
951
1134
  return {
952
1135
  ...restored,
953
1136
  session: RelaySession.rehydrate(restored.session).stop(new Date().toISOString())
@@ -956,15 +1139,26 @@ export async function composeRelayApp(options) {
956
1139
  return restored;
957
1140
  }
958
1141
  : undefined,
959
- ownsWriter: runtime ? (id) => runtime.ownsWriter(id) : undefined,
960
- promoteRecent: runtime
1142
+ ownsWriter: (id) => {
1143
+ const session = sessions.find(id);
1144
+ const owner = session ? ownerRuntime(session) : null;
1145
+ return owner ? owner.ownsWriter(id) : false;
1146
+ },
1147
+ promoteRecent: runtime || kimiRuntime
961
1148
  ? (thread) => promoteRecentThread(thread, {
962
1149
  createId: randomUUID,
963
1150
  now: () => new Date().toISOString(),
964
1151
  list: () => sessions.list(),
965
1152
  save: saveSession,
966
- read: (session) => runtime.readHistory(session),
967
- executionPolicy: (candidate) => recentThreads.executionPolicy(candidate.id),
1153
+ read: (session) => {
1154
+ const owner = ownerRuntime(session);
1155
+ if (!owner)
1156
+ throw new Error('LLM_RUNTIME_UNAVAILABLE');
1157
+ return owner.readHistory(session);
1158
+ },
1159
+ executionPolicy: (candidate) => candidate.provider === 'kimi'
1160
+ ? Promise.resolve(undefined)
1161
+ : recentThreads.executionPolicy(candidate.id),
968
1162
  })
969
1163
  : undefined,
970
1164
  release: (session) => {
@@ -977,17 +1171,28 @@ export async function composeRelayApp(options) {
977
1171
  sessions.remove(id);
978
1172
  },
979
1173
  idempotency,
980
- close: runtime
1174
+ close: runtime || kimiRuntime
981
1175
  ? (id) => {
982
1176
  autopilot.cancel(id, 'sessionEnded');
983
1177
  planMeasurementRefresh?.stop(id);
984
1178
  dismissPendingInteractions(id, new Date().toISOString());
985
1179
  activity.dispose(id);
986
- return runtime.release(id);
1180
+ const session = sessions.find(id);
1181
+ const owner = session ? ownerRuntime(session) : null;
1182
+ return owner ? owner.release(id) : Promise.resolve();
987
1183
  }
988
1184
  : undefined,
989
- replyInteraction: runtime
990
- ? (sessionId, requestId, value) => runtime.resolveServerRequest(sessionId, requestId, value) ? 'accepted' : 'cleared'
1185
+ replyInteraction: runtime || kimiRuntime
1186
+ ? async (sessionId, requestId, value) => {
1187
+ const session = sessions.find(sessionId);
1188
+ const owner = session ? ownerRuntime(session) : null;
1189
+ if (await owner?.resolveServerRequest(sessionId, requestId, value))
1190
+ return 'accepted';
1191
+ // Codex reports false when its app-server has already cleared a
1192
+ // request. Kimi returns false for a failed provider delivery and
1193
+ // retains the pending request for retry.
1194
+ return session?.provider === 'kimi' ? 'unavailable' : 'cleared';
1195
+ }
991
1196
  : undefined,
992
1197
  interactionResolved: (sessionId, requestId, occurredAt, outcome) => {
993
1198
  const remaining = interactions.list(sessionId);
@@ -1217,7 +1422,7 @@ export async function composeRelayApp(options) {
1217
1422
  // not a human disable: a later fenced continuation can reacquire it safely.
1218
1423
  if (session.desiredState === 'active') {
1219
1424
  saveSession(RelaySession.rehydrate(session).stop(new Date().toISOString()).snapshot);
1220
- await runtime?.release(session.id);
1425
+ await (session.provider === 'kimi' ? kimiRuntime : runtime)?.release(session.id);
1221
1426
  }
1222
1427
  await runtime?.watchPlanStatus(session);
1223
1428
  autopilot.restore(session.id);
@@ -1230,7 +1435,10 @@ export async function composeRelayApp(options) {
1230
1435
  app.addHook('onListen', async () => {
1231
1436
  const profile = (await options.profiles.list()).find((item) => item.state === 'ok')?.name;
1232
1437
  if (profile)
1233
- await editorSkillCatalog.refresh(profile, root);
1438
+ await editorSkillCatalog.refresh('codex', profile, root);
1439
+ // Kimi discovery owns a provider process, so passive relay startup must not
1440
+ // refresh it. Explicit session creation and user-requested skill refreshes
1441
+ // enter KimiSkillCatalog through their normal operation paths instead.
1234
1442
  await detachActiveSessions();
1235
1443
  });
1236
1444
  app.addHook('onClose', async () => {
@@ -1261,6 +1469,8 @@ export async function composeRelayApp(options) {
1261
1469
  }
1262
1470
  }
1263
1471
  runtime?.stopAll();
1472
+ kimiRuntime?.stopAll();
1473
+ await kimiManager?.stopAll();
1264
1474
  planStatusSource.closeAll();
1265
1475
  database.close();
1266
1476
  authorization?.close();
@@ -3,8 +3,8 @@
3
3
  * Designed by Denis Roio <jaromil@dyne.org>
4
4
  * SPDX-License-Identifier: AGPL-3.0-or-later
5
5
  */
6
- export const AUTOPILOT_PROMPT_VERSION = 'v9';
7
- export const AUTOPILOT_CONTINUATION_PROMPT = 'Inspect the active supervised Org Plan. Refer to every L1 as L<a> and each nested L2 as L<a>.<b>, using one-based positions. Spawn exactly one executor per L1 and pass the exact literal task_name l<a>: l1 for L1, l2 for L2, and so on. Never create an L2-specific task name or append a title, role, nickname, plan name, or generated label. A validated DONE L2 and an accepted L1 are mandatory answer boundaries. The corresponding gestalt_org_plan_checkpoint call must be the last tool call of that root turn: immediately emit exactly one boundary final and end the turn, without followup_task, review, executor launch, or later milestone work. On this later Autopilot turn, resume the same executor after an L2 boundary or launch the next executor after an accepted-L1 boundary. Treat a status question as an interruption: answer briefly, then perform the applicable continuation in the same turn. Before sending any blocker response or yielding because progress cannot continue safely, call gestalt_org_plan_attention for the matching decision-table blocker; never rely on blocker prose as the signal. Do not call it for routine progress or a recoverable failure. Before yielding for known long work, call gestalt_autopilot_wait_lease version 2 with relevant observable wake conditions and a bounded maxWaitMs; it is one episode, so reassess before registering another in a later turn. For GitHub PR CI, start an owned gh pr checks --watch process and lease processExited plus processResultAvailable while it runs. When the Autopilot probe is active, register the compatible wait lease, declare genuine attention, or immediately do actionable work; never acknowledge waiting in prose. Do not send a status-only response.';
6
+ export const AUTOPILOT_PROMPT_VERSION = 'v10';
7
+ export const AUTOPILOT_CONTINUATION_PROMPT = 'Inspect the active supervised Org Plan. Refer to every L1 as L<a> and each nested L2 as L<a>.<b>, using one-based positions. Spawn exactly one executor per L1 and pass the exact literal task_name l<a>: l1 for L1, l2 for L2, and so on. Never create an L2-specific task name or append a title, role, nickname, plan name, or generated label. A validated DONE L2 and an accepted L1 are mandatory answer boundaries. The corresponding gestalt_org_plan_checkpoint call must be the last tool call of that root turn: immediately emit exactly one boundary final and end the turn, without followup_task, review, executor launch, or later milestone work. Roll up commentary, files, verification, and commands since the previous boundary into that one answer; all later milestone activity belongs to a new root turn. On this later Autopilot turn, resume the same executor after an L2 boundary or launch the next executor after an accepted-L1 boundary. Treat a status question as an interruption: answer briefly, then perform the applicable continuation in the same turn. Before sending any blocker response or yielding because progress cannot continue safely, call gestalt_org_plan_attention for the matching decision-table blocker; never rely on blocker prose as the signal. Do not call it for routine progress or a recoverable failure. Before yielding for known long work, call gestalt_autopilot_wait_lease version 2 with relevant observable wake conditions and a bounded maxWaitMs; it is one episode, so reassess before registering another in a later turn. Yield only when its response contains accepted:true; accepted:false means no continuation was registered and requires same-turn supervision. For GitHub PR CI, start an owned gh pr checks --watch process and lease processExited plus processResultAvailable while it runs. When the Autopilot probe is active, register the compatible wait lease, declare genuine attention, or immediately do actionable work; never acknowledge waiting in prose. Do not send a status-only response.';
8
8
  export const AUTOPILOT_EXECUTOR_CONTINUATION_PROMPT = 'Continue the same assigned Org L1 from its durable state. A prior turn ending did not complete the objective. Consume any supplied process result and take the next legal L2 action. Whenever an L2 reaches DONE, return its concise structured evidence and end the executor turn so the root can publish that mandatory answer boundary. Also report at the L1 review boundary or through structured attention.';
9
9
  /** Builds a physical launch instruction while keeping durable L1 identity canonical. */
10
10
  export function autopilotExecutorLaunchPrompt(identity) {
@@ -4,6 +4,6 @@
4
4
  * SPDX-License-Identifier: AGPL-3.0-or-later
5
5
  */
6
6
  import { registerAutopilotToggle } from './toggle/endpoint.js';
7
- export function registerAutopilotRoutes(app, coordinator, idempotency) {
8
- registerAutopilotToggle(app, coordinator, idempotency);
7
+ export function registerAutopilotRoutes(app, coordinator, idempotency, providerFor) {
8
+ registerAutopilotToggle(app, coordinator, idempotency, providerFor);
9
9
  }
@@ -4,13 +4,17 @@
4
4
  * SPDX-License-Identifier: AGPL-3.0-or-later
5
5
  */
6
6
  import { createHash } from 'node:crypto';
7
- export function registerAutopilotToggle(app, coordinator, idempotency) {
7
+ export function registerAutopilotToggle(app, coordinator, idempotency, providerFor) {
8
8
  app.put('/api/sessions/:id/autopilot', async (request, reply) => {
9
9
  const enabled = request.body?.enabled;
10
10
  if (typeof enabled !== 'boolean')
11
11
  return reply.code(400).send({ code: 'AUTOPILOT_ENABLED_REQUIRED' });
12
12
  const key = request.headers['idempotency-key'];
13
13
  const sessionId = request.params.id;
14
+ // Autopilot orchestration (executors, plan measurement, wait leases) is
15
+ // codex-only; kimi sessions must not toggle it at all, in either direction.
16
+ if (providerFor?.(sessionId) === 'kimi')
17
+ return reply.code(409).send({ code: 'AUTOPILOT_PROVIDER_UNSUPPORTED' });
14
18
  const scope = `autopilot-toggle:${sessionId}`;
15
19
  const fingerprint = createHash('sha256').update(String(enabled)).digest('hex');
16
20
  if (typeof key === 'string' && key && idempotency) {
@@ -4,15 +4,16 @@
4
4
  * SPDX-License-Identifier: AGPL-3.0-or-later
5
5
  */
6
6
  export async function getBootstrap(deps) {
7
- const [workspaces, profiles, models] = await Promise.all([
7
+ const [workspaces, profiles, codexModels, kimiModels] = await Promise.all([
8
8
  deps.workspaces.list(),
9
9
  deps.profiles.list(),
10
- deps.models?.list().catch(() => []) ?? [],
10
+ deps.models?.list('codex').catch(() => []) ?? [],
11
+ deps.models?.list('kimi').catch(() => []) ?? [],
11
12
  ]);
12
13
  return {
13
14
  workspaces,
14
15
  profiles,
15
- models,
16
+ models: { codex: codexModels, kimi: kimiModels },
16
17
  sessions: deps.sessions.list(),
17
18
  versions: deps.versions ?? [],
18
19
  capabilities: {
@@ -20,6 +21,7 @@ export async function getBootstrap(deps) {
20
21
  userInput: true,
21
22
  git: true,
22
23
  protocolCompatible: deps.protocolCompatible,
24
+ providers: deps.providers,
23
25
  },
24
26
  };
25
27
  }
@@ -201,9 +201,10 @@ function measurementFor(heading) {
201
201
  if (value === undefined)
202
202
  continue;
203
203
  if (property.endsWith('_AT')) {
204
- if (!isUtcIsoInstant(value))
204
+ const instant = normalizeUtcIsoInstant(value);
205
+ if (!instant)
205
206
  return null;
206
- measurement[field] = value;
207
+ measurement[field] = instant;
207
208
  continue;
208
209
  }
209
210
  if (!/^\d+$/.test(value))
@@ -215,14 +216,38 @@ function measurementFor(heading) {
215
216
  }
216
217
  return Object.keys(measurement).length === 0 ? undefined : measurement;
217
218
  }
218
- function isUtcIsoInstant(value) {
219
- if (!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{3})?Z$/.test(value))
220
- return false;
219
+ function normalizeUtcIsoInstant(value) {
220
+ const match = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.(\d{3}))?(Z|[+-]\d{2}:\d{2})$/.exec(value);
221
+ if (!match)
222
+ return null;
223
+ const [, yearValue, monthValue, dayValue, hourValue, minuteValue, secondValue, , zone] = match;
224
+ const [year, month, day, hour, minute, second] = [
225
+ yearValue,
226
+ monthValue,
227
+ dayValue,
228
+ hourValue,
229
+ minuteValue,
230
+ secondValue,
231
+ ].map(Number);
232
+ const offset = zone === 'Z' ? null : zone.slice(1).split(':').map(Number);
233
+ if (year < 1 ||
234
+ month < 1 ||
235
+ month > 12 ||
236
+ day < 1 ||
237
+ day > new Date(Date.UTC(year, month, 0)).getUTCDate() ||
238
+ hour > 23 ||
239
+ minute > 59 ||
240
+ second > 59 ||
241
+ (offset && (offset[0] > 23 || offset[1] > 59)))
242
+ return null;
221
243
  const instant = new Date(value);
222
244
  if (Number.isNaN(instant.getTime()))
223
- return false;
224
- const canonical = instant.toISOString();
225
- return value === canonical || value === canonical.replace('.000Z', 'Z');
245
+ return null;
246
+ if (zone === 'Z') {
247
+ const canonical = instant.toISOString();
248
+ return value === canonical || value === canonical.replace('.000Z', 'Z') ? value : null;
249
+ }
250
+ return instant.toISOString().replace('.000Z', 'Z');
226
251
  }
227
252
  function descriptionFor(heading) {
228
253
  const fields = heading.level === 1 ? ['Effort', 'Goal', 'Notes'] : ['Why', 'Change', 'Tests', 'Done when'];
@@ -27,7 +27,7 @@ export function registerGetSession(app, find, activity, autopilot, plan) {
27
27
  pendingAttention: Boolean(session.pendingInteractions?.length),
28
28
  observedAt: new Date().toISOString(),
29
29
  }),
30
- resumeCommand: session.threadId ? buildResumeCommand(session) : null,
30
+ resumeCommand: session.threadId && session.provider !== 'kimi' ? buildResumeCommand(session) : null,
31
31
  });
32
32
  });
33
33
  }