gestalt-mobile 0.11.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 (41) hide show
  1. package/dist/client/assets/index-CMs5ZzxJ.css +1 -0
  2. package/dist/client/assets/index-D2sT9ZHY.js +19 -0
  3. package/dist/client/index.html +2 -2
  4. package/dist/server/server/app.js +22 -221
  5. package/dist/server/server/composition.js +4 -0
  6. package/dist/server/server/features/auth/application/ceremony-attempts.js +22 -0
  7. package/dist/server/server/features/auth/domain/errors.js +7 -0
  8. package/dist/server/server/features/auth/http/cookies.js +10 -0
  9. package/dist/server/server/features/auth/login/options/endpoint.js +22 -20
  10. package/dist/server/server/features/auth/login/verify/endpoint.js +6 -14
  11. package/dist/server/server/features/auth/logout/endpoint.js +2 -6
  12. package/dist/server/server/features/auth/register/options/endpoint.js +43 -11
  13. package/dist/server/server/features/auth/register/verify/endpoint.js +4 -3
  14. package/dist/server/server/features/auth/register-routes.js +41 -0
  15. package/dist/server/server/features/git/checkout-branch/endpoint.js +1 -1
  16. package/dist/server/server/features/git/clone-repository/endpoint.js +1 -1
  17. package/dist/server/server/features/git/register-routes.js +34 -0
  18. package/dist/server/server/features/plans/domain/workspace-plan-catalog.js +6 -0
  19. package/dist/server/server/features/plans/get-workspace-plan/endpoint.js +23 -0
  20. package/dist/server/server/features/plans/list-workspace-plans/endpoint.js +18 -0
  21. package/dist/server/server/features/plans/register-routes.js +22 -0
  22. package/dist/server/server/features/sessions/promote-recent-thread/endpoint.js +1 -1
  23. package/dist/server/server/features/sessions/register-routes.js +138 -0
  24. package/dist/server/server/features/sessions/respond-interaction/endpoint.js +1 -1
  25. package/dist/server/server/features/sessions/select-model/endpoint.js +1 -1
  26. package/dist/server/server/features/sessions/start-session/endpoint.js +5 -2
  27. package/dist/server/server/features/sessions/start-turn/endpoint.js +2 -1
  28. package/dist/server/server/features/skills/register-routes.js +17 -0
  29. package/dist/server/server/features/skills/replace-profile/endpoint.js +20 -5
  30. package/dist/server/server/platform/auth/sqlite-authorization-store.js +29 -6
  31. package/dist/server/server/platform/codex/normalizer.js +32 -6
  32. package/dist/server/server/platform/codex/protocol-compatibility.js +19 -1
  33. package/dist/server/server/platform/codex/server-request.js +2 -0
  34. package/dist/server/server/platform/codex/session-runtime.js +207 -111
  35. package/dist/server/server/platform/codex/thread-plan-name.js +23 -0
  36. package/dist/server/server/platform/http/problem-handler.js +10 -0
  37. package/dist/server/server/platform/http/problem-reply.js +10 -0
  38. package/dist/server/server/platform/plans/filesystem-workspace-plan-catalog.js +113 -0
  39. package/package.json +2 -1
  40. package/dist/client/assets/index-Bkm5GDCs.css +0 -1
  41. package/dist/client/assets/index-DviggEIS.js +0 -19
@@ -8,9 +8,50 @@ import { randomUUID } from 'node:crypto';
8
8
  import { createPlanMeasurementSnapshot, } from '../../features/plans/application/measurement-snapshot.js';
9
9
  import { canRebindMissingRollout, rebindMissingRollout, } from '../../features/sessions/restore-session/use-case.js';
10
10
  import { gestaltQuizDynamicTool } from '../../../shared/contracts/quiz.js';
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
+ }
11
53
  export class CodexSessionRuntime {
12
54
  launch;
13
- processes;
14
55
  onNotification;
15
56
  onServerRequest;
16
57
  onProcessExit;
@@ -18,9 +59,13 @@ export class CodexSessionRuntime {
18
59
  planStatusSource;
19
60
  onPlanStatus;
20
61
  planMeasurementBaseUrl;
21
- 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) {
22
68
  this.launch = launch;
23
- this.processes = processes;
24
69
  this.onNotification = onNotification;
25
70
  this.onServerRequest = onServerRequest;
26
71
  this.onProcessExit = onProcessExit;
@@ -28,48 +73,37 @@ export class CodexSessionRuntime {
28
73
  this.planStatusSource = planStatusSource;
29
74
  this.onPlanStatus = onPlanStatus;
30
75
  this.planMeasurementBaseUrl = planMeasurementBaseUrl;
76
+ this.requestTimeoutMs = requestTimeoutMs;
77
+ this.maxPendingRequests = maxPendingRequests;
78
+ void _legacyProcesses;
31
79
  }
32
- pendingRequests = new Map();
33
- exitUnsubscribers = new Map();
34
- threadIds = new Map();
35
- planStatusLeases = new Map();
36
- planMeasurementTokens = new Map();
80
+ sessions = new Map();
37
81
  async start(session, now, settings = {}) {
38
- const process = await this.launchForSession(session);
82
+ const resource = await this.createResource(session);
39
83
  try {
40
- process.rpc.onNotification((notification) => this.onNotification?.(session.id, notification));
41
- process.rpc.onServerRequest((request) => this.holdServerRequest(session.id, request));
42
- this.attachExitHandler(session.id, process);
43
- await process.rpc.request('initialize', {
84
+ await resource.process.rpc.request('initialize', {
44
85
  clientInfo: { name: 'gestalt-mobile', version: '0.1.0' },
45
86
  capabilities: { experimentalApi: true },
46
87
  });
47
- const startedThreadId = await this.startThread(process, session, settings);
48
- this.processes.set(session.id, process);
49
- 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);
91
+ await this.writePendingThreadName(session.id);
50
92
  return RelaySession.rehydrate(session).bindThread(startedThreadId, now).snapshot;
51
93
  }
52
94
  catch (error) {
53
- this.discardProcess(session.id, process);
95
+ resource.dispose();
54
96
  throw error;
55
97
  }
56
98
  }
57
99
  stop(sessionId) {
58
- this.exitUnsubscribers.get(sessionId)?.();
59
- this.exitUnsubscribers.delete(sessionId);
60
- this.processes.get(sessionId)?.close();
61
- this.processes.delete(sessionId);
62
- this.threadIds.delete(sessionId);
63
- this.clearPendingRequests(sessionId);
64
- this.planMeasurementTokens.delete(sessionId);
65
- this.releasePlanStatus(sessionId);
100
+ this.sessions.get(sessionId)?.dispose();
66
101
  }
67
102
  async release(sessionId) {
68
- const process = this.processes.get(sessionId);
69
- const threadId = this.threadIds.get(sessionId);
70
- if (process && threadId) {
103
+ const resource = this.sessions.get(sessionId);
104
+ if (resource?.threadId) {
71
105
  try {
72
- await process.rpc.request('thread/unsubscribe', { threadId });
106
+ await resource.process.rpc.request('thread/unsubscribe', { threadId: resource.threadId });
73
107
  }
74
108
  catch {
75
109
  // Closing the child still releases relay ownership if Codex has already exited.
@@ -79,66 +113,63 @@ export class CodexSessionRuntime {
79
113
  }
80
114
  /** Releases all relay-owned app-server children during graceful shutdown. */
81
115
  stopAll() {
82
- for (const sessionId of [...this.processes.keys()])
83
- this.stop(sessionId);
84
- for (const sessionId of [...this.planStatusLeases.keys()])
85
- this.releasePlanStatus(sessionId);
116
+ for (const resource of [...this.sessions.values()])
117
+ resource.dispose();
86
118
  }
87
119
  resolveServerRequest(sessionId, requestId, result) {
88
- const key = `${sessionId}:${requestId}`;
89
- const resolve = this.pendingRequests.get(key);
90
- if (!resolve)
120
+ const resource = this.sessions.get(sessionId);
121
+ const pending = resource?.pendingRequests.get(requestId);
122
+ if (!resource || !pending)
91
123
  return false;
92
- this.pendingRequests.delete(key);
93
- resolve(result);
124
+ resource.pendingRequests.delete(requestId);
125
+ clearTimeout(pending.timer);
126
+ pending.resolve(result);
94
127
  return true;
95
128
  }
96
129
  async startTurn(session, text, now) {
97
- const process = this.processes.get(session.id);
98
- if (!process || !session.threadId)
130
+ const resource = this.sessions.get(session.id);
131
+ if (!resource || !session.threadId)
99
132
  throw new Error('CODEX_SESSION_NOT_RUNNING');
100
- const result = (await process.rpc.request('turn/start', {
133
+ const result = decodeTurnStart(await resource.process.rpc.request('turn/start', {
101
134
  threadId: session.threadId,
102
135
  input: [{ type: 'text', text, text_elements: [] }],
103
136
  ...(session.model ? { model: session.model } : {}),
104
137
  }));
105
- if (!result.turn?.id)
106
- throw new Error('CODEX_TURN_ID_MISSING');
107
- return RelaySession.rehydrate(session).startTurn(result.turn.id, now).snapshot;
138
+ return RelaySession.rehydrate(session).startTurn(result, now).snapshot;
108
139
  }
109
140
  async interruptTurn(session, turnId) {
110
- const process = this.processes.get(session.id);
111
- if (!process || !session.threadId)
141
+ const resource = this.sessions.get(session.id);
142
+ if (!resource || !session.threadId)
112
143
  throw new Error('CODEX_SESSION_NOT_RUNNING');
113
- await process.rpc.request('turn/interrupt', { threadId: session.threadId, turnId });
144
+ await resource.process.rpc.request('turn/interrupt', { threadId: session.threadId, turnId });
114
145
  }
115
146
  async readPlanMeasurement(session) {
116
- const process = this.processes.get(session.id);
117
- if (!process || !session.threadId)
147
+ const resource = this.sessions.get(session.id);
148
+ if (!resource || !session.threadId)
118
149
  throw new Error('CODEX_SESSION_NOT_RUNNING');
119
150
  const [rateLimits, thread] = await Promise.all([
120
- process.rpc.request('account/rateLimits/read', {}),
121
- 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 }),
122
153
  ]);
123
154
  return createPlanMeasurementSnapshot({
124
155
  capturedAt: new Date().toISOString(),
125
- rateLimits: rateLimitWindows(rateLimits),
156
+ rateLimits: rateLimitWindows(decodeRateLimits(rateLimits)),
126
157
  tokenUsage: threadTokenUsage(thread),
127
158
  });
128
159
  }
129
160
  authorizePlanMeasurement(sessionId, authorization) {
130
- const token = this.planMeasurementTokens.get(sessionId);
161
+ const token = this.sessions.get(sessionId)?.planMeasurementToken;
131
162
  return Boolean(token && authorization === `Bearer ${token}`);
132
163
  }
133
164
  async readHistory(session) {
134
- const process = this.processes.get(session.id);
135
- if (!process || !session.threadId)
165
+ const resource = this.sessions.get(session.id);
166
+ if (!resource || !session.threadId)
136
167
  throw new Error('CODEX_SESSION_NOT_RUNNING');
137
- const result = (await process.rpc.request('thread/read', {
168
+ const result = decodeThreadRead(await resource.process.rpc.request('thread/read', {
138
169
  threadId: session.threadId,
139
170
  includeTurns: true,
140
171
  }));
141
- const rawTurns = result.thread?.turns ?? [];
172
+ const rawTurns = result;
142
173
  const activeTurn = rawTurns.find((turn) => turn.status === 'inProgress' && typeof turn.id === 'string');
143
174
  return {
144
175
  turns: rawTurns.map((turn) => ({
@@ -155,18 +186,15 @@ export class CodexSessionRuntime {
155
186
  async restoreWithOutcome(session, now) {
156
187
  if (!session.threadId)
157
188
  throw new Error('CODEX_THREAD_ID_MISSING');
158
- const process = await this.launchForSession(session);
189
+ const resource = await this.createResource(session);
159
190
  try {
160
- process.rpc.onNotification((notification) => this.onNotification?.(session.id, notification));
161
- process.rpc.onServerRequest((request) => this.holdServerRequest(session.id, request));
162
- this.attachExitHandler(session.id, process);
163
- await process.rpc.request('initialize', {
191
+ await resource.process.rpc.request('initialize', {
164
192
  clientInfo: { name: 'gestalt-mobile', version: '0.1.0' },
165
193
  capabilities: { experimentalApi: true },
166
194
  });
167
195
  let result;
168
196
  try {
169
- await process.rpc.request('thread/resume', {
197
+ await resource.process.rpc.request('thread/resume', {
170
198
  threadId: session.threadId,
171
199
  cwd: session.workspacePath,
172
200
  dynamicTools: [gestaltQuizDynamicTool],
@@ -180,41 +208,64 @@ export class CodexSessionRuntime {
180
208
  catch (error) {
181
209
  if (!canRebindMissingRollout(session, error))
182
210
  throw error;
183
- const replacementThreadId = await this.startThread(process, session, { model: session.model });
211
+ const replacementThreadId = await this.startThread(resource.process, session, { model: session.model });
184
212
  result = rebindMissingRollout(session, error, replacementThreadId, now);
185
213
  }
186
- this.processes.set(session.id, process);
187
- 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);
217
+ await this.writePendingThreadName(session.id);
188
218
  return result;
189
219
  }
190
220
  catch (error) {
191
- this.discardProcess(session.id, process);
221
+ resource.dispose();
192
222
  throw error;
193
223
  }
194
224
  }
195
- holdServerRequest(sessionId, request) {
196
- if (!this.onServerRequest?.(sessionId, request)) {
197
- return Promise.reject(new Error('CODEX_SERVER_REQUEST_UNSUPPORTED'));
225
+ /** Best-effort metadata only: failures never affect plan or session execution. */
226
+ async syncThreadPlanName(sessionId, plan) {
227
+ const resource = this.sessions.get(sessionId);
228
+ if (!resource)
229
+ return;
230
+ resource.pendingThreadName = threadPlanName(plan);
231
+ await this.writePendingThreadName(sessionId);
232
+ }
233
+ async writePendingThreadName(sessionId) {
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)
238
+ return;
239
+ try {
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
+ }
245
+ }
246
+ catch (error) {
247
+ if (isMethodNotFound(error))
248
+ resource.capabilities.set('thread/name/set', false);
249
+ // Unsupported servers and transient failures are bounded metadata failures.
198
250
  }
199
- return new Promise((resolve) => this.pendingRequests.set(`${sessionId}:${request.id}`, resolve));
200
251
  }
201
- attachExitHandler(sessionId, process) {
202
- this.exitUnsubscribers.set(sessionId, process.onExit?.(() => {
203
- this.processes.delete(sessionId);
204
- this.threadIds.delete(sessionId);
205
- this.clearPendingRequests(sessionId);
206
- this.planMeasurementTokens.delete(sessionId);
207
- this.exitUnsubscribers.delete(sessionId);
208
- this.releasePlanStatus(sessionId);
209
- this.onProcessExit?.(sessionId);
210
- }) ?? (() => { }));
252
+ holdServerRequest(resource, request) {
253
+ if (!resource.active || !this.onServerRequest?.(resource.sessionId, request)) {
254
+ return Promise.reject(new Error('CODEX_SERVER_REQUEST_UNSUPPORTED'));
255
+ }
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
+ });
211
266
  }
212
267
  async startThread(process, session, settings = {}) {
213
- const result = (await process.rpc.request('thread/start', this.threadStartParams(session, settings)));
214
- if (typeof result.thread?.id !== 'string' || !result.thread.id) {
215
- throw new Error('CODEX_THREAD_ID_MISSING');
216
- }
217
- return result.thread.id;
268
+ return decodeThreadStart(await process.rpc.request('thread/start', this.threadStartParams(session, settings)));
218
269
  }
219
270
  threadStartParams(session, settings) {
220
271
  return {
@@ -225,31 +276,13 @@ export class CodexSessionRuntime {
225
276
  ...(settings.sandbox ? { sandbox: settings.sandbox } : {}),
226
277
  };
227
278
  }
228
- discardProcess(sessionId, process) {
229
- this.exitUnsubscribers.get(sessionId)?.();
230
- this.exitUnsubscribers.delete(sessionId);
231
- process.close();
232
- this.clearPendingRequests(sessionId);
233
- this.planMeasurementTokens.delete(sessionId);
234
- this.releasePlanStatus(sessionId);
235
- }
236
- clearPendingRequests(sessionId) {
237
- const prefix = `${sessionId}:`;
238
- for (const key of this.pendingRequests.keys()) {
239
- if (key.startsWith(prefix))
240
- this.pendingRequests.delete(key);
241
- }
242
- }
243
- async launchForSession(session) {
279
+ async createResource(session) {
244
280
  const lease = this.planStatusSource
245
281
  ? await this.planStatusSource.open({ id: session.id, workspacePath: session.workspacePath }, (update) => this.onPlanStatus?.(session.id, update))
246
282
  : undefined;
247
- if (lease)
248
- this.planStatusLeases.set(session.id, lease);
249
283
  try {
250
284
  const token = randomUUID();
251
- this.planMeasurementTokens.set(session.id, token);
252
- return this.launch({
285
+ const process = this.launch({
253
286
  profile: session.profile,
254
287
  cwd: session.workspacePath,
255
288
  skillsConfig: await this.resolveSkills?.(session),
@@ -269,16 +302,31 @@ export class CodexSessionRuntime {
269
302
  }
270
303
  : {}),
271
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;
272
321
  }
273
322
  catch (error) {
274
- this.releasePlanStatus(session.id);
323
+ lease?.close();
275
324
  throw error;
276
325
  }
277
326
  }
278
- releasePlanStatus(sessionId) {
279
- this.planStatusLeases.get(sessionId)?.close();
280
- this.planStatusLeases.delete(sessionId);
281
- }
327
+ }
328
+ function isMethodNotFound(error) {
329
+ return Boolean(error && typeof error === 'object' && error.code === -32601);
282
330
  }
283
331
  function rateLimitWindows(value) {
284
332
  const limits = asRecord(value)?.rateLimits;
@@ -293,6 +341,16 @@ function rateLimitWindows(value) {
293
341
  : [];
294
342
  });
295
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
+ }
296
354
  function threadTokenUsage(value) {
297
355
  const turns = asRecord(asRecord(value)?.thread)?.turns;
298
356
  if (!Array.isArray(turns))
@@ -318,3 +376,41 @@ function threadTokenUsage(value) {
318
376
  function asRecord(value) {
319
377
  return value && typeof value === 'object' ? value : undefined;
320
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
+ }
@@ -0,0 +1,23 @@
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
+ /** Derives the bounded, session-private label sent to Codex for a live plan only. */
7
+ export function threadPlanName(plan, limit = 120) {
8
+ const l1 = plan.steps.filter((step) => step.level === 1);
9
+ const active = l1.find((step) => step.state === 'WIP');
10
+ const awaiting = l1.find((step) => step.state === 'DONE' && step.reviewStatus === 'UNREVIEWED');
11
+ const suffix = active
12
+ ? `L1 ${l1.indexOf(active) + 1}/${l1.length}`
13
+ : awaiting
14
+ ? `Review ${l1.indexOf(awaiting) + 1}/${l1.length}`
15
+ : l1.length > 0 && l1.every((step) => step.state === 'DONE' && step.reviewStatus === 'REVIEWED')
16
+ ? 'Complete'
17
+ : 'Authoring';
18
+ return `${truncateTitle(plan.title, Math.max(1, limit - suffix.length - 3))} — ${suffix}`;
19
+ }
20
+ function truncateTitle(value, limit) {
21
+ const characters = Array.from(value);
22
+ return characters.length <= limit ? value : `${characters.slice(0, Math.max(0, limit - 1)).join('')}…`;
23
+ }
@@ -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
+ }
@@ -0,0 +1,113 @@
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 { lstat, readdir, readFile, realpath, stat } from 'node:fs/promises';
7
+ import { basename, dirname, isAbsolute, join, resolve } from 'node:path';
8
+ import { parseSupervisedPlan } from '../../features/plans/application/parse-supervised-plan.js';
9
+ const maximumPlans = 100;
10
+ const maximumBytes = 1_048_576;
11
+ /**
12
+ * Bounded, passive access to direct `.gestalt/*.org` children. Every path is
13
+ * revalidated after discovery so a workspace cannot escape through symlinks or
14
+ * replacement races.
15
+ */
16
+ export class FilesystemWorkspacePlanCatalog {
17
+ filesystem;
18
+ constructor(filesystem = { lstat, readdir, readFile, realpath, stat }) {
19
+ this.filesystem = filesystem;
20
+ }
21
+ async list(workspacePath) {
22
+ const directory = await this.directory(workspacePath);
23
+ if (!directory)
24
+ return [];
25
+ let names;
26
+ try {
27
+ names = (await this.filesystem.readdir(directory, { encoding: 'utf8' }))
28
+ .filter((name) => isPlanName(name))
29
+ .sort((left, right) => left.localeCompare(right));
30
+ }
31
+ catch {
32
+ return [];
33
+ }
34
+ const entries = [];
35
+ for (const name of names.slice(0, maximumPlans)) {
36
+ const result = await this.readFromDirectory(workspacePath, directory, name);
37
+ if (result.kind !== 'available')
38
+ continue;
39
+ entries.push(toEntry(name, result.plan));
40
+ }
41
+ return entries;
42
+ }
43
+ async read(workspacePath, planName) {
44
+ if (!isPlanName(planName))
45
+ return { kind: 'missing' };
46
+ const directory = await this.directory(workspacePath);
47
+ if (!directory)
48
+ return { kind: 'missing' };
49
+ return this.readFromDirectory(workspacePath, directory, planName);
50
+ }
51
+ async directory(workspacePath) {
52
+ if (!isAbsolute(workspacePath))
53
+ return null;
54
+ const workspace = resolve(workspacePath);
55
+ const directory = join(workspace, '.gestalt');
56
+ try {
57
+ const metadata = await this.filesystem.lstat(directory);
58
+ if (!metadata.isDirectory() || metadata.isSymbolicLink())
59
+ return null;
60
+ const [canonicalWorkspace, canonicalDirectory] = await Promise.all([
61
+ this.filesystem.realpath(workspace),
62
+ this.filesystem.realpath(directory),
63
+ ]);
64
+ return dirname(canonicalDirectory) === canonicalWorkspace ? canonicalDirectory : null;
65
+ }
66
+ catch {
67
+ return null;
68
+ }
69
+ }
70
+ async readFromDirectory(workspacePath, directory, planName) {
71
+ const path = join(directory, planName);
72
+ try {
73
+ const before = await this.filesystem.lstat(path);
74
+ if (!before.isFile() || before.isSymbolicLink() || before.size > maximumBytes)
75
+ return { kind: 'unavailable' };
76
+ const canonical = await this.filesystem.realpath(path);
77
+ if (dirname(canonical) !== directory || basename(canonical) !== planName)
78
+ return { kind: 'unavailable' };
79
+ const source = await this.filesystem.readFile(canonical, 'utf8');
80
+ const after = await this.filesystem.stat(canonical);
81
+ if (!after.isFile() || after.size !== before.size || after.ino !== before.ino || after.dev !== before.dev)
82
+ return { kind: 'unavailable' };
83
+ const parsed = parseSupervisedPlan({ source, planPath: canonical, workspacePath });
84
+ return parsed.kind === 'available' ? parsed : { kind: 'unavailable' };
85
+ }
86
+ catch (error) {
87
+ return error.code === 'ENOENT'
88
+ ? { kind: 'missing' }
89
+ : { kind: 'unavailable' };
90
+ }
91
+ }
92
+ }
93
+ function isPlanName(value) {
94
+ return (value.endsWith('.org') &&
95
+ value !== '.org' &&
96
+ !isAbsolute(value) &&
97
+ basename(value) === value &&
98
+ !value.includes('\\') &&
99
+ value !== '.' &&
100
+ value !== '..');
101
+ }
102
+ function toEntry(planName, plan) {
103
+ return {
104
+ planName,
105
+ title: plan.title,
106
+ ...(plan.subtitle === undefined ? {} : { subtitle: plan.subtitle }),
107
+ ...(plan.date === undefined ? {} : { date: plan.date }),
108
+ ...(plan.keywords === undefined ? {} : { keywords: plan.keywords }),
109
+ totalSteps: plan.totalSteps,
110
+ doneSteps: plan.doneSteps,
111
+ allDone: plan.allDone,
112
+ };
113
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gestalt-mobile",
3
- "version": "0.11.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",