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.
- package/dist/client/assets/index-D2sT9ZHY.js +19 -0
- package/dist/client/index.html +1 -1
- package/dist/server/server/app.js +22 -227
- package/dist/server/server/composition.js +0 -5
- package/dist/server/server/features/auth/application/ceremony-attempts.js +22 -0
- package/dist/server/server/features/auth/domain/errors.js +7 -0
- package/dist/server/server/features/auth/http/cookies.js +10 -0
- package/dist/server/server/features/auth/login/options/endpoint.js +22 -20
- package/dist/server/server/features/auth/login/verify/endpoint.js +6 -14
- package/dist/server/server/features/auth/logout/endpoint.js +2 -6
- package/dist/server/server/features/auth/register/options/endpoint.js +43 -11
- package/dist/server/server/features/auth/register/verify/endpoint.js +4 -3
- package/dist/server/server/features/auth/register-routes.js +41 -0
- package/dist/server/server/features/git/checkout-branch/endpoint.js +1 -1
- package/dist/server/server/features/git/clone-repository/endpoint.js +1 -1
- package/dist/server/server/features/git/register-routes.js +34 -0
- package/dist/server/server/features/plans/register-routes.js +22 -0
- package/dist/server/server/features/sessions/promote-recent-thread/endpoint.js +1 -1
- package/dist/server/server/features/sessions/register-routes.js +138 -0
- package/dist/server/server/features/sessions/respond-interaction/endpoint.js +1 -1
- package/dist/server/server/features/sessions/select-model/endpoint.js +1 -1
- package/dist/server/server/features/sessions/start-session/endpoint.js +5 -2
- package/dist/server/server/features/sessions/start-turn/endpoint.js +2 -1
- package/dist/server/server/features/skills/register-routes.js +17 -0
- package/dist/server/server/features/skills/replace-profile/endpoint.js +20 -5
- package/dist/server/server/platform/auth/sqlite-authorization-store.js +29 -6
- package/dist/server/server/platform/codex/normalizer.js +32 -6
- package/dist/server/server/platform/codex/protocol-compatibility.js +19 -1
- package/dist/server/server/platform/codex/server-request.js +2 -0
- package/dist/server/server/platform/codex/session-runtime.js +193 -125
- package/dist/server/server/platform/http/problem-handler.js +10 -0
- package/dist/server/server/platform/http/problem-reply.js +10 -0
- package/package.json +2 -1
- package/dist/client/assets/index-B7KwEjTE.js +0 -19
- 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
|
-
|
|
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
|
-
|
|
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
|
|
82
|
+
const resource = await this.createResource(session);
|
|
42
83
|
try {
|
|
43
|
-
process.rpc.
|
|
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
|
-
|
|
52
|
-
this.
|
|
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
|
-
|
|
95
|
+
resource.dispose();
|
|
58
96
|
throw error;
|
|
59
97
|
}
|
|
60
98
|
}
|
|
61
99
|
stop(sessionId) {
|
|
62
|
-
this.
|
|
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
|
|
75
|
-
|
|
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
|
|
89
|
-
|
|
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
|
|
95
|
-
const
|
|
96
|
-
if (!
|
|
120
|
+
const resource = this.sessions.get(sessionId);
|
|
121
|
+
const pending = resource?.pendingRequests.get(requestId);
|
|
122
|
+
if (!resource || !pending)
|
|
97
123
|
return false;
|
|
98
|
-
|
|
99
|
-
|
|
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
|
|
104
|
-
if (!
|
|
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
|
-
|
|
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
|
|
117
|
-
if (!
|
|
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
|
|
123
|
-
if (!
|
|
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.
|
|
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
|
|
141
|
-
if (!
|
|
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
|
|
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
|
|
189
|
+
const resource = await this.createResource(session);
|
|
165
190
|
try {
|
|
166
|
-
process.rpc.
|
|
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
|
-
|
|
193
|
-
this.
|
|
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
|
-
|
|
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.
|
|
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
|
|
209
|
-
const threadId =
|
|
210
|
-
const name =
|
|
211
|
-
if (!
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
this.
|
|
233
|
-
|
|
234
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
323
|
+
lease?.close();
|
|
303
324
|
throw error;
|
|
304
325
|
}
|
|
305
326
|
}
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
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.
|
|
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",
|