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.
- package/dist/client/assets/index-CMs5ZzxJ.css +1 -0
- package/dist/client/assets/index-D2sT9ZHY.js +19 -0
- package/dist/client/index.html +2 -2
- package/dist/server/server/app.js +22 -221
- package/dist/server/server/composition.js +4 -0
- 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/domain/workspace-plan-catalog.js +6 -0
- package/dist/server/server/features/plans/get-workspace-plan/endpoint.js +23 -0
- package/dist/server/server/features/plans/list-workspace-plans/endpoint.js +18 -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 +207 -111
- package/dist/server/server/platform/codex/thread-plan-name.js +23 -0
- package/dist/server/server/platform/http/problem-handler.js +10 -0
- package/dist/server/server/platform/http/problem-reply.js +10 -0
- package/dist/server/server/platform/plans/filesystem-workspace-plan-catalog.js +113 -0
- package/package.json +2 -1
- package/dist/client/assets/index-Bkm5GDCs.css +0 -1
- 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
|
-
|
|
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
|
-
|
|
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
|
|
82
|
+
const resource = await this.createResource(session);
|
|
39
83
|
try {
|
|
40
|
-
process.rpc.
|
|
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
|
-
|
|
49
|
-
this.
|
|
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
|
-
|
|
95
|
+
resource.dispose();
|
|
54
96
|
throw error;
|
|
55
97
|
}
|
|
56
98
|
}
|
|
57
99
|
stop(sessionId) {
|
|
58
|
-
this.
|
|
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
|
|
69
|
-
|
|
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
|
|
83
|
-
|
|
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
|
|
89
|
-
const
|
|
90
|
-
if (!
|
|
120
|
+
const resource = this.sessions.get(sessionId);
|
|
121
|
+
const pending = resource?.pendingRequests.get(requestId);
|
|
122
|
+
if (!resource || !pending)
|
|
91
123
|
return false;
|
|
92
|
-
|
|
93
|
-
|
|
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
|
|
98
|
-
if (!
|
|
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
|
-
|
|
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
|
|
111
|
-
if (!
|
|
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
|
|
117
|
-
if (!
|
|
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.
|
|
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
|
|
135
|
-
if (!
|
|
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
|
|
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
|
|
189
|
+
const resource = await this.createResource(session);
|
|
159
190
|
try {
|
|
160
|
-
process.rpc.
|
|
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
|
-
|
|
187
|
-
this.
|
|
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
|
-
|
|
221
|
+
resource.dispose();
|
|
192
222
|
throw error;
|
|
193
223
|
}
|
|
194
224
|
}
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
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
|
-
|
|
202
|
-
this.
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
323
|
+
lease?.close();
|
|
275
324
|
throw error;
|
|
276
325
|
}
|
|
277
326
|
}
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
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.
|
|
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",
|