gestalt-mobile 0.12.0 → 0.14.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 (79) hide show
  1. package/README.md +42 -11
  2. package/dist/client/assets/index-C5qFcFUg.css +1 -0
  3. package/dist/client/assets/index-tMyOolAm.js +24 -0
  4. package/dist/client/assets/jetbrains-mono-cyrillic-400-normal-BEIGL1Tu.woff2 +0 -0
  5. package/dist/client/assets/jetbrains-mono-cyrillic-400-normal-ugxPyKxw.woff +0 -0
  6. package/dist/client/assets/jetbrains-mono-cyrillic-ext-400-normal-Bh0R7Dhr.woff +0 -0
  7. package/dist/client/assets/jetbrains-mono-cyrillic-ext-400-normal-C_uLvvQ5.woff2 +0 -0
  8. package/dist/client/assets/jetbrains-mono-greek-400-normal-B9oWc5Lo.woff +0 -0
  9. package/dist/client/assets/jetbrains-mono-greek-400-normal-C190GLew.woff2 +0 -0
  10. package/dist/client/assets/jetbrains-mono-latin-400-normal-6-qcROiO.woff +0 -0
  11. package/dist/client/assets/jetbrains-mono-latin-400-normal-V6pRDFza.woff2 +0 -0
  12. package/dist/client/assets/jetbrains-mono-latin-ext-400-normal-Bc8Ftmh3.woff2 +0 -0
  13. package/dist/client/assets/jetbrains-mono-latin-ext-400-normal-fXTG6kC5.woff +0 -0
  14. package/dist/client/assets/jetbrains-mono-vietnamese-400-normal-ByoDsISC.woff2 +0 -0
  15. package/dist/client/assets/jetbrains-mono-vietnamese-400-normal-CqNFfHCs.woff +0 -0
  16. package/dist/client/assets/syne-greek-600-normal-BJs4kvyH.woff +0 -0
  17. package/dist/client/assets/syne-greek-600-normal-BpFII5Ja.woff2 +0 -0
  18. package/dist/client/assets/syne-latin-600-normal-BiwQbQXw.woff2 +0 -0
  19. package/dist/client/assets/syne-latin-600-normal-dRu9QuIh.woff +0 -0
  20. package/dist/client/assets/syne-latin-ext-600-normal-DEzBMW8B.woff +0 -0
  21. package/dist/client/assets/syne-latin-ext-600-normal-ZnizrDKU.woff2 +0 -0
  22. package/dist/client/index.html +3 -2
  23. package/dist/server/server/app.js +22 -227
  24. package/dist/server/server/cli.js +3 -1
  25. package/dist/server/server/composition.js +25 -26
  26. package/dist/server/server/features/auth/application/ceremony-attempts.js +22 -0
  27. package/dist/server/server/features/auth/devices/revoke/endpoint.js +3 -1
  28. package/dist/server/server/features/auth/domain/errors.js +7 -0
  29. package/dist/server/server/features/auth/enrollment-tickets/endpoint.js +7 -2
  30. package/dist/server/server/features/auth/enrollment-tickets/status/endpoint.js +3 -1
  31. package/dist/server/server/features/auth/http/cookies.js +20 -0
  32. package/dist/server/server/features/auth/login/options/endpoint.js +22 -20
  33. package/dist/server/server/features/auth/login/verify/endpoint.js +6 -14
  34. package/dist/server/server/features/auth/logout/endpoint.js +2 -6
  35. package/dist/server/server/features/auth/register/options/endpoint.js +43 -11
  36. package/dist/server/server/features/auth/register/verify/endpoint.js +74 -18
  37. package/dist/server/server/features/auth/register-routes.js +41 -0
  38. package/dist/server/server/features/git/checkout-branch/endpoint.js +1 -1
  39. package/dist/server/server/features/git/clone-repository/endpoint.js +1 -1
  40. package/dist/server/server/features/git/register-routes.js +34 -0
  41. package/dist/server/server/features/plans/application/measurement-snapshot.js +1 -1
  42. package/dist/server/server/features/plans/get-measurement/endpoint.js +1 -3
  43. package/dist/server/server/features/plans/register-routes.js +22 -0
  44. package/dist/server/server/features/sessions/interaction/kind.js +7 -1
  45. package/dist/server/server/features/sessions/list-recent-threads/endpoint.js +3 -1
  46. package/dist/server/server/features/sessions/model/relay-session.js +3 -1
  47. package/dist/server/server/features/sessions/promote-recent-thread/endpoint.js +1 -1
  48. package/dist/server/server/features/sessions/register-routes.js +138 -0
  49. package/dist/server/server/features/sessions/respond-interaction/endpoint.js +1 -1
  50. package/dist/server/server/features/sessions/select-model/endpoint.js +1 -1
  51. package/dist/server/server/features/sessions/start-session/endpoint.js +5 -2
  52. package/dist/server/server/features/sessions/start-session/use-case.js +1 -1
  53. package/dist/server/server/features/sessions/start-turn/endpoint.js +2 -1
  54. package/dist/server/server/features/skills/delete-profile/endpoint.js +12 -3
  55. package/dist/server/server/features/skills/list-available/endpoint.js +24 -6
  56. package/dist/server/server/features/skills/list-profiles/endpoint.js +29 -5
  57. package/dist/server/server/features/skills/model/skill-profile.js +23 -3
  58. package/dist/server/server/features/skills/register-routes.js +17 -0
  59. package/dist/server/server/features/skills/replace-profile/endpoint.js +20 -5
  60. package/dist/server/server/platform/auth/simple-webauthn-adapter.js +23 -3
  61. package/dist/server/server/platform/auth/sqlite-authorization-store.js +29 -6
  62. package/dist/server/server/platform/catalog/profile-command.js +8 -1
  63. package/dist/server/server/platform/codex/normalizer.js +50 -6
  64. package/dist/server/server/platform/codex/protocol-compatibility.js +19 -1
  65. package/dist/server/server/platform/codex/server-request.js +2 -0
  66. package/dist/server/server/platform/codex/session-runtime.js +235 -126
  67. package/dist/server/server/platform/codex/thread-plan-name.js +5 -2
  68. package/dist/server/server/platform/http/problem-handler.js +10 -0
  69. package/dist/server/server/platform/http/problem-reply.js +13 -0
  70. package/dist/server/server/platform/plans/filesystem-plan-status-source.js +11 -6
  71. package/dist/server/server/platform/plans/filesystem-workspace-plan-catalog.js +4 -1
  72. package/dist/server/server/platform/plans/plan-measurement-command.js +4 -1
  73. package/dist/server/server/platform/skills/codex-skill-catalog.js +29 -7
  74. package/dist/server/server/platform/skills/filesystem-skill-profile-store.js +5 -1
  75. package/dist/server/shared/contracts/quiz.js +30 -9
  76. package/package.json +4 -1
  77. package/dist/client/assets/index-B7KwEjTE.js +0 -19
  78. package/dist/client/assets/index-CMs5ZzxJ.css +0 -1
  79. package/dist/server/server/features/plans/application/native-plan-alignment.js +0 -39
@@ -9,9 +9,53 @@ 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() {
51
+ return !this.disposed;
52
+ }
53
+ attach(unregister) {
54
+ this.unregister = unregister;
55
+ }
56
+ }
12
57
  export class CodexSessionRuntime {
13
58
  launch;
14
- processes;
15
59
  onNotification;
16
60
  onServerRequest;
17
61
  onProcessExit;
@@ -19,9 +63,13 @@ export class CodexSessionRuntime {
19
63
  planStatusSource;
20
64
  onPlanStatus;
21
65
  planMeasurementBaseUrl;
22
- constructor(launch, processes = new Map(), onNotification, onServerRequest, onProcessExit, resolveSkills, planStatusSource, onPlanStatus, planMeasurementBaseUrl) {
66
+ requestTimeoutMs;
67
+ maxPendingRequests;
68
+ constructor(launch,
69
+ // Kept as an ignored compatibility slot while callers migrate from the old
70
+ // correlated-map constructor shape. Live ownership is `sessions` only.
71
+ _legacyProcesses = undefined, onNotification, onServerRequest, onProcessExit, resolveSkills, planStatusSource, onPlanStatus, planMeasurementBaseUrl, requestTimeoutMs = 30_000, maxPendingRequests = 64) {
23
72
  this.launch = launch;
24
- this.processes = processes;
25
73
  this.onNotification = onNotification;
26
74
  this.onServerRequest = onServerRequest;
27
75
  this.onProcessExit = onProcessExit;
@@ -29,53 +77,37 @@ export class CodexSessionRuntime {
29
77
  this.planStatusSource = planStatusSource;
30
78
  this.onPlanStatus = onPlanStatus;
31
79
  this.planMeasurementBaseUrl = planMeasurementBaseUrl;
80
+ this.requestTimeoutMs = requestTimeoutMs;
81
+ this.maxPendingRequests = maxPendingRequests;
82
+ void _legacyProcesses;
32
83
  }
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();
84
+ sessions = new Map();
40
85
  async start(session, now, settings = {}) {
41
- const process = await this.launchForSession(session);
86
+ const resource = await this.createResource(session);
42
87
  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', {
88
+ await resource.process.rpc.request('initialize', {
47
89
  clientInfo: { name: 'gestalt-mobile', version: '0.1.0' },
48
90
  capabilities: { experimentalApi: true },
49
91
  });
50
- const startedThreadId = await this.startThread(process, session, settings);
51
- this.processes.set(session.id, process);
52
- this.threadIds.set(session.id, startedThreadId);
92
+ const startedThreadId = await this.startThread(resource.process, session, settings);
93
+ resource.threadId = startedThreadId;
94
+ this.sessions.set(session.id, resource);
53
95
  await this.writePendingThreadName(session.id);
54
96
  return RelaySession.rehydrate(session).bindThread(startedThreadId, now).snapshot;
55
97
  }
56
98
  catch (error) {
57
- this.discardProcess(session.id, process);
99
+ resource.dispose();
58
100
  throw error;
59
101
  }
60
102
  }
61
103
  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);
104
+ this.sessions.get(sessionId)?.dispose();
72
105
  }
73
106
  async release(sessionId) {
74
- const process = this.processes.get(sessionId);
75
- const threadId = this.threadIds.get(sessionId);
76
- if (process && threadId) {
107
+ const resource = this.sessions.get(sessionId);
108
+ if (resource?.threadId) {
77
109
  try {
78
- await process.rpc.request('thread/unsubscribe', { threadId });
110
+ await resource.process.rpc.request('thread/unsubscribe', { threadId: resource.threadId });
79
111
  }
80
112
  catch {
81
113
  // Closing the child still releases relay ownership if Codex has already exited.
@@ -85,66 +117,66 @@ export class CodexSessionRuntime {
85
117
  }
86
118
  /** Releases all relay-owned app-server children during graceful shutdown. */
87
119
  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);
120
+ for (const resource of [...this.sessions.values()])
121
+ resource.dispose();
92
122
  }
93
123
  resolveServerRequest(sessionId, requestId, result) {
94
- const key = `${sessionId}:${requestId}`;
95
- const resolve = this.pendingRequests.get(key);
96
- if (!resolve)
124
+ const resource = this.sessions.get(sessionId);
125
+ const pending = resource?.pendingRequests.get(requestId);
126
+ if (!resource || !pending)
97
127
  return false;
98
- this.pendingRequests.delete(key);
99
- resolve(result);
128
+ resource.pendingRequests.delete(requestId);
129
+ clearTimeout(pending.timer);
130
+ pending.resolve(result);
100
131
  return true;
101
132
  }
102
133
  async startTurn(session, text, now) {
103
- const process = this.processes.get(session.id);
104
- if (!process || !session.threadId)
134
+ const resource = this.sessions.get(session.id);
135
+ if (!resource || !session.threadId)
105
136
  throw new Error('CODEX_SESSION_NOT_RUNNING');
106
- const result = (await process.rpc.request('turn/start', {
137
+ const result = decodeTurnStart(await resource.process.rpc.request('turn/start', {
107
138
  threadId: session.threadId,
108
139
  input: [{ type: 'text', text, text_elements: [] }],
109
140
  ...(session.model ? { model: session.model } : {}),
110
141
  }));
111
- if (!result.turn?.id)
112
- throw new Error('CODEX_TURN_ID_MISSING');
113
- return RelaySession.rehydrate(session).startTurn(result.turn.id, now).snapshot;
142
+ return RelaySession.rehydrate(session).startTurn(result, now).snapshot;
114
143
  }
115
144
  async interruptTurn(session, turnId) {
116
- const process = this.processes.get(session.id);
117
- if (!process || !session.threadId)
145
+ const resource = this.sessions.get(session.id);
146
+ if (!resource || !session.threadId)
118
147
  throw new Error('CODEX_SESSION_NOT_RUNNING');
119
- await process.rpc.request('turn/interrupt', { threadId: session.threadId, turnId });
148
+ await resource.process.rpc.request('turn/interrupt', { threadId: session.threadId, turnId });
120
149
  }
121
150
  async readPlanMeasurement(session) {
122
- const process = this.processes.get(session.id);
123
- if (!process || !session.threadId)
151
+ const resource = this.sessions.get(session.id);
152
+ if (!resource || !session.threadId)
124
153
  throw new Error('CODEX_SESSION_NOT_RUNNING');
125
154
  const [rateLimits, thread] = await Promise.all([
126
- process.rpc.request('account/rateLimits/read', {}),
127
- process.rpc.request('thread/read', { threadId: session.threadId, includeTurns: true }),
155
+ resource.process.rpc.request('account/rateLimits/read', {}),
156
+ resource.process.rpc.request('thread/read', {
157
+ threadId: session.threadId,
158
+ includeTurns: true,
159
+ }),
128
160
  ]);
129
161
  return createPlanMeasurementSnapshot({
130
162
  capturedAt: new Date().toISOString(),
131
- rateLimits: rateLimitWindows(rateLimits),
163
+ rateLimits: rateLimitWindows(decodeRateLimits(rateLimits)),
132
164
  tokenUsage: threadTokenUsage(thread),
133
165
  });
134
166
  }
135
167
  authorizePlanMeasurement(sessionId, authorization) {
136
- const token = this.planMeasurementTokens.get(sessionId);
168
+ const token = this.sessions.get(sessionId)?.planMeasurementToken;
137
169
  return Boolean(token && authorization === `Bearer ${token}`);
138
170
  }
139
171
  async readHistory(session) {
140
- const process = this.processes.get(session.id);
141
- if (!process || !session.threadId)
172
+ const resource = this.sessions.get(session.id);
173
+ if (!resource || !session.threadId)
142
174
  throw new Error('CODEX_SESSION_NOT_RUNNING');
143
- const result = (await process.rpc.request('thread/read', {
175
+ const result = decodeThreadRead(await resource.process.rpc.request('thread/read', {
144
176
  threadId: session.threadId,
145
177
  includeTurns: true,
146
178
  }));
147
- const rawTurns = result.thread?.turns ?? [];
179
+ const rawTurns = result;
148
180
  const activeTurn = rawTurns.find((turn) => turn.status === 'inProgress' && typeof turn.id === 'string');
149
181
  return {
150
182
  turns: rawTurns.map((turn) => ({
@@ -161,18 +193,15 @@ export class CodexSessionRuntime {
161
193
  async restoreWithOutcome(session, now) {
162
194
  if (!session.threadId)
163
195
  throw new Error('CODEX_THREAD_ID_MISSING');
164
- const process = await this.launchForSession(session);
196
+ const resource = await this.createResource(session);
165
197
  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', {
198
+ await resource.process.rpc.request('initialize', {
170
199
  clientInfo: { name: 'gestalt-mobile', version: '0.1.0' },
171
200
  capabilities: { experimentalApi: true },
172
201
  });
173
202
  let result;
174
203
  try {
175
- await process.rpc.request('thread/resume', {
204
+ await resource.process.rpc.request('thread/resume', {
176
205
  threadId: session.threadId,
177
206
  cwd: session.workspacePath,
178
207
  dynamicTools: [gestaltQuizDynamicTool],
@@ -186,63 +215,70 @@ export class CodexSessionRuntime {
186
215
  catch (error) {
187
216
  if (!canRebindMissingRollout(session, error))
188
217
  throw error;
189
- const replacementThreadId = await this.startThread(process, session, { model: session.model });
218
+ const replacementThreadId = await this.startThread(resource.process, session, {
219
+ model: session.model,
220
+ });
190
221
  result = rebindMissingRollout(session, error, replacementThreadId, now);
191
222
  }
192
- this.processes.set(session.id, process);
193
- this.threadIds.set(session.id, result.session.threadId);
223
+ resource.threadId = result.session.threadId;
224
+ this.sessions.get(session.id)?.dispose();
225
+ this.sessions.set(session.id, resource);
194
226
  await this.writePendingThreadName(session.id);
195
227
  return result;
196
228
  }
197
229
  catch (error) {
198
- this.discardProcess(session.id, process);
230
+ resource.dispose();
199
231
  throw error;
200
232
  }
201
233
  }
202
234
  /** Best-effort metadata only: failures never affect plan or session execution. */
203
235
  async syncThreadPlanName(sessionId, plan) {
204
- this.pendingThreadNames.set(sessionId, threadPlanName(plan));
236
+ const resource = this.sessions.get(sessionId);
237
+ if (!resource)
238
+ return;
239
+ resource.pendingThreadName = threadPlanName(plan);
205
240
  await this.writePendingThreadName(sessionId);
206
241
  }
207
242
  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)
243
+ const resource = this.sessions.get(sessionId);
244
+ const threadId = resource?.threadId;
245
+ const name = resource?.pendingThreadName;
246
+ if (!resource ||
247
+ !threadId ||
248
+ !name ||
249
+ resource.writtenThreadName === name ||
250
+ resource.capabilities.get('thread/name/set') === false)
212
251
  return;
213
252
  try {
214
- await process.rpc.request('thread/name/set', { threadId, name });
215
- this.writtenThreadNames.set(sessionId, name);
253
+ await resource.process.rpc.request('thread/name/set', { threadId, name });
254
+ if (resource.active) {
255
+ resource.capabilities.set('thread/name/set', true);
256
+ resource.writtenThreadName = name;
257
+ }
216
258
  }
217
- catch {
259
+ catch (error) {
260
+ if (isMethodNotFound(error))
261
+ resource.capabilities.set('thread/name/set', false);
218
262
  // Unsupported servers and transient failures are bounded metadata failures.
219
263
  }
220
264
  }
221
- holdServerRequest(sessionId, request) {
222
- if (!this.onServerRequest?.(sessionId, request)) {
265
+ holdServerRequest(resource, request) {
266
+ if (!resource.active || !this.onServerRequest?.(resource.sessionId, request)) {
223
267
  return Promise.reject(new Error('CODEX_SERVER_REQUEST_UNSUPPORTED'));
224
268
  }
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
- }) ?? (() => { }));
269
+ if (resource.pendingRequests.size >= this.maxPendingRequests)
270
+ return Promise.reject(new Error('CODEX_SERVER_REQUEST_LIMIT'));
271
+ const requestId = String(request.id);
272
+ return new Promise((resolve, reject) => {
273
+ const timer = setTimeout(() => {
274
+ if (resource.pendingRequests.delete(requestId))
275
+ reject(new Error('CODEX_SERVER_REQUEST_TIMEOUT'));
276
+ }, this.requestTimeoutMs);
277
+ resource.pendingRequests.set(requestId, { resolve, reject, timer });
278
+ });
239
279
  }
240
280
  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;
281
+ return decodeThreadStart(await process.rpc.request('thread/start', this.threadStartParams(session, settings)));
246
282
  }
247
283
  threadStartParams(session, settings) {
248
284
  return {
@@ -253,35 +289,17 @@ export class CodexSessionRuntime {
253
289
  ...(settings.sandbox ? { sandbox: settings.sandbox } : {}),
254
290
  };
255
291
  }
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) {
292
+ async createResource(session) {
272
293
  const lease = this.planStatusSource
273
294
  ? await this.planStatusSource.open({ id: session.id, workspacePath: session.workspacePath }, (update) => this.onPlanStatus?.(session.id, update))
274
295
  : undefined;
275
- if (lease)
276
- this.planStatusLeases.set(session.id, lease);
277
296
  try {
278
297
  const token = randomUUID();
279
- this.planMeasurementTokens.set(session.id, token);
280
- return this.launch({
298
+ const process = this.launch({
281
299
  profile: session.profile,
282
300
  cwd: session.workspacePath,
283
301
  skillsConfig: await this.resolveSkills?.(session),
284
- ...((lease || this.planMeasurementBaseUrl)
302
+ ...(lease || this.planMeasurementBaseUrl
285
303
  ? {
286
304
  environment: {
287
305
  ...(lease
@@ -297,16 +315,31 @@ export class CodexSessionRuntime {
297
315
  }
298
316
  : {}),
299
317
  });
318
+ const resource = new SessionResource(session.id, process, lease, token, [], () => {
319
+ if (this.sessions.get(session.id) === resource)
320
+ this.sessions.delete(session.id);
321
+ });
322
+ const notificationUnsubscribe = process.rpc.onNotification((notification) => {
323
+ if (resource.active)
324
+ this.onNotification?.(session.id, notification);
325
+ });
326
+ const requestUnsubscribe = process.rpc.onServerRequest((request) => this.holdServerRequest(resource, request));
327
+ const exitUnsubscribe = process.onExit?.(() => {
328
+ if (resource.dispose())
329
+ this.onProcessExit?.(session.id);
330
+ }) ?? (() => { });
331
+ // The resource's private unsubscribe list is populated before it is published.
332
+ resource.attach([notificationUnsubscribe, requestUnsubscribe, exitUnsubscribe]);
333
+ return resource;
300
334
  }
301
335
  catch (error) {
302
- this.releasePlanStatus(session.id);
336
+ lease?.close();
303
337
  throw error;
304
338
  }
305
339
  }
306
- releasePlanStatus(sessionId) {
307
- this.planStatusLeases.get(sessionId)?.close();
308
- this.planStatusLeases.delete(sessionId);
309
- }
340
+ }
341
+ function isMethodNotFound(error) {
342
+ return Boolean(error && typeof error === 'object' && error.code === -32601);
310
343
  }
311
344
  function rateLimitWindows(value) {
312
345
  const limits = asRecord(value)?.rateLimits;
@@ -321,6 +354,21 @@ function rateLimitWindows(value) {
321
354
  : [];
322
355
  });
323
356
  }
357
+ function decodeRateLimits(value) {
358
+ const limits = asRecord(value)?.rateLimits;
359
+ if (!Array.isArray(limits) || limits.length > 32)
360
+ return undefined;
361
+ const decoded = limits.flatMap((limit) => {
362
+ const record = asRecord(limit);
363
+ return typeof record?.windowDurationMins === 'number' &&
364
+ Number.isFinite(record.windowDurationMins) &&
365
+ typeof record.usedPercent === 'number' &&
366
+ Number.isFinite(record.usedPercent)
367
+ ? [{ windowDurationMins: record.windowDurationMins, usedPercent: record.usedPercent }]
368
+ : [];
369
+ });
370
+ return decoded.length === limits.length ? { rateLimits: decoded } : undefined;
371
+ }
324
372
  function threadTokenUsage(value) {
325
373
  const turns = asRecord(asRecord(value)?.thread)?.turns;
326
374
  if (!Array.isArray(turns))
@@ -346,3 +394,64 @@ function threadTokenUsage(value) {
346
394
  function asRecord(value) {
347
395
  return value && typeof value === 'object' ? value : undefined;
348
396
  }
397
+ function decodeThreadStart(value) {
398
+ const id = asRecord(asRecord(value)?.thread)?.id;
399
+ if (typeof id !== 'string' || !id || id.length > 256)
400
+ throw new Error('CODEX_THREAD_ID_MISSING');
401
+ return id;
402
+ }
403
+ function decodeTurnStart(value) {
404
+ const id = asRecord(asRecord(value)?.turn)?.id;
405
+ if (typeof id !== 'string' || !id || id.length > 256)
406
+ throw new Error('CODEX_TURN_ID_MISSING');
407
+ return id;
408
+ }
409
+ function decodeThreadRead(value) {
410
+ const turns = asRecord(asRecord(value)?.thread)?.turns;
411
+ if (!Array.isArray(turns) || turns.length > 10_000)
412
+ throw new Error('CODEX_THREAD_READ_MALFORMED');
413
+ return turns.map((turn) => {
414
+ const record = asRecord(turn);
415
+ if (!record)
416
+ throw new Error('CODEX_THREAD_READ_MALFORMED');
417
+ return {
418
+ id: boundedString(record.id),
419
+ status: boundedString(record.status),
420
+ startedAt: typeof record.startedAt === 'number' ? record.startedAt : undefined,
421
+ completedAt: typeof record.completedAt === 'number' ? record.completedAt : undefined,
422
+ items: Array.isArray(record.items) ? record.items.flatMap(decodeHistoryItem) : undefined,
423
+ };
424
+ });
425
+ }
426
+ function boundedString(value, max = 64_000) {
427
+ return typeof value === 'string' && value.length <= max ? value : undefined;
428
+ }
429
+ function decodeHistoryItem(value) {
430
+ const item = asRecord(value);
431
+ const id = boundedString(item?.id, 256);
432
+ const type = boundedString(item?.type, 64);
433
+ if (!item || !id || !type)
434
+ return [];
435
+ if (type === 'agentMessage')
436
+ return [
437
+ {
438
+ id,
439
+ type,
440
+ ...(boundedString(item.text) ? { text: boundedString(item.text) } : {}),
441
+ ...(boundedString(item.phase, 64) ? { phase: boundedString(item.phase, 64) } : {}),
442
+ },
443
+ ];
444
+ if (type === 'plan' && boundedString(item.text))
445
+ return [{ id, type, text: boundedString(item.text) }];
446
+ if (type === 'commandExecution' && boundedString(item.command) && boundedString(item.status, 64))
447
+ return [
448
+ {
449
+ id,
450
+ type,
451
+ command: boundedString(item.command),
452
+ status: boundedString(item.status, 64),
453
+ ...(typeof item.exitCode === 'number' ? { exitCode: item.exitCode } : {}),
454
+ },
455
+ ];
456
+ return [];
457
+ }
@@ -12,12 +12,15 @@ export function threadPlanName(plan, limit = 120) {
12
12
  ? `L1 ${l1.indexOf(active) + 1}/${l1.length}`
13
13
  : awaiting
14
14
  ? `Review ${l1.indexOf(awaiting) + 1}/${l1.length}`
15
- : l1.length > 0 && l1.every((step) => step.state === 'DONE' && step.reviewStatus === 'REVIEWED')
15
+ : l1.length > 0 &&
16
+ l1.every((step) => step.state === 'DONE' && step.reviewStatus === 'REVIEWED')
16
17
  ? 'Complete'
17
18
  : 'Authoring';
18
19
  return `${truncateTitle(plan.title, Math.max(1, limit - suffix.length - 3))} — ${suffix}`;
19
20
  }
20
21
  function truncateTitle(value, limit) {
21
22
  const characters = Array.from(value);
22
- return characters.length <= limit ? value : `${characters.slice(0, Math.max(0, limit - 1)).join('')}…`;
23
+ return characters.length <= limit
24
+ ? value
25
+ : `${characters.slice(0, Math.max(0, limit - 1)).join('')}…`;
23
26
  }
@@ -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,13 @@
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
10
+ .code(status)
11
+ .type('application/problem+json')
12
+ .send(problem(code, status, detail));
13
+ }
@@ -29,10 +29,7 @@ export class FilesystemPlanStatusSource {
29
29
  await mkdir(this.dismissalDirectory, { recursive: true, mode: 0o700 });
30
30
  await mkdir(statusDirectory, { recursive: true, mode: 0o700 });
31
31
  if (process.platform !== 'win32') {
32
- await Promise.all([
33
- chmod(this.dismissalDirectory, 0o700),
34
- chmod(statusDirectory, 0o700),
35
- ]);
32
+ await Promise.all([chmod(this.dismissalDirectory, 0o700), chmod(statusDirectory, 0o700)]);
36
33
  }
37
34
  const lease = new ActiveLease(statusDirectory, session.id, session.workspacePath, listener, this.planReadFilesystem, (identity) => this.isDismissed(session.id, identity), (statusPath) => this.activeStatusPaths.set(session.id, statusPath), () => {
38
35
  this.leases.delete(session.id);
@@ -97,7 +94,9 @@ export class FilesystemPlanStatusSource {
97
94
  const next = new Set(dismissed).add(identity);
98
95
  const candidate = join(this.dismissalDirectory, `.${randomUUID()}.dismissals.tmp`);
99
96
  try {
100
- await this.dismissalFilesystem.writeFile(candidate, JSON.stringify([...next]), { mode: 0o600 });
97
+ await this.dismissalFilesystem.writeFile(candidate, JSON.stringify([...next]), {
98
+ mode: 0o600,
99
+ });
101
100
  await this.dismissalFilesystem.rename(candidate, path);
102
101
  }
103
102
  catch (error) {
@@ -272,7 +271,13 @@ class ActiveLease {
272
271
  const previousStatusPath = this.activeStatusPath;
273
272
  this.activeStatusPath = statusPath;
274
273
  this.onActiveStatusPath(statusPath);
275
- const update = { kind: 'updated', plan: result.plan, identity, planPath, reason: signal.reason };
274
+ const update = {
275
+ kind: 'updated',
276
+ plan: result.plan,
277
+ identity,
278
+ planPath,
279
+ reason: signal.reason,
280
+ };
276
281
  const serializedUpdate = JSON.stringify(update);
277
282
  if (serializedUpdate !== this.lastEmittedUpdate) {
278
283
  this.lastEmittedUpdate = serializedUpdate;
@@ -78,7 +78,10 @@ export class FilesystemWorkspacePlanCatalog {
78
78
  return { kind: 'unavailable' };
79
79
  const source = await this.filesystem.readFile(canonical, 'utf8');
80
80
  const after = await this.filesystem.stat(canonical);
81
- if (!after.isFile() || after.size !== before.size || after.ino !== before.ino || after.dev !== before.dev)
81
+ if (!after.isFile() ||
82
+ after.size !== before.size ||
83
+ after.ino !== before.ino ||
84
+ after.dev !== before.dev)
82
85
  return { kind: 'unavailable' };
83
86
  const parsed = parseSupervisedPlan({ source, planPath: canonical, workspacePath });
84
87
  return parsed.kind === 'available' ? parsed : { kind: 'unavailable' };
@@ -9,5 +9,8 @@ const execute = promisify(execFile);
9
9
  export const PLAN_MEASUREMENT_COMMAND_TIMEOUT_MS = 15_000;
10
10
  /** Invokes the explicitly configured Org Plan helper without a shell. */
11
11
  export async function checkpointPlanMeasurement(helperPath, planPath, stepId, snapshot) {
12
- await execute(helperPath, ['measure', 'checkpoint', planPath, stepId, JSON.stringify(snapshot)], { shell: false, timeout: PLAN_MEASUREMENT_COMMAND_TIMEOUT_MS });
12
+ await execute(helperPath, ['measure', 'checkpoint', planPath, stepId, JSON.stringify(snapshot)], {
13
+ shell: false,
14
+ timeout: PLAN_MEASUREMENT_COMMAND_TIMEOUT_MS,
15
+ });
13
16
  }