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,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
+ export function appendUserMessage(messages, id, text, occurredAt) {
7
+ return [
8
+ ...messages,
9
+ { id, role: 'user', text, ...(occurredAt ? { occurredAt } : {}), complete: true },
10
+ ];
11
+ }
12
+ export function applyDelta(messages, id, text, occurredAt) {
13
+ const previous = messages.find((message) => message.id === id);
14
+ return previous
15
+ ? messages.map((message) => message.id === id ? { ...message, text: message.text + text } : message)
16
+ : [
17
+ ...messages,
18
+ {
19
+ id,
20
+ role: 'assistant',
21
+ phase: 'commentary',
22
+ text,
23
+ ...(occurredAt ? { occurredAt } : {}),
24
+ complete: false,
25
+ },
26
+ ];
27
+ }
28
+ export function completeMessage(messages, id) {
29
+ return messages.map((message) => (message.id === id ? { ...message, complete: true } : message));
30
+ }
@@ -46,7 +46,7 @@ export async function buildApp(deps) {
46
46
  registerGetBootstrap(app, deps.bootstrap);
47
47
  registerSessionRoutes(app, deps);
48
48
  if (deps.autopilot)
49
- registerAutopilotRoutes(app, deps.autopilot, deps.sessionRoutes?.idempotency);
49
+ registerAutopilotRoutes(app, deps.autopilot, deps.sessionRoutes?.idempotency, deps.sessionRoutes ? (id) => deps.sessionRoutes?.find(id)?.provider : undefined);
50
50
  if (deps.orgPlanAttention)
51
51
  registerOrgPlanAttentionRoutes(app, deps.orgPlanAttention);
52
52
  registerPlanRoutes(app, deps);
@@ -98,6 +98,12 @@ export async function probeCodexVersion() {
98
98
  .then(({ stdout }) => stdout.trim() || null)
99
99
  .catch(() => null);
100
100
  }
101
+ /** Detects the kimi CLI exactly like the codex probe; absence is a soft status. */
102
+ export async function probeKimiVersion() {
103
+ return runFile('kimi', ['--version'])
104
+ .then(({ stdout }) => stdout.trim() || null)
105
+ .catch(() => null);
106
+ }
101
107
  export function installShutdownHandlers(app, signalSource = process) {
102
108
  let closing;
103
109
  const shutdown = () => {
@@ -205,6 +211,7 @@ export async function runCli(dependencies = {}) {
205
211
  if (!diagnosis.ok)
206
212
  stderr.write('WARNING: Startup diagnostics reported a problem; Gestalt Mobile will continue so it can show recovery controls.\n');
207
213
  const installedCodexVersion = await (dependencies.probeCodexVersion ?? probeCodexVersion)();
214
+ const installedKimiVersion = await (dependencies.probeKimiVersion ?? probeKimiVersion)();
208
215
  const mobileVersion = await packageVersion(moduleUrl);
209
216
  const app = await (dependencies.compose ?? composeRelayApp)({
210
217
  root: config.root,
@@ -214,6 +221,7 @@ export async function runCli(dependencies = {}) {
214
221
  staticDir: packagedClientDir(moduleUrl),
215
222
  profiles: new LauncherProfileCatalog(),
216
223
  installedCodexVersion,
224
+ installedKimiVersion,
217
225
  componentVersions: componentVersions(mobileVersion, installedCodexVersion, dependencies.environment),
218
226
  startAppServers: true,
219
227
  homeDirectory: dependencies.homeDirectory,
@@ -21,6 +21,7 @@ import { workspaceId } from './platform/catalog/workspace-id.js';
21
21
  import { planStatusDirectoryPath, planStatusFilePath, } from './platform/plans/filesystem-plan-status-source.js';
22
22
  import { toOrgPlanAttentionToolResponse } from '../shared/contracts/org-plan-attention.js';
23
23
  import { toOrgPlanCheckpointToolResponse } from '../shared/contracts/org-plan-checkpoint.js';
24
+ import { applyProjectionEvent, createChatProjection, } from '../client/features/chat/chat-projection.js';
24
25
  function fakeAppServer(calls) {
25
26
  return {
26
27
  rpc: {
@@ -125,7 +126,7 @@ async function createComposedSession(app) {
125
126
  const created = await app.inject({
126
127
  method: 'POST',
127
128
  url: '/api/sessions',
128
- payload: { workspaceId: workspace.id, profile: 'default' },
129
+ payload: { workspaceId: workspace.id, profile: 'default', provider: 'codex' },
129
130
  });
130
131
  expect(created.statusCode).toBe(202);
131
132
  return created.json().id;
@@ -152,13 +153,13 @@ const autopilotPlanText = (childState = 'TODO') => `#+TITLE: Autopilot fixture
152
153
  const completedAutopilotPlanText = () => autopilotPlanText('DONE')
153
154
  .replace('* WIP [#A] Parent', '* DONE [#A] Parent')
154
155
  .replace(':REVIEW_STATUS: UNREVIEWED', ':REVIEW_STATUS: REVIEWED');
155
- async function installAutopilotPlan(app, sessionId, workspacePath, name, childState = 'TODO') {
156
+ async function installAutopilotPlan(app, sessionId, workspacePath, name, childState = 'TODO', reason = 'supervision-start') {
156
157
  const planPath = join(workspacePath, `${name}.org`);
157
158
  await writeFile(planPath, autopilotPlanText(childState));
158
159
  await writeFile(planStatusFilePath(planStatusDirectoryPath(workspacePath, sessionId), planPath), JSON.stringify({
159
160
  schemaVersion: 1,
160
161
  planPath,
161
- reason: 'supervision-start',
162
+ reason,
162
163
  updatedAt: new Date().toISOString(),
163
164
  }));
164
165
  await expect
@@ -2242,6 +2243,37 @@ describe('production composition', () => {
2242
2243
  });
2243
2244
  });
2244
2245
  describeCompositionConcern('lifecycle', () => {
2246
+ it('retains an authored plan without starting supervision', async () => {
2247
+ const root = await mkdtemp(join(tmpdir(), 'gestalt-mobile-root-'));
2248
+ const dataDir = await mkdtemp(join(tmpdir(), 'gestalt-mobile-state-'));
2249
+ ownTemporaryPaths(root, dataDir);
2250
+ const workspacePath = join(root, 'workspace');
2251
+ await mkdir(workspacePath);
2252
+ const handles = [];
2253
+ const app = await composeAuthorizedApp({
2254
+ root,
2255
+ dataDir,
2256
+ relyingParty,
2257
+ installedCodexVersion: 'codex-cli 0.144.3',
2258
+ startAppServers: true,
2259
+ launchAppServer: liveAppServer(handles),
2260
+ profiles: {
2261
+ list: async () => [],
2262
+ require: async () => ({
2263
+ name: 'default',
2264
+ state: 'ok',
2265
+ status: 'ready',
2266
+ }),
2267
+ },
2268
+ });
2269
+ const sessionId = await createComposedSession(app);
2270
+ await installAutopilotPlan(app, sessionId, workspacePath, 'authored-only', 'TODO', 'authoring-start');
2271
+ expect((await app.inject(`/api/sessions/${sessionId}`)).json()).toMatchObject({
2272
+ autopilot: { enabled: false, state: 'disabled' },
2273
+ });
2274
+ expect(handles.flatMap((handle) => handle.requests.filter((request) => request.method === 'turn/start'))).toHaveLength(0);
2275
+ await app.close();
2276
+ });
2245
2277
  it('rejects a wait lease when automatic continuation is unavailable', async () => {
2246
2278
  const root = await mkdtemp(join(tmpdir(), 'gestalt-mobile-root-'));
2247
2279
  const dataDir = await mkdtemp(join(tmpdir(), 'gestalt-mobile-state-'));
@@ -2649,6 +2681,7 @@ describe('production composition', () => {
2649
2681
  ['DELETE', '/api/auth/devices/:deviceId', '/api/auth/devices/device-1', 'protected'],
2650
2682
  ['GET', '/api/bootstrap', '/api/bootstrap', 'protected'],
2651
2683
  ['HEAD', '/api/bootstrap', '/api/bootstrap', 'protected'],
2684
+ ['POST', '/api/session-models/:provider', '/api/session-models/codex', 'protected'],
2652
2685
  ['POST', '/api/sessions', '/api/sessions', 'protected'],
2653
2686
  ['GET', '/api/sessions', '/api/sessions', 'protected'],
2654
2687
  ['HEAD', '/api/sessions', '/api/sessions', 'protected'],
@@ -3029,7 +3062,7 @@ describe('production composition', () => {
3029
3062
  const created = await app.inject({
3030
3063
  method: 'POST',
3031
3064
  url: '/api/sessions',
3032
- payload: { workspaceId: workspace.id, profile: 'default' },
3065
+ payload: { workspaceId: workspace.id, profile: 'default', provider: 'codex' },
3033
3066
  });
3034
3067
  expect(created.statusCode).toBe(202);
3035
3068
  const restored = await app.inject({
@@ -3042,6 +3075,156 @@ describe('production composition', () => {
3042
3075
  });
3043
3076
  await app.close();
3044
3077
  });
3078
+ it('keeps cold bootstrap passive while explicit Kimi session creation resolves a Kimi model', async () => {
3079
+ const root = await mkdtemp(join(tmpdir(), 'gestalt-mobile-root-'));
3080
+ const dataDir = await mkdtemp(join(tmpdir(), 'gestalt-mobile-state-'));
3081
+ ownTemporaryPaths(root, dataDir);
3082
+ await mkdir(join(root, 'workspace'));
3083
+ const sessionModelCalls = [];
3084
+ const app = await composeAuthorizedApp({
3085
+ root,
3086
+ dataDir,
3087
+ relyingParty,
3088
+ installedCodexVersion: null,
3089
+ installedKimiVersion: '2.0.2',
3090
+ launchAppServer: () => fakeAppServer([]),
3091
+ sessionModelCatalog: {
3092
+ list: async (provider) => {
3093
+ sessionModelCalls.push(provider);
3094
+ return provider === 'kimi' ? ['k2-thinking'] : ['gpt-5.6-terra'];
3095
+ },
3096
+ },
3097
+ profiles: {
3098
+ list: async () => [],
3099
+ require: async () => {
3100
+ throw new Error('CODEX_LAUNCHER_UNAVAILABLE');
3101
+ },
3102
+ },
3103
+ });
3104
+ const bootstrap = await app.inject('/api/bootstrap');
3105
+ const workspace = bootstrap
3106
+ .json()
3107
+ .workspaces[0]?.children.find((item) => item.name === 'workspace');
3108
+ expect(bootstrap.json().models.kimi).toEqual([]);
3109
+ expect(sessionModelCalls).toEqual([]);
3110
+ const created = await app.inject({
3111
+ method: 'POST',
3112
+ url: '/api/sessions',
3113
+ payload: { workspaceId: workspace.id, profile: 'default', provider: 'kimi' },
3114
+ });
3115
+ expect(created.statusCode).toBe(202);
3116
+ expect(created.json()).toMatchObject({ provider: 'kimi', model: 'k2-thinking' });
3117
+ expect(sessionModelCalls).toEqual(['kimi']);
3118
+ await app.close();
3119
+ });
3120
+ it('keeps real server listen passive when Kimi is installed', async () => {
3121
+ const root = await mkdtemp(join(tmpdir(), 'gestalt-mobile-root-'));
3122
+ const dataDir = await mkdtemp(join(tmpdir(), 'gestalt-mobile-state-'));
3123
+ ownTemporaryPaths(root, dataDir);
3124
+ await mkdir(join(root, 'workspace'));
3125
+ const ensure = vi.fn(async () => {
3126
+ throw new Error('passive startup must not ensure Kimi');
3127
+ });
3128
+ const app = await composeAuthorizedApp({
3129
+ root,
3130
+ dataDir,
3131
+ relyingParty,
3132
+ installedCodexVersion: null,
3133
+ installedKimiVersion: '2.0.2',
3134
+ startAppServers: true,
3135
+ kimiServerManager: {
3136
+ ensure,
3137
+ get: () => null,
3138
+ list: () => [],
3139
+ stopAll: async () => { },
3140
+ },
3141
+ profiles: {
3142
+ list: async () => [],
3143
+ require: async () => {
3144
+ throw new Error('CODEX_LAUNCHER_UNAVAILABLE');
3145
+ },
3146
+ },
3147
+ });
3148
+ await app.listen({ host: '127.0.0.1', port: 0 });
3149
+ expect(ensure).not.toHaveBeenCalled();
3150
+ await app.close();
3151
+ });
3152
+ it('delivers a realistic Codex answer notification through the journal to the chat projection', async () => {
3153
+ const root = await mkdtemp(join(tmpdir(), 'gestalt-mobile-root-'));
3154
+ const dataDir = await mkdtemp(join(tmpdir(), 'gestalt-mobile-state-'));
3155
+ ownTemporaryPaths(root, dataDir);
3156
+ await mkdir(join(root, 'workspace'));
3157
+ const handles = [];
3158
+ const app = await composeAuthorizedApp({
3159
+ root,
3160
+ dataDir,
3161
+ relyingParty,
3162
+ installedCodexVersion: 'codex-cli 0.144.3',
3163
+ startAppServers: true,
3164
+ launchAppServer: liveAppServer(handles),
3165
+ profiles: {
3166
+ list: async () => [],
3167
+ require: async () => ({
3168
+ name: 'default',
3169
+ state: 'ok',
3170
+ status: 'ready',
3171
+ }),
3172
+ },
3173
+ });
3174
+ const sessionId = await createComposedSession(app);
3175
+ const handle = handles.find((candidate) => candidate.calls.includes('thread/start'));
3176
+ const started = await app.inject({
3177
+ method: 'POST',
3178
+ url: `/api/sessions/${sessionId}/turns`,
3179
+ payload: { text: 'give the answer' },
3180
+ });
3181
+ expect(started.statusCode).toBe(202);
3182
+ const turnId = started.json().activeTurnId;
3183
+ const threadId = (await app.inject(`/api/sessions/${sessionId}`)).json().threadId;
3184
+ await app.listen({ host: '127.0.0.1', port: 0 });
3185
+ const address = app.server.address();
3186
+ if (!address || typeof address === 'string')
3187
+ throw new Error('Expected TCP listener');
3188
+ const socket = new WebSocket(`ws://127.0.0.1:${address.port}/api/sessions/${sessionId}/events?after=0`, {
3189
+ headers: {
3190
+ origin: relyingParty.publicOrigin,
3191
+ cookie: 'gestalt_mobile_session=test-session',
3192
+ },
3193
+ });
3194
+ const messages = [];
3195
+ socket.on('message', (data) => messages.push(JSON.parse(String(data))));
3196
+ await once(socket, 'open');
3197
+ handle.notify({
3198
+ method: 'item/completed',
3199
+ params: {
3200
+ threadId,
3201
+ turnId,
3202
+ item: {
3203
+ id: 'answer-1',
3204
+ type: 'agentMessage',
3205
+ phase: 'final_answer',
3206
+ text: 'The provider boundary is intact.',
3207
+ },
3208
+ },
3209
+ });
3210
+ await vi.waitFor(() => expect(messages.some((message) => message.event.type === 'agentMessageCompleted')).toBe(true));
3211
+ const projection = messages
3212
+ .map((message) => message.event)
3213
+ .sort((left, right) => left.sequence - right.sequence)
3214
+ .reduce((current, event) => applyProjectionEvent(current, event), createChatProjection(sessionId));
3215
+ expect(projection.messages).toEqual([
3216
+ expect.objectContaining({
3217
+ id: 'assistant:answer-1',
3218
+ role: 'assistant',
3219
+ text: 'The provider boundary is intact.',
3220
+ phase: 'final_answer',
3221
+ complete: true,
3222
+ turnId,
3223
+ }),
3224
+ ]);
3225
+ socket.close();
3226
+ await app.close();
3227
+ });
3045
3228
  it('hands a replayed session-owned plan replacement off to its live close event exactly once', async () => {
3046
3229
  const root = await mkdtemp(join(tmpdir(), 'gestalt-mobile-root-'));
3047
3230
  const dataDir = await mkdtemp(join(tmpdir(), 'gestalt-mobile-state-'));
@@ -3079,7 +3262,7 @@ describe('production composition', () => {
3079
3262
  const created = await app.inject({
3080
3263
  method: 'POST',
3081
3264
  url: '/api/sessions',
3082
- payload: { workspaceId: workspace.id, profile: 'default' },
3265
+ payload: { workspaceId: workspace.id, profile: 'default', provider: 'codex' },
3083
3266
  });
3084
3267
  const sessionId = created.json().id;
3085
3268
  await writeFile(planStatusFilePath(planStatusDirectoryPath(workspacePath, sessionId), planPath), JSON.stringify({
@@ -3203,7 +3386,7 @@ describe('production composition', () => {
3203
3386
  const created = await first.inject({
3204
3387
  method: 'POST',
3205
3388
  url: '/api/sessions',
3206
- payload: { workspaceId: workspace.id, profile: 'default' },
3389
+ payload: { workspaceId: workspace.id, profile: 'default', provider: 'codex' },
3207
3390
  });
3208
3391
  expect(created.statusCode).toBe(202);
3209
3392
  expect(firstCalls).toEqual([
@@ -3285,7 +3468,7 @@ describe('production composition', () => {
3285
3468
  await app.inject({
3286
3469
  method: 'POST',
3287
3470
  url: '/api/sessions',
3288
- payload: { workspaceId: workspace.id, profile: 'default' },
3471
+ payload: { workspaceId: workspace.id, profile: 'default', provider: 'codex' },
3289
3472
  });
3290
3473
  await app.close();
3291
3474
  // Model and skill catalogs run for bootstrap and session start; the active child closes with the relay.
@@ -3356,7 +3539,7 @@ describe('production composition', () => {
3356
3539
  const created = await app.inject({
3357
3540
  method: 'POST',
3358
3541
  url: '/api/sessions',
3359
- payload: { workspaceId: workspace.id, profile: 'default' },
3542
+ payload: { workspaceId: workspace.id, profile: 'default', provider: 'codex' },
3360
3543
  });
3361
3544
  const sessionId = created.json().id;
3362
3545
  const handle = handles.find((candidate) => candidate.calls.includes('thread/start'));