gestalt-mobile 0.12.0 → 0.13.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 (35) hide show
  1. package/dist/client/assets/index-D2sT9ZHY.js +19 -0
  2. package/dist/client/index.html +1 -1
  3. package/dist/server/server/app.js +22 -227
  4. package/dist/server/server/composition.js +0 -5
  5. package/dist/server/server/features/auth/application/ceremony-attempts.js +22 -0
  6. package/dist/server/server/features/auth/domain/errors.js +7 -0
  7. package/dist/server/server/features/auth/http/cookies.js +10 -0
  8. package/dist/server/server/features/auth/login/options/endpoint.js +22 -20
  9. package/dist/server/server/features/auth/login/verify/endpoint.js +6 -14
  10. package/dist/server/server/features/auth/logout/endpoint.js +2 -6
  11. package/dist/server/server/features/auth/register/options/endpoint.js +43 -11
  12. package/dist/server/server/features/auth/register/verify/endpoint.js +4 -3
  13. package/dist/server/server/features/auth/register-routes.js +41 -0
  14. package/dist/server/server/features/git/checkout-branch/endpoint.js +1 -1
  15. package/dist/server/server/features/git/clone-repository/endpoint.js +1 -1
  16. package/dist/server/server/features/git/register-routes.js +34 -0
  17. package/dist/server/server/features/plans/register-routes.js +22 -0
  18. package/dist/server/server/features/sessions/promote-recent-thread/endpoint.js +1 -1
  19. package/dist/server/server/features/sessions/register-routes.js +138 -0
  20. package/dist/server/server/features/sessions/respond-interaction/endpoint.js +1 -1
  21. package/dist/server/server/features/sessions/select-model/endpoint.js +1 -1
  22. package/dist/server/server/features/sessions/start-session/endpoint.js +5 -2
  23. package/dist/server/server/features/sessions/start-turn/endpoint.js +2 -1
  24. package/dist/server/server/features/skills/register-routes.js +17 -0
  25. package/dist/server/server/features/skills/replace-profile/endpoint.js +20 -5
  26. package/dist/server/server/platform/auth/sqlite-authorization-store.js +29 -6
  27. package/dist/server/server/platform/codex/normalizer.js +32 -6
  28. package/dist/server/server/platform/codex/protocol-compatibility.js +19 -1
  29. package/dist/server/server/platform/codex/server-request.js +2 -0
  30. package/dist/server/server/platform/codex/session-runtime.js +193 -125
  31. package/dist/server/server/platform/http/problem-handler.js +10 -0
  32. package/dist/server/server/platform/http/problem-reply.js +10 -0
  33. package/package.json +2 -1
  34. package/dist/client/assets/index-B7KwEjTE.js +0 -19
  35. package/dist/server/server/features/plans/application/native-plan-alignment.js +0 -39
@@ -0,0 +1,34 @@
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 { registerCheckoutBranch } from './checkout-branch/endpoint.js';
7
+ import { registerCloneRepository } from './clone-repository/endpoint.js';
8
+ import { registerGetGitSummary } from './get-summary/endpoint.js';
9
+ import { registerPullRebase } from './pull-rebase/endpoint.js';
10
+ import { registerPushUpstream } from './push-upstream/endpoint.js';
11
+ import { registerRefreshGit } from './refresh/endpoint.js';
12
+ export function registerGitRoutes(app, deps) {
13
+ const git = deps.gitSummary;
14
+ if (!git)
15
+ return;
16
+ registerGetGitSummary(app, { workspaces: git.workspaces, inspect: git.inspect });
17
+ registerPushUpstream(app, {
18
+ workspaces: git.workspaces,
19
+ inspect: git.inspectForPush ?? git.inspect,
20
+ push: git.push,
21
+ idempotency: git.idempotency,
22
+ });
23
+ registerRefreshGit(app, {
24
+ workspaces: git.workspaces,
25
+ refresh: git.refresh,
26
+ idempotency: git.idempotency,
27
+ });
28
+ if (git.pull && git.checkout) {
29
+ registerPullRebase(app, { workspaces: git.workspaces, pull: git.pull });
30
+ registerCheckoutBranch(app, { workspaces: git.workspaces, checkout: git.checkout });
31
+ }
32
+ if (git.clone)
33
+ registerCloneRepository(app, { workspaces: git.workspaces, clone: git.clone });
34
+ }
@@ -0,0 +1,22 @@
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 { registerClosePlan } from './close-plan/endpoint.js';
7
+ import { registerGetPlanMeasurement } from './get-measurement/endpoint.js';
8
+ import { registerGetPlan } from './get-plan/endpoint.js';
9
+ import { registerGetWorkspacePlan } from './get-workspace-plan/endpoint.js';
10
+ import { registerListWorkspacePlans } from './list-workspace-plans/endpoint.js';
11
+ export function registerPlanRoutes(app, deps) {
12
+ if (deps.planRoutes) {
13
+ registerGetPlan(app, deps.planRoutes);
14
+ registerClosePlan(app, deps.planRoutes);
15
+ }
16
+ if (deps.workspacePlanRoutes) {
17
+ registerListWorkspacePlans(app, deps.workspacePlanRoutes);
18
+ registerGetWorkspacePlan(app, deps.workspacePlanRoutes);
19
+ }
20
+ if (deps.planMeasurementRoutes)
21
+ registerGetPlanMeasurement(app, deps.planMeasurementRoutes);
22
+ }
@@ -9,7 +9,7 @@ const requestSchema = z.object({
9
9
  cwd: z.string().startsWith('/'),
10
10
  });
11
11
  export function registerPromoteRecentThread(app, deps) {
12
- app.post('/api/sessions/recent-threads/open', async (request, reply) => {
12
+ app.post('/api/sessions/recent-threads/open', { bodyLimit: 8 * 1024 }, async (request, reply) => {
13
13
  const parsed = requestSchema.safeParse(request.body);
14
14
  if (!parsed.success)
15
15
  return reply.code(400).send({ code: 'INVALID_RECENT_THREAD' });
@@ -0,0 +1,138 @@
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 { authorizationSessionDevice } from '../../platform/http/authorization-boundary.js';
7
+ import { stopSession } from './lifecycle/use-case.js';
8
+ import { registerForgetSession } from './forget-session/endpoint.js';
9
+ import { registerGetHistory } from './get-history/endpoint.js';
10
+ import { registerGetSession } from './get-session/endpoint.js';
11
+ import { registerInterruptTurn } from './interrupt-turn/endpoint.js';
12
+ import { registerListRecentThreads } from './list-recent-threads/endpoint.js';
13
+ import { registerListSessions } from './list-sessions/endpoint.js';
14
+ import { registerPromoteRecentThread } from './promote-recent-thread/endpoint.js';
15
+ import { registerReleaseSession } from './release-session/endpoint.js';
16
+ import { registerRestoreSession } from './restore-session/endpoint.js';
17
+ import { registerRespondInteraction } from './respond-interaction/endpoint.js';
18
+ import { registerSelectModel } from './select-model/endpoint.js';
19
+ import { registerSessionEvents } from './session-events/endpoint.js';
20
+ import { registerStartSession } from './start-session/endpoint.js';
21
+ import { registerStartTurn } from './start-turn/endpoint.js';
22
+ import { registerStopSession } from './stop-session/endpoint.js';
23
+ export function registerSessionRoutes(app, deps) {
24
+ const sessions = deps.sessionRoutes;
25
+ if (deps.recentThreads) {
26
+ registerListRecentThreads(app, {
27
+ ...deps.recentThreads,
28
+ metadata: (threadId) => {
29
+ const session = sessions?.list?.().find((candidate) => candidate.threadId === threadId);
30
+ if (!session)
31
+ return null;
32
+ return {
33
+ ...(session.model === undefined ? {} : { model: session.model }),
34
+ ...(session.effectiveSkillSelection?.selectedProfileName === undefined
35
+ ? {}
36
+ : { skillProfile: session.effectiveSkillSelection.selectedProfileName }),
37
+ ...(session.lastOrgPlan === undefined
38
+ ? {}
39
+ : { orgPlanFilename: session.lastOrgPlan.filename }),
40
+ };
41
+ },
42
+ });
43
+ }
44
+ if (sessions) {
45
+ registerStartSession(app, {
46
+ ...sessions,
47
+ reportFailure: (operation, error) => deps.logger.error(`${operation} failed: ${safeErrorLabel(error)}`),
48
+ });
49
+ registerGetSession(app, sessions.find);
50
+ if (sessions.list)
51
+ registerListSessions(app, { list: sessions.list });
52
+ if (sessions.readHistory)
53
+ registerGetHistory(app, {
54
+ find: sessions.find,
55
+ read: sessions.readHistory,
56
+ currentSequence: sessions.currentSequence ?? (() => 0),
57
+ });
58
+ if (sessions.startTurn)
59
+ registerStartTurn(app, {
60
+ find: sessions.find,
61
+ start: sessions.startTurn,
62
+ save: sessions.save,
63
+ onStarted: sessions.onTurnStarted,
64
+ });
65
+ if (sessions.models)
66
+ registerSelectModel(app, {
67
+ find: sessions.find,
68
+ models: () => sessions.models.list(),
69
+ now: sessions.now,
70
+ save: sessions.save,
71
+ });
72
+ if (sessions.interruptTurn)
73
+ registerInterruptTurn(app, { find: sessions.find, interrupt: sessions.interruptTurn });
74
+ if (sessions.restore)
75
+ registerRestoreSession(app, {
76
+ find: sessions.find,
77
+ restore: sessions.restore,
78
+ save: sessions.save,
79
+ idempotency: sessions.idempotency,
80
+ });
81
+ if (deps.recentThreads && sessions.promoteRecent)
82
+ registerPromoteRecentThread(app, {
83
+ list: () => deps.recentThreads.list(),
84
+ promote: sessions.promoteRecent,
85
+ });
86
+ if (sessions.release && sessions.close)
87
+ registerReleaseSession(app, {
88
+ find: sessions.find,
89
+ release: sessions.release,
90
+ save: sessions.save,
91
+ close: sessions.close,
92
+ idempotency: sessions.idempotency,
93
+ });
94
+ if (sessions.remove && sessions.close)
95
+ registerForgetSession(app, {
96
+ find: sessions.find,
97
+ close: sessions.close,
98
+ remove: sessions.remove,
99
+ });
100
+ if (sessions.close)
101
+ registerStopSession(app, {
102
+ find: sessions.find,
103
+ stop: (session) => stopSession(session, sessions.now()),
104
+ save: sessions.save,
105
+ close: sessions.close,
106
+ idempotency: sessions.idempotency,
107
+ });
108
+ if (sessions.replyInteraction && deps.interactions)
109
+ registerRespondInteraction(app, {
110
+ exists: (id) => sessions.find(id) !== null,
111
+ resolve: (sessionId, requestId, resolvedAt) => deps.interactions.resolve(sessionId, requestId, resolvedAt),
112
+ validate: deps.interactions.validate,
113
+ reply: sessions.replyInteraction,
114
+ resolved: sessions.interactionResolved,
115
+ now: sessions.now,
116
+ });
117
+ }
118
+ if (deps.sessionEvents)
119
+ registerSessionEvents(app, {
120
+ ...deps.sessionEvents,
121
+ ...(deps.auth
122
+ ? {
123
+ publicOrigin: deps.auth.relyingParty.publicOrigin,
124
+ authorized: (cookieHeader) => authorizationSessionDevice(cookieHeader, deps.auth) !== null,
125
+ }
126
+ : {}),
127
+ });
128
+ }
129
+ function safeErrorLabel(error) {
130
+ if (typeof error === 'object' && error !== null && 'code' in error) {
131
+ const code = error.code;
132
+ if (typeof code === 'string' && /^[A-Z0-9_]+$/.test(code))
133
+ return code;
134
+ }
135
+ if (error instanceof Error && /^[A-Z0-9_]+$/.test(error.message))
136
+ return error.message;
137
+ return error instanceof Error ? error.name : 'UNKNOWN_ERROR';
138
+ }
@@ -4,7 +4,7 @@
4
4
  * SPDX-License-Identifier: AGPL-3.0-or-later
5
5
  */
6
6
  export function registerRespondInteraction(app, deps) {
7
- app.post('/api/sessions/:id/interactions/:requestId', async (request, reply) => {
7
+ app.post('/api/sessions/:id/interactions/:requestId', { bodyLimit: 64 * 1024 }, async (request, reply) => {
8
8
  const { id, requestId } = request.params;
9
9
  if (!deps.exists(id))
10
10
  return reply.code(404).send({ code: 'SESSION_NOT_FOUND' });
@@ -5,7 +5,7 @@
5
5
  */
6
6
  import { RelaySession } from '../model/relay-session.js';
7
7
  export function registerSelectModel(app, deps) {
8
- app.post('/api/sessions/:id/model', async (request, reply) => {
8
+ app.post('/api/sessions/:id/model', { bodyLimit: 4 * 1024 }, async (request, reply) => {
9
9
  const session = deps.find(request.params.id);
10
10
  if (!session)
11
11
  return reply.code(404).send({ code: 'SESSION_NOT_FOUND' });
@@ -9,7 +9,7 @@ import { startSession } from './use-case.js';
9
9
  import { SkillProfileError } from '../../skills/model/errors.js';
10
10
  import { problem } from '../../../platform/http/problem.js';
11
11
  export function registerStartSession(app, deps) {
12
- app.post('/api/sessions', async (request, reply) => {
12
+ app.post('/api/sessions', { bodyLimit: 4 * 1024 }, async (request, reply) => {
13
13
  const key = idempotencyKey(request.headers);
14
14
  const prior = key ? deps.idempotency?.get('start-session', key) : null;
15
15
  if (prior)
@@ -31,7 +31,10 @@ export function registerStartSession(app, deps) {
31
31
  catch (error) {
32
32
  deps.reportFailure?.('start-session', error);
33
33
  if (error instanceof SkillProfileError)
34
- return reply.code(400).type('application/problem+json').send(problem(error.code, 400, error.code === 'UNKNOWN_SKILL_PROFILE'
34
+ return reply
35
+ .code(400)
36
+ .type('application/problem+json')
37
+ .send(problem(error.code, 400, error.code === 'UNKNOWN_SKILL_PROFILE'
35
38
  ? 'The selected skill profile does not exist.'
36
39
  : 'The selected skill profile is invalid.'));
37
40
  throw error;
@@ -4,7 +4,8 @@
4
4
  * SPDX-License-Identifier: AGPL-3.0-or-later
5
5
  */
6
6
  export function registerStartTurn(app, deps) {
7
- app.post('/api/sessions/:id/turns', async (request, reply) => {
7
+ // Text is capped at 100,000 characters; leave room for JSON encoding only.
8
+ app.post('/api/sessions/:id/turns', { bodyLimit: 128 * 1024 }, async (request, reply) => {
8
9
  const session = deps.find(request.params.id);
9
10
  if (!session)
10
11
  return reply.code(404).send({ code: 'SESSION_NOT_FOUND' });
@@ -0,0 +1,17 @@
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 { registerDeleteSkillProfile } from './delete-profile/endpoint.js';
7
+ import { registerListAvailableSkills } from './list-available/endpoint.js';
8
+ import { registerListSkillProfiles } from './list-profiles/endpoint.js';
9
+ import { registerReplaceSkillProfile } from './replace-profile/endpoint.js';
10
+ export function registerSkillRoutes(app, deps) {
11
+ if (!deps.skills)
12
+ return;
13
+ registerListAvailableSkills(app, deps.skills);
14
+ registerListSkillProfiles(app, deps.skills);
15
+ registerReplaceSkillProfile(app, deps.skills);
16
+ registerDeleteSkillProfile(app, deps.skills);
17
+ }
@@ -7,14 +7,23 @@ import { z } from 'zod';
7
7
  import { createSkillProfile, normalizeSkillProfileName } from '../model/skill-profile.js';
8
8
  import { SkillProfileError } from '../model/errors.js';
9
9
  import { problem } from '../../../platform/http/problem.js';
10
- const requestSchema = z.object({ version: z.literal(1), name: z.string(), skills: z.array(z.object({ name: z.string(), path: z.string(), enabled: z.boolean() }).strict()) }).strict();
10
+ const requestSchema = z
11
+ .object({
12
+ version: z.literal(1),
13
+ name: z.string(),
14
+ skills: z.array(z.object({ name: z.string(), path: z.string(), enabled: z.boolean() }).strict()),
15
+ })
16
+ .strict();
11
17
  export function registerReplaceSkillProfile(app, deps) {
12
- app.put('/api/skill-profiles/:name', async (request, reply) => {
18
+ app.put('/api/skill-profiles/:name', { bodyLimit: 64 * 1024 }, async (request, reply) => {
13
19
  const body = requestSchema.safeParse(request.body);
14
20
  try {
15
21
  const name = normalizeSkillProfileName(request.params.name);
16
22
  if (!body.success || normalizeSkillProfileName(body.data.name) !== name)
17
- return reply.code(400).type('application/problem+json').send(problem('INVALID_SKILL_PROFILE', 400, 'The route and body profile names must match.'));
23
+ return reply
24
+ .code(400)
25
+ .type('application/problem+json')
26
+ .send(problem('INVALID_SKILL_PROFILE', 400, 'The route and body profile names must match.'));
18
27
  const profile = createSkillProfile(body.data);
19
28
  const existed = await deps.readGlobalProfile(name);
20
29
  await deps.replaceGlobalProfile(profile);
@@ -22,8 +31,14 @@ export function registerReplaceSkillProfile(app, deps) {
22
31
  }
23
32
  catch (error) {
24
33
  if (error instanceof SkillProfileError)
25
- return reply.code(400).type('application/problem+json').send(problem('INVALID_SKILL_PROFILE', 400, 'The skill profile could not be stored.'));
26
- return reply.code(500).type('application/problem+json').send(problem('SKILL_PROFILE_PERSISTENCE_FAILED', 500, 'The skill profile could not be stored.', true));
34
+ return reply
35
+ .code(400)
36
+ .type('application/problem+json')
37
+ .send(problem('INVALID_SKILL_PROFILE', 400, 'The skill profile could not be stored.'));
38
+ return reply
39
+ .code(500)
40
+ .type('application/problem+json')
41
+ .send(problem('SKILL_PROFILE_PERSISTENCE_FAILED', 500, 'The skill profile could not be stored.', true));
27
42
  }
28
43
  });
29
44
  }
@@ -9,6 +9,7 @@ import { dirname, join, resolve } from 'node:path';
9
9
  import { DatabaseSync } from 'node:sqlite';
10
10
  import { authorizedDeviceId, localOwnerId, passkeyCeremonyId, webAuthnCredentialId, } from '../../features/auth/domain/identifiers.js';
11
11
  import { deviceNickname } from '../../features/auth/domain/device-nickname.js';
12
+ import { CeremonyCapacityError } from '../../features/auth/domain/errors.js';
12
13
  const OWNER_ID = localOwnerId('local-owner');
13
14
  const SCHEMA_VERSION = 1;
14
15
  const SQLITE_BUSY_TIMEOUT_MS = 5_000;
@@ -234,10 +235,28 @@ CREATE TABLE IF NOT EXISTS auth_sessions (token_hash TEXT PRIMARY KEY, device_id
234
235
  throw error;
235
236
  }
236
237
  }
237
- saveCeremony(token, ceremony) {
238
- this.db
239
- .prepare('INSERT INTO auth_ceremonies (token_hash, purpose, challenge, expected_origin, rp_id, expires_at, ticket_hash) VALUES (?, ?, ?, ?, ?, ?, ?)')
240
- .run(hash(token), ceremony.purpose, ceremony.challenge, ceremony.expectedOrigin, ceremony.rpId, ceremony.expiresAt, ceremony.enrollmentTicket ? hash(ceremony.enrollmentTicket) : null);
238
+ saveCeremony(token, ceremony, now) {
239
+ this.db.exec('BEGIN IMMEDIATE');
240
+ try {
241
+ // Admission and cleanup share one immediate transaction, so separate
242
+ // processes cannot both observe the same remaining slot.
243
+ this.db.prepare('DELETE FROM auth_ceremonies WHERE consumed_at IS NOT NULL').run();
244
+ if (now)
245
+ this.db.prepare('DELETE FROM auth_ceremonies WHERE expires_at <= ?').run(now);
246
+ const active = this.db
247
+ .prepare('SELECT count(*) AS count FROM auth_ceremonies WHERE consumed_at IS NULL')
248
+ .get();
249
+ if (active.count >= 64)
250
+ throw new CeremonyCapacityError('Ceremony capacity reached');
251
+ this.db
252
+ .prepare('INSERT INTO auth_ceremonies (token_hash, purpose, challenge, expected_origin, rp_id, expires_at, ticket_hash) VALUES (?, ?, ?, ?, ?, ?, ?)')
253
+ .run(hash(token), ceremony.purpose, ceremony.challenge, ceremony.expectedOrigin, ceremony.rpId, ceremony.expiresAt, ceremony.enrollmentTicket ? hash(ceremony.enrollmentTicket) : null);
254
+ this.db.exec('COMMIT');
255
+ }
256
+ catch (error) {
257
+ this.db.exec('ROLLBACK');
258
+ throw error;
259
+ }
241
260
  }
242
261
  consumeCeremony(token, now) {
243
262
  this.db.exec('BEGIN IMMEDIATE');
@@ -286,8 +305,12 @@ CREATE TABLE IF NOT EXISTS auth_sessions (token_hash TEXT PRIMARY KEY, device_id
286
305
  this.db.exec('BEGIN IMMEDIATE');
287
306
  try {
288
307
  const creator = hash(creatorSession);
289
- this.db.prepare('DELETE FROM auth_tickets WHERE creator_session_hash = ? AND consumed_at IS NULL').run(creator);
290
- this.db.prepare('INSERT INTO auth_tickets (token_hash, expires_at, consumed_at, creator_session_hash) VALUES (?, ?, NULL, ?)').run(hash(token), expiresAt, creator);
308
+ this.db
309
+ .prepare('DELETE FROM auth_tickets WHERE creator_session_hash = ? AND consumed_at IS NULL')
310
+ .run(creator);
311
+ this.db
312
+ .prepare('INSERT INTO auth_tickets (token_hash, expires_at, consumed_at, creator_session_hash) VALUES (?, ?, NULL, ?)')
313
+ .run(hash(token), expiresAt, creator);
291
314
  this.db.exec('COMMIT');
292
315
  }
293
316
  catch (error) {
@@ -5,29 +5,52 @@
5
5
  */
6
6
  import { relative } from 'node:path';
7
7
  export function normalizeCodexNotification(sessionId, sequence, occurredAt, notification, workspacePath) {
8
- if (notification.method === 'item/agentMessage/delta')
8
+ const decoded = decodeNotification(notification);
9
+ if (!decoded)
10
+ return null;
11
+ if (decoded.method === 'item/agentMessage/delta')
9
12
  return {
10
13
  sessionId,
11
14
  sequence,
12
15
  occurredAt,
13
16
  type: 'agentMessageDelta',
14
- payload: { text: notification.params?.delta ?? '' },
17
+ payload: { text: decoded.params.delta },
15
18
  };
16
- if (notification.method === 'turn/completed')
19
+ if (decoded.method === 'turn/completed')
17
20
  return {
18
21
  sessionId,
19
22
  sequence,
20
23
  occurredAt,
21
24
  type: 'turnCompleted',
22
- payload: notification.params ?? {},
25
+ payload: decoded.params,
23
26
  };
24
- if (notification.method === 'item/started' || notification.method === 'item/completed') {
25
- const activity = safeActivity(notification.params?.item, workspacePath);
27
+ if (decoded.method === 'item/started' || decoded.method === 'item/completed') {
28
+ const activity = safeActivity(decoded.params.item, workspacePath);
26
29
  if (activity)
27
30
  return { sessionId, sequence, occurredAt, type: 'activity.updated', payload: activity };
28
31
  }
29
32
  return null;
30
33
  }
34
+ /** Strictly decode only consumed notification shapes; unknown/future methods stay isolated. */
35
+ export function decodeNotification(input) {
36
+ if (input.method === 'item/agentMessage/delta') {
37
+ const params = record(input.params);
38
+ return typeof params?.delta === 'string' && params.delta.length <= 64_000
39
+ ? { method: input.method, params: { delta: params.delta } } : null;
40
+ }
41
+ if (input.method === 'turn/completed') {
42
+ const params = record(input.params);
43
+ const turn = record(params?.turn);
44
+ if (!turn || typeof turn.id !== 'string' || turn.id.length > 256)
45
+ return null;
46
+ return { method: input.method, params: { ...(typeof params?.threadId === 'string' && params.threadId.length <= 256 ? { threadId: params.threadId } : {}), turn: { id: turn.id, ...(typeof turn.status === 'string' && turn.status.length <= 64 ? { status: turn.status } : {}) } } };
47
+ }
48
+ if (input.method === 'item/started' || input.method === 'item/completed') {
49
+ const params = record(input.params);
50
+ return params && 'item' in params ? { method: input.method, params: { item: params.item } } : null;
51
+ }
52
+ return null;
53
+ }
31
54
  function safeActivity(item, workspacePath) {
32
55
  if (!item || typeof item !== 'object')
33
56
  return null;
@@ -79,3 +102,6 @@ function reasoningSummary(parts) {
79
102
  return [];
80
103
  });
81
104
  }
105
+ function record(value) {
106
+ return value && typeof value === 'object' && !Array.isArray(value) ? value : null;
107
+ }
@@ -6,7 +6,25 @@
6
6
  export function protocolCompatibility(installedVersion, protocolVersion) {
7
7
  if (!installedVersion)
8
8
  return { compatible: false, code: 'CODEX_NOT_FOUND' };
9
- if (installedVersion !== protocolVersion)
9
+ const installed = parseCoreVersion(installedVersion);
10
+ const expected = protocolVersion ? parseCoreVersion(protocolVersion) : undefined;
11
+ // 0.x is pre-1.0: the minor is the core wire contract. Patch releases and
12
+ // newer compatible minors are accepted only within a small forward window;
13
+ // optional APIs are negotiated independently at runtime.
14
+ if (!installed || !expected || !coreCompatible(installed, expected))
10
15
  return { compatible: false, code: 'CODEX_PROTOCOL_MISMATCH' };
11
16
  return { compatible: true };
12
17
  }
18
+ function parseCoreVersion(value) {
19
+ const match = /\b(\d+)\.(\d+)(?:\.(\d+))?\b/.exec(value);
20
+ if (!match)
21
+ return undefined;
22
+ return { major: Number(match[1]), minor: Number(match[2]) };
23
+ }
24
+ function coreCompatible(installed, expected) {
25
+ if (installed.major !== expected.major)
26
+ return false;
27
+ if (installed.major !== 0)
28
+ return installed.minor === expected.minor;
29
+ return installed.minor >= expected.minor && installed.minor <= expected.minor + 4;
30
+ }
@@ -5,6 +5,8 @@
5
5
  */
6
6
  import { GESTALT_QUIZ_TOOL_NAME, parseQuiz } from '../../../shared/contracts/quiz.js';
7
7
  export function toPendingInteraction(input) {
8
+ if (!Number.isSafeInteger(input.id) || !isRecord(input.params))
9
+ return null;
8
10
  if (input.method === 'item/tool/call') {
9
11
  if (!isRecord(input.params) || input.params.tool !== GESTALT_QUIZ_TOOL_NAME)
10
12
  return null;