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
@@ -9,9 +9,49 @@ import { createPlanMeasurementSnapshot, } from '../../features/plans/application
9
9
  import { canRebindMissingRollout, rebindMissingRollout, } from '../../features/sessions/restore-session/use-case.js';
10
10
  import { gestaltQuizDynamicTool } from '../../../shared/contracts/quiz.js';
11
11
  import { threadPlanName } from './thread-plan-name.js';
12
+ /** One private owner for every resource acquired for a live Codex child. */
13
+ class SessionResource {
14
+ sessionId;
15
+ process;
16
+ planStatusLease;
17
+ planMeasurementToken;
18
+ unregister;
19
+ onDisposed;
20
+ disposed = false;
21
+ pendingRequests = new Map();
22
+ threadId;
23
+ pendingThreadName;
24
+ writtenThreadName;
25
+ capabilities = new Map();
26
+ constructor(sessionId, process, planStatusLease, planMeasurementToken, unregister, onDisposed) {
27
+ this.sessionId = sessionId;
28
+ this.process = process;
29
+ this.planStatusLease = planStatusLease;
30
+ this.planMeasurementToken = planMeasurementToken;
31
+ this.unregister = unregister;
32
+ this.onDisposed = onDisposed;
33
+ }
34
+ dispose() {
35
+ if (this.disposed)
36
+ return false;
37
+ this.disposed = true;
38
+ for (const pending of this.pendingRequests.values()) {
39
+ clearTimeout(pending.timer);
40
+ pending.reject(new Error('CODEX_SERVER_REQUEST_CANCELLED'));
41
+ }
42
+ this.pendingRequests.clear();
43
+ for (const unsubscribe of this.unregister)
44
+ unsubscribe();
45
+ this.process.close();
46
+ this.planStatusLease?.close();
47
+ this.onDisposed();
48
+ return true;
49
+ }
50
+ get active() { return !this.disposed; }
51
+ attach(unregister) { this.unregister = unregister; }
52
+ }
12
53
  export class CodexSessionRuntime {
13
54
  launch;
14
- processes;
15
55
  onNotification;
16
56
  onServerRequest;
17
57
  onProcessExit;
@@ -19,9 +59,13 @@ export class CodexSessionRuntime {
19
59
  planStatusSource;
20
60
  onPlanStatus;
21
61
  planMeasurementBaseUrl;
22
- constructor(launch, processes = new Map(), onNotification, onServerRequest, onProcessExit, resolveSkills, planStatusSource, onPlanStatus, planMeasurementBaseUrl) {
62
+ requestTimeoutMs;
63
+ maxPendingRequests;
64
+ constructor(launch,
65
+ // Kept as an ignored compatibility slot while callers migrate from the old
66
+ // correlated-map constructor shape. Live ownership is `sessions` only.
67
+ _legacyProcesses = undefined, onNotification, onServerRequest, onProcessExit, resolveSkills, planStatusSource, onPlanStatus, planMeasurementBaseUrl, requestTimeoutMs = 30_000, maxPendingRequests = 64) {
23
68
  this.launch = launch;
24
- this.processes = processes;
25
69
  this.onNotification = onNotification;
26
70
  this.onServerRequest = onServerRequest;
27
71
  this.onProcessExit = onProcessExit;
@@ -29,53 +73,37 @@ export class CodexSessionRuntime {
29
73
  this.planStatusSource = planStatusSource;
30
74
  this.onPlanStatus = onPlanStatus;
31
75
  this.planMeasurementBaseUrl = planMeasurementBaseUrl;
76
+ this.requestTimeoutMs = requestTimeoutMs;
77
+ this.maxPendingRequests = maxPendingRequests;
78
+ void _legacyProcesses;
32
79
  }
33
- pendingRequests = new Map();
34
- exitUnsubscribers = new Map();
35
- threadIds = new Map();
36
- planStatusLeases = new Map();
37
- planMeasurementTokens = new Map();
38
- pendingThreadNames = new Map();
39
- writtenThreadNames = new Map();
80
+ sessions = new Map();
40
81
  async start(session, now, settings = {}) {
41
- const process = await this.launchForSession(session);
82
+ const resource = await this.createResource(session);
42
83
  try {
43
- process.rpc.onNotification((notification) => this.onNotification?.(session.id, notification));
44
- process.rpc.onServerRequest((request) => this.holdServerRequest(session.id, request));
45
- this.attachExitHandler(session.id, process);
46
- await process.rpc.request('initialize', {
84
+ await resource.process.rpc.request('initialize', {
47
85
  clientInfo: { name: 'gestalt-mobile', version: '0.1.0' },
48
86
  capabilities: { experimentalApi: true },
49
87
  });
50
- const startedThreadId = await this.startThread(process, session, settings);
51
- this.processes.set(session.id, process);
52
- this.threadIds.set(session.id, startedThreadId);
88
+ const startedThreadId = await this.startThread(resource.process, session, settings);
89
+ resource.threadId = startedThreadId;
90
+ this.sessions.set(session.id, resource);
53
91
  await this.writePendingThreadName(session.id);
54
92
  return RelaySession.rehydrate(session).bindThread(startedThreadId, now).snapshot;
55
93
  }
56
94
  catch (error) {
57
- this.discardProcess(session.id, process);
95
+ resource.dispose();
58
96
  throw error;
59
97
  }
60
98
  }
61
99
  stop(sessionId) {
62
- this.exitUnsubscribers.get(sessionId)?.();
63
- this.exitUnsubscribers.delete(sessionId);
64
- this.processes.get(sessionId)?.close();
65
- this.processes.delete(sessionId);
66
- this.threadIds.delete(sessionId);
67
- this.pendingThreadNames.delete(sessionId);
68
- this.writtenThreadNames.delete(sessionId);
69
- this.clearPendingRequests(sessionId);
70
- this.planMeasurementTokens.delete(sessionId);
71
- this.releasePlanStatus(sessionId);
100
+ this.sessions.get(sessionId)?.dispose();
72
101
  }
73
102
  async release(sessionId) {
74
- const process = this.processes.get(sessionId);
75
- const threadId = this.threadIds.get(sessionId);
76
- if (process && threadId) {
103
+ const resource = this.sessions.get(sessionId);
104
+ if (resource?.threadId) {
77
105
  try {
78
- await process.rpc.request('thread/unsubscribe', { threadId });
106
+ await resource.process.rpc.request('thread/unsubscribe', { threadId: resource.threadId });
79
107
  }
80
108
  catch {
81
109
  // Closing the child still releases relay ownership if Codex has already exited.
@@ -85,66 +113,63 @@ export class CodexSessionRuntime {
85
113
  }
86
114
  /** Releases all relay-owned app-server children during graceful shutdown. */
87
115
  stopAll() {
88
- for (const sessionId of [...this.processes.keys()])
89
- this.stop(sessionId);
90
- for (const sessionId of [...this.planStatusLeases.keys()])
91
- this.releasePlanStatus(sessionId);
116
+ for (const resource of [...this.sessions.values()])
117
+ resource.dispose();
92
118
  }
93
119
  resolveServerRequest(sessionId, requestId, result) {
94
- const key = `${sessionId}:${requestId}`;
95
- const resolve = this.pendingRequests.get(key);
96
- if (!resolve)
120
+ const resource = this.sessions.get(sessionId);
121
+ const pending = resource?.pendingRequests.get(requestId);
122
+ if (!resource || !pending)
97
123
  return false;
98
- this.pendingRequests.delete(key);
99
- resolve(result);
124
+ resource.pendingRequests.delete(requestId);
125
+ clearTimeout(pending.timer);
126
+ pending.resolve(result);
100
127
  return true;
101
128
  }
102
129
  async startTurn(session, text, now) {
103
- const process = this.processes.get(session.id);
104
- if (!process || !session.threadId)
130
+ const resource = this.sessions.get(session.id);
131
+ if (!resource || !session.threadId)
105
132
  throw new Error('CODEX_SESSION_NOT_RUNNING');
106
- const result = (await process.rpc.request('turn/start', {
133
+ const result = decodeTurnStart(await resource.process.rpc.request('turn/start', {
107
134
  threadId: session.threadId,
108
135
  input: [{ type: 'text', text, text_elements: [] }],
109
136
  ...(session.model ? { model: session.model } : {}),
110
137
  }));
111
- if (!result.turn?.id)
112
- throw new Error('CODEX_TURN_ID_MISSING');
113
- return RelaySession.rehydrate(session).startTurn(result.turn.id, now).snapshot;
138
+ return RelaySession.rehydrate(session).startTurn(result, now).snapshot;
114
139
  }
115
140
  async interruptTurn(session, turnId) {
116
- const process = this.processes.get(session.id);
117
- if (!process || !session.threadId)
141
+ const resource = this.sessions.get(session.id);
142
+ if (!resource || !session.threadId)
118
143
  throw new Error('CODEX_SESSION_NOT_RUNNING');
119
- await process.rpc.request('turn/interrupt', { threadId: session.threadId, turnId });
144
+ await resource.process.rpc.request('turn/interrupt', { threadId: session.threadId, turnId });
120
145
  }
121
146
  async readPlanMeasurement(session) {
122
- const process = this.processes.get(session.id);
123
- if (!process || !session.threadId)
147
+ const resource = this.sessions.get(session.id);
148
+ if (!resource || !session.threadId)
124
149
  throw new Error('CODEX_SESSION_NOT_RUNNING');
125
150
  const [rateLimits, thread] = await Promise.all([
126
- process.rpc.request('account/rateLimits/read', {}),
127
- process.rpc.request('thread/read', { threadId: session.threadId, includeTurns: true }),
151
+ resource.process.rpc.request('account/rateLimits/read', {}),
152
+ resource.process.rpc.request('thread/read', { threadId: session.threadId, includeTurns: true }),
128
153
  ]);
129
154
  return createPlanMeasurementSnapshot({
130
155
  capturedAt: new Date().toISOString(),
131
- rateLimits: rateLimitWindows(rateLimits),
156
+ rateLimits: rateLimitWindows(decodeRateLimits(rateLimits)),
132
157
  tokenUsage: threadTokenUsage(thread),
133
158
  });
134
159
  }
135
160
  authorizePlanMeasurement(sessionId, authorization) {
136
- const token = this.planMeasurementTokens.get(sessionId);
161
+ const token = this.sessions.get(sessionId)?.planMeasurementToken;
137
162
  return Boolean(token && authorization === `Bearer ${token}`);
138
163
  }
139
164
  async readHistory(session) {
140
- const process = this.processes.get(session.id);
141
- if (!process || !session.threadId)
165
+ const resource = this.sessions.get(session.id);
166
+ if (!resource || !session.threadId)
142
167
  throw new Error('CODEX_SESSION_NOT_RUNNING');
143
- const result = (await process.rpc.request('thread/read', {
168
+ const result = decodeThreadRead(await resource.process.rpc.request('thread/read', {
144
169
  threadId: session.threadId,
145
170
  includeTurns: true,
146
171
  }));
147
- const rawTurns = result.thread?.turns ?? [];
172
+ const rawTurns = result;
148
173
  const activeTurn = rawTurns.find((turn) => turn.status === 'inProgress' && typeof turn.id === 'string');
149
174
  return {
150
175
  turns: rawTurns.map((turn) => ({
@@ -161,18 +186,15 @@ export class CodexSessionRuntime {
161
186
  async restoreWithOutcome(session, now) {
162
187
  if (!session.threadId)
163
188
  throw new Error('CODEX_THREAD_ID_MISSING');
164
- const process = await this.launchForSession(session);
189
+ const resource = await this.createResource(session);
165
190
  try {
166
- process.rpc.onNotification((notification) => this.onNotification?.(session.id, notification));
167
- process.rpc.onServerRequest((request) => this.holdServerRequest(session.id, request));
168
- this.attachExitHandler(session.id, process);
169
- await process.rpc.request('initialize', {
191
+ await resource.process.rpc.request('initialize', {
170
192
  clientInfo: { name: 'gestalt-mobile', version: '0.1.0' },
171
193
  capabilities: { experimentalApi: true },
172
194
  });
173
195
  let result;
174
196
  try {
175
- await process.rpc.request('thread/resume', {
197
+ await resource.process.rpc.request('thread/resume', {
176
198
  threadId: session.threadId,
177
199
  cwd: session.workspacePath,
178
200
  dynamicTools: [gestaltQuizDynamicTool],
@@ -186,63 +208,64 @@ export class CodexSessionRuntime {
186
208
  catch (error) {
187
209
  if (!canRebindMissingRollout(session, error))
188
210
  throw error;
189
- const replacementThreadId = await this.startThread(process, session, { model: session.model });
211
+ const replacementThreadId = await this.startThread(resource.process, session, { model: session.model });
190
212
  result = rebindMissingRollout(session, error, replacementThreadId, now);
191
213
  }
192
- this.processes.set(session.id, process);
193
- this.threadIds.set(session.id, result.session.threadId);
214
+ resource.threadId = result.session.threadId;
215
+ this.sessions.get(session.id)?.dispose();
216
+ this.sessions.set(session.id, resource);
194
217
  await this.writePendingThreadName(session.id);
195
218
  return result;
196
219
  }
197
220
  catch (error) {
198
- this.discardProcess(session.id, process);
221
+ resource.dispose();
199
222
  throw error;
200
223
  }
201
224
  }
202
225
  /** Best-effort metadata only: failures never affect plan or session execution. */
203
226
  async syncThreadPlanName(sessionId, plan) {
204
- this.pendingThreadNames.set(sessionId, threadPlanName(plan));
227
+ const resource = this.sessions.get(sessionId);
228
+ if (!resource)
229
+ return;
230
+ resource.pendingThreadName = threadPlanName(plan);
205
231
  await this.writePendingThreadName(sessionId);
206
232
  }
207
233
  async writePendingThreadName(sessionId) {
208
- const process = this.processes.get(sessionId);
209
- const threadId = this.threadIds.get(sessionId);
210
- const name = this.pendingThreadNames.get(sessionId);
211
- if (!process || !threadId || !name || this.writtenThreadNames.get(sessionId) === name)
234
+ const resource = this.sessions.get(sessionId);
235
+ const threadId = resource?.threadId;
236
+ const name = resource?.pendingThreadName;
237
+ if (!resource || !threadId || !name || resource.writtenThreadName === name || resource.capabilities.get('thread/name/set') === false)
212
238
  return;
213
239
  try {
214
- await process.rpc.request('thread/name/set', { threadId, name });
215
- this.writtenThreadNames.set(sessionId, name);
240
+ await resource.process.rpc.request('thread/name/set', { threadId, name });
241
+ if (resource.active) {
242
+ resource.capabilities.set('thread/name/set', true);
243
+ resource.writtenThreadName = name;
244
+ }
216
245
  }
217
- catch {
246
+ catch (error) {
247
+ if (isMethodNotFound(error))
248
+ resource.capabilities.set('thread/name/set', false);
218
249
  // Unsupported servers and transient failures are bounded metadata failures.
219
250
  }
220
251
  }
221
- holdServerRequest(sessionId, request) {
222
- if (!this.onServerRequest?.(sessionId, request)) {
252
+ holdServerRequest(resource, request) {
253
+ if (!resource.active || !this.onServerRequest?.(resource.sessionId, request)) {
223
254
  return Promise.reject(new Error('CODEX_SERVER_REQUEST_UNSUPPORTED'));
224
255
  }
225
- return new Promise((resolve) => this.pendingRequests.set(`${sessionId}:${request.id}`, resolve));
226
- }
227
- attachExitHandler(sessionId, process) {
228
- this.exitUnsubscribers.set(sessionId, process.onExit?.(() => {
229
- this.processes.delete(sessionId);
230
- this.threadIds.delete(sessionId);
231
- this.pendingThreadNames.delete(sessionId);
232
- this.writtenThreadNames.delete(sessionId);
233
- this.clearPendingRequests(sessionId);
234
- this.planMeasurementTokens.delete(sessionId);
235
- this.exitUnsubscribers.delete(sessionId);
236
- this.releasePlanStatus(sessionId);
237
- this.onProcessExit?.(sessionId);
238
- }) ?? (() => { }));
256
+ if (resource.pendingRequests.size >= this.maxPendingRequests)
257
+ return Promise.reject(new Error('CODEX_SERVER_REQUEST_LIMIT'));
258
+ const requestId = String(request.id);
259
+ return new Promise((resolve, reject) => {
260
+ const timer = setTimeout(() => {
261
+ if (resource.pendingRequests.delete(requestId))
262
+ reject(new Error('CODEX_SERVER_REQUEST_TIMEOUT'));
263
+ }, this.requestTimeoutMs);
264
+ resource.pendingRequests.set(requestId, { resolve, reject, timer });
265
+ });
239
266
  }
240
267
  async startThread(process, session, settings = {}) {
241
- const result = (await process.rpc.request('thread/start', this.threadStartParams(session, settings)));
242
- if (typeof result.thread?.id !== 'string' || !result.thread.id) {
243
- throw new Error('CODEX_THREAD_ID_MISSING');
244
- }
245
- return result.thread.id;
268
+ return decodeThreadStart(await process.rpc.request('thread/start', this.threadStartParams(session, settings)));
246
269
  }
247
270
  threadStartParams(session, settings) {
248
271
  return {
@@ -253,31 +276,13 @@ export class CodexSessionRuntime {
253
276
  ...(settings.sandbox ? { sandbox: settings.sandbox } : {}),
254
277
  };
255
278
  }
256
- discardProcess(sessionId, process) {
257
- this.exitUnsubscribers.get(sessionId)?.();
258
- this.exitUnsubscribers.delete(sessionId);
259
- process.close();
260
- this.clearPendingRequests(sessionId);
261
- this.planMeasurementTokens.delete(sessionId);
262
- this.releasePlanStatus(sessionId);
263
- }
264
- clearPendingRequests(sessionId) {
265
- const prefix = `${sessionId}:`;
266
- for (const key of this.pendingRequests.keys()) {
267
- if (key.startsWith(prefix))
268
- this.pendingRequests.delete(key);
269
- }
270
- }
271
- async launchForSession(session) {
279
+ async createResource(session) {
272
280
  const lease = this.planStatusSource
273
281
  ? await this.planStatusSource.open({ id: session.id, workspacePath: session.workspacePath }, (update) => this.onPlanStatus?.(session.id, update))
274
282
  : undefined;
275
- if (lease)
276
- this.planStatusLeases.set(session.id, lease);
277
283
  try {
278
284
  const token = randomUUID();
279
- this.planMeasurementTokens.set(session.id, token);
280
- return this.launch({
285
+ const process = this.launch({
281
286
  profile: session.profile,
282
287
  cwd: session.workspacePath,
283
288
  skillsConfig: await this.resolveSkills?.(session),
@@ -297,16 +302,31 @@ export class CodexSessionRuntime {
297
302
  }
298
303
  : {}),
299
304
  });
305
+ const resource = new SessionResource(session.id, process, lease, token, [], () => {
306
+ if (this.sessions.get(session.id) === resource)
307
+ this.sessions.delete(session.id);
308
+ });
309
+ const notificationUnsubscribe = process.rpc.onNotification((notification) => {
310
+ if (resource.active)
311
+ this.onNotification?.(session.id, notification);
312
+ });
313
+ const requestUnsubscribe = process.rpc.onServerRequest((request) => this.holdServerRequest(resource, request));
314
+ const exitUnsubscribe = process.onExit?.(() => {
315
+ if (resource.dispose())
316
+ this.onProcessExit?.(session.id);
317
+ }) ?? (() => { });
318
+ // The resource's private unsubscribe list is populated before it is published.
319
+ resource.attach([notificationUnsubscribe, requestUnsubscribe, exitUnsubscribe]);
320
+ return resource;
300
321
  }
301
322
  catch (error) {
302
- this.releasePlanStatus(session.id);
323
+ lease?.close();
303
324
  throw error;
304
325
  }
305
326
  }
306
- releasePlanStatus(sessionId) {
307
- this.planStatusLeases.get(sessionId)?.close();
308
- this.planStatusLeases.delete(sessionId);
309
- }
327
+ }
328
+ function isMethodNotFound(error) {
329
+ return Boolean(error && typeof error === 'object' && error.code === -32601);
310
330
  }
311
331
  function rateLimitWindows(value) {
312
332
  const limits = asRecord(value)?.rateLimits;
@@ -321,6 +341,16 @@ function rateLimitWindows(value) {
321
341
  : [];
322
342
  });
323
343
  }
344
+ function decodeRateLimits(value) {
345
+ const limits = asRecord(value)?.rateLimits;
346
+ if (!Array.isArray(limits) || limits.length > 32)
347
+ return undefined;
348
+ const decoded = limits.flatMap((limit) => {
349
+ const record = asRecord(limit);
350
+ return typeof record?.windowDurationMins === 'number' && Number.isFinite(record.windowDurationMins) && typeof record.usedPercent === 'number' && Number.isFinite(record.usedPercent) ? [{ windowDurationMins: record.windowDurationMins, usedPercent: record.usedPercent }] : [];
351
+ });
352
+ return decoded.length === limits.length ? { rateLimits: decoded } : undefined;
353
+ }
324
354
  function threadTokenUsage(value) {
325
355
  const turns = asRecord(asRecord(value)?.thread)?.turns;
326
356
  if (!Array.isArray(turns))
@@ -346,3 +376,41 @@ function threadTokenUsage(value) {
346
376
  function asRecord(value) {
347
377
  return value && typeof value === 'object' ? value : undefined;
348
378
  }
379
+ function decodeThreadStart(value) {
380
+ const id = asRecord(asRecord(value)?.thread)?.id;
381
+ if (typeof id !== 'string' || !id || id.length > 256)
382
+ throw new Error('CODEX_THREAD_ID_MISSING');
383
+ return id;
384
+ }
385
+ function decodeTurnStart(value) {
386
+ const id = asRecord(asRecord(value)?.turn)?.id;
387
+ if (typeof id !== 'string' || !id || id.length > 256)
388
+ throw new Error('CODEX_TURN_ID_MISSING');
389
+ return id;
390
+ }
391
+ function decodeThreadRead(value) {
392
+ const turns = asRecord(asRecord(value)?.thread)?.turns;
393
+ if (!Array.isArray(turns) || turns.length > 10_000)
394
+ throw new Error('CODEX_THREAD_READ_MALFORMED');
395
+ return turns.map((turn) => {
396
+ const record = asRecord(turn);
397
+ if (!record)
398
+ throw new Error('CODEX_THREAD_READ_MALFORMED');
399
+ return { id: boundedString(record.id), status: boundedString(record.status), startedAt: typeof record.startedAt === 'number' ? record.startedAt : undefined, completedAt: typeof record.completedAt === 'number' ? record.completedAt : undefined, items: Array.isArray(record.items) ? record.items.flatMap(decodeHistoryItem) : undefined };
400
+ });
401
+ }
402
+ function boundedString(value, max = 64_000) { return typeof value === 'string' && value.length <= max ? value : undefined; }
403
+ function decodeHistoryItem(value) {
404
+ const item = asRecord(value);
405
+ const id = boundedString(item?.id, 256);
406
+ const type = boundedString(item?.type, 64);
407
+ if (!item || !id || !type)
408
+ return [];
409
+ if (type === 'agentMessage')
410
+ return [{ id, type, ...(boundedString(item.text) ? { text: boundedString(item.text) } : {}), ...(boundedString(item.phase, 64) ? { phase: boundedString(item.phase, 64) } : {}) }];
411
+ if (type === 'plan' && boundedString(item.text))
412
+ return [{ id, type, text: boundedString(item.text) }];
413
+ if (type === 'commandExecution' && boundedString(item.command) && boundedString(item.status, 64))
414
+ return [{ id, type, command: boundedString(item.command), status: boundedString(item.status, 64), ...(typeof item.exitCode === 'number' ? { exitCode: item.exitCode } : {}) }];
415
+ return [];
416
+ }
@@ -3,6 +3,7 @@
3
3
  * Designed by Denis Roio <jaromil@dyne.org>
4
4
  * SPDX-License-Identifier: AGPL-3.0-or-later
5
5
  */
6
+ import { problem as createProblem } from './problem.js';
6
7
  export function registerProblemHandler(app, serveSpa = false) {
7
8
  app.setNotFoundHandler((request, reply) => {
8
9
  if (serveSpa && request.method === 'GET' && !request.url.startsWith('/api/')) {
@@ -19,6 +20,15 @@ export function registerProblemHandler(app, serveSpa = false) {
19
20
  return reply.code(404).type('application/problem+json').send(problem);
20
21
  });
21
22
  app.setErrorHandler((error, _request, reply) => {
23
+ if (typeof error === 'object' &&
24
+ error !== null &&
25
+ 'code' in error &&
26
+ error.code === 'FST_ERR_CTP_BODY_TOO_LARGE') {
27
+ return reply
28
+ .code(413)
29
+ .type('application/problem+json')
30
+ .send(createProblem('PAYLOAD_TOO_LARGE', 413, 'The request body exceeds this endpoint limit.'));
31
+ }
22
32
  const problem = {
23
33
  type: 'urn:gestalt-mobile:error:internal',
24
34
  title: 'Internal server error',
@@ -0,0 +1,10 @@
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 { problem } from './problem.js';
7
+ /** Send the established RFC 7807-compatible response without deciding its semantics. */
8
+ export function sendProblem(reply, code, status, detail) {
9
+ return reply.code(status).type('application/problem+json').send(problem(code, status, detail));
10
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gestalt-mobile",
3
- "version": "0.12.0",
3
+ "version": "0.13.0",
4
4
  "description": "Mobile-first web relay for durable Codex development sessions",
5
5
  "keywords": [
6
6
  "codex",
@@ -45,6 +45,7 @@
45
45
  "format": "prettier --write .",
46
46
  "format:check": "prettier --check .",
47
47
  "test": "vitest run",
48
+ "audit:prod": "npm audit --omit=dev --audit-level=high",
48
49
  "test:open-profile-smoke": "tsx scripts/open-profile-smoke.ts",
49
50
  "test:watch": "vitest",
50
51
  "test:package": "node scripts/smoke-packed-cli.mjs",