gestalt-mobile 0.7.0 → 0.9.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-D7-LoAAR.js +12 -0
- package/dist/client/assets/index-DrULMr7l.css +1 -0
- package/dist/client/index.html +2 -2
- package/dist/server/server/app.js +19 -1
- package/dist/server/server/cli.js +1 -0
- package/dist/server/server/composition.js +50 -4
- package/dist/server/server/features/plans/application/measurement-snapshot.js +41 -0
- package/dist/server/server/features/plans/application/parse-supervised-plan.js +48 -1
- package/dist/server/server/features/plans/get-measurement/endpoint.js +21 -0
- package/dist/server/server/features/plans/get-measurement/response.js +6 -0
- package/dist/server/server/features/sessions/list-recent-threads/endpoint.js +12 -6
- package/dist/server/server/features/sessions/start-turn/endpoint.js +1 -0
- package/dist/server/server/platform/codex/codex-process-launcher.js +2 -0
- package/dist/server/server/platform/codex/session-runtime.js +75 -3
- package/dist/server/server/platform/persistence/migrate.js +3 -1
- package/dist/server/server/platform/persistence/sqlite-session-repository.js +6 -2
- package/dist/server/server/platform/plans/filesystem-plan-status-source.js +9 -2
- package/dist/server/server/platform/plans/plan-measurement-command.js +13 -0
- package/dist/server/server/platform/plans/plan-measurement-refresh.js +93 -0
- package/package.json +1 -1
- package/dist/client/assets/index-CmTK_fRb.css +0 -1
- package/dist/client/assets/index-D9_bxMtt.js +0 -12
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
import { randomUUID } from 'node:crypto';
|
|
7
7
|
import { existsSync } from 'node:fs';
|
|
8
8
|
import { homedir } from 'node:os';
|
|
9
|
-
import { dirname, join, resolve } from 'node:path';
|
|
9
|
+
import { basename, dirname, join, resolve } from 'node:path';
|
|
10
10
|
import { buildApp } from './app.js';
|
|
11
11
|
import { FilesystemWorkspaceCatalog } from './platform/catalog/filesystem-workspace-catalog.js';
|
|
12
12
|
import { protocolCompatibility } from './platform/codex/protocol-compatibility.js';
|
|
@@ -38,6 +38,8 @@ import { CachedSkillCatalog } from './platform/skills/cached-skill-catalog.js';
|
|
|
38
38
|
import { compileSkillOverride } from './features/skills/model/skill-profile.js';
|
|
39
39
|
import { SupervisedPlanRegistry } from './features/plans/application/supervised-plan-registry.js';
|
|
40
40
|
import { FilesystemPlanStatusSource } from './platform/plans/filesystem-plan-status-source.js';
|
|
41
|
+
import { checkpointPlanMeasurement } from './platform/plans/plan-measurement-command.js';
|
|
42
|
+
import { PlanMeasurementRefresh } from './platform/plans/plan-measurement-refresh.js';
|
|
41
43
|
const generatedProtocolVersion = 'codex-cli 0.144.3';
|
|
42
44
|
export async function composeRelayApp(options) {
|
|
43
45
|
const root = resolve(options.root);
|
|
@@ -52,6 +54,7 @@ export async function composeRelayApp(options) {
|
|
|
52
54
|
const idempotency = new SqliteIdempotencyStore(database);
|
|
53
55
|
const supervisedPlans = new SupervisedPlanRegistry();
|
|
54
56
|
const planStatusSource = new FilesystemPlanStatusSource(join(dirname(databasePath), 'plans'));
|
|
57
|
+
const planMeasurementHelperPath = options.planMeasurementHelperPath ?? process.env.GESTALT_MOBILE_ORG_PLAN_HELPER;
|
|
55
58
|
const withPendingInteractions = (session) => (session ? { ...session, pendingInteractions: interactions.list(session.id) } : null);
|
|
56
59
|
const events = new SessionEventBus();
|
|
57
60
|
const workspaces = new FilesystemWorkspaceCatalog(root);
|
|
@@ -84,6 +87,7 @@ export async function composeRelayApp(options) {
|
|
|
84
87
|
const gitFetches = new GitFetchCoordinator(fetchUpstream);
|
|
85
88
|
const gitSummaries = new GitSummaryCache(inspectGit);
|
|
86
89
|
let recoverExitedSession = () => { };
|
|
90
|
+
let planMeasurementRefresh;
|
|
87
91
|
const runtime = options.startAppServers
|
|
88
92
|
? new CodexSessionRuntime(options.launchAppServer ?? launchCodexAppServer, undefined, (sessionId, notification) => {
|
|
89
93
|
const occurredAt = new Date().toISOString();
|
|
@@ -95,6 +99,7 @@ export async function composeRelayApp(options) {
|
|
|
95
99
|
const session = sessions.find(sessionId);
|
|
96
100
|
if (session && turnId && session.activeTurnId === turnId)
|
|
97
101
|
sessions.save(RelaySession.rehydrate(session).completeTurn(turnId, occurredAt).snapshot);
|
|
102
|
+
planMeasurementRefresh?.refreshNow(sessionId);
|
|
98
103
|
}
|
|
99
104
|
events.publish(journal.append(sessionId, normalized.type, normalized.payload, normalized.occurredAt));
|
|
100
105
|
}, (sessionId, request) => {
|
|
@@ -109,12 +114,31 @@ export async function composeRelayApp(options) {
|
|
|
109
114
|
return true;
|
|
110
115
|
}, (sessionId) => recoverExitedSession(sessionId), resolveSkills, planStatusSource, (sessionId, update) => {
|
|
111
116
|
supervisedPlans.accept(sessionId, update);
|
|
117
|
+
planMeasurementRefresh?.accept(sessionId, update);
|
|
112
118
|
if (update.kind === 'updated') {
|
|
113
119
|
const occurredAt = new Date().toISOString();
|
|
114
|
-
|
|
120
|
+
const session = sessions.find(sessionId);
|
|
121
|
+
if (session) {
|
|
122
|
+
const updated = {
|
|
123
|
+
...session,
|
|
124
|
+
lastOrgPlan: { filename: basename(update.planPath), title: update.plan.title },
|
|
125
|
+
updatedAt: occurredAt,
|
|
126
|
+
};
|
|
127
|
+
sessions.save(updated);
|
|
128
|
+
events.publish(journal.append(sessionId, 'session.updated', updated, occurredAt));
|
|
129
|
+
}
|
|
130
|
+
events.publish(journal.append(sessionId, 'plan.updated', { plan: update.plan, reason: update.reason }, occurredAt));
|
|
115
131
|
}
|
|
116
|
-
})
|
|
132
|
+
}, options.planMeasurementBaseUrl)
|
|
117
133
|
: null;
|
|
134
|
+
if (runtime && planMeasurementHelperPath) {
|
|
135
|
+
planMeasurementRefresh = new PlanMeasurementRefresh(async (sessionId) => {
|
|
136
|
+
const session = sessions.find(sessionId);
|
|
137
|
+
if (!session)
|
|
138
|
+
throw new Error('CODEX_SESSION_NOT_RUNNING');
|
|
139
|
+
return runtime.readPlanMeasurement(session);
|
|
140
|
+
}, (planPath, stepId, snapshot) => checkpointPlanMeasurement(planMeasurementHelperPath, planPath, stepId, snapshot));
|
|
141
|
+
}
|
|
118
142
|
const saveSession = (session) => {
|
|
119
143
|
sessions.save(session);
|
|
120
144
|
events.publish(journal.append(session.id, 'session.updated', session, session.updatedAt));
|
|
@@ -188,6 +212,7 @@ export async function composeRelayApp(options) {
|
|
|
188
212
|
startTurn: runtime
|
|
189
213
|
? async (session, text) => runtime.startTurn(session, text, new Date().toISOString())
|
|
190
214
|
: undefined,
|
|
215
|
+
onTurnStarted: (session) => planMeasurementRefresh?.refreshNow(session.id),
|
|
191
216
|
models,
|
|
192
217
|
readHistory: runtime ? (session) => runtime.readHistory(session) : undefined,
|
|
193
218
|
currentSequence: (sessionId) => journal.since(sessionId, 0).at(-1)?.sequence ?? 0,
|
|
@@ -209,7 +234,12 @@ export async function composeRelayApp(options) {
|
|
|
209
234
|
release: (session) => RelaySession.rehydrate(session).release(new Date().toISOString()).snapshot,
|
|
210
235
|
remove: (id) => sessions.remove(id),
|
|
211
236
|
idempotency,
|
|
212
|
-
close: runtime
|
|
237
|
+
close: runtime
|
|
238
|
+
? (id) => {
|
|
239
|
+
planMeasurementRefresh?.stop(id);
|
|
240
|
+
return runtime.release(id);
|
|
241
|
+
}
|
|
242
|
+
: undefined,
|
|
213
243
|
replyInteraction: runtime
|
|
214
244
|
? (sessionId, requestId, value) => runtime.resolveServerRequest(sessionId, requestId, value)
|
|
215
245
|
: undefined,
|
|
@@ -228,10 +258,25 @@ export async function composeRelayApp(options) {
|
|
|
228
258
|
removeStatus: (id) => planStatusSource.remove(id, supervisedPlans.identity(id) ?? undefined),
|
|
229
259
|
clear: (id) => supervisedPlans.clear(id),
|
|
230
260
|
closed: (id) => {
|
|
261
|
+
planMeasurementRefresh?.stop(id);
|
|
231
262
|
const occurredAt = new Date().toISOString();
|
|
232
263
|
events.publish(journal.append(id, 'plan.closed', {}, occurredAt));
|
|
233
264
|
},
|
|
234
265
|
},
|
|
266
|
+
...(runtime
|
|
267
|
+
? {
|
|
268
|
+
planMeasurementRoutes: {
|
|
269
|
+
exists: (id) => sessions.find(id) !== null,
|
|
270
|
+
authorize: (id, authorization) => runtime.authorizePlanMeasurement(id, authorization),
|
|
271
|
+
read: async (id) => {
|
|
272
|
+
const session = sessions.find(id);
|
|
273
|
+
if (!session)
|
|
274
|
+
throw new Error('CODEX_SESSION_NOT_RUNNING');
|
|
275
|
+
return runtime.readPlanMeasurement(session);
|
|
276
|
+
},
|
|
277
|
+
},
|
|
278
|
+
}
|
|
279
|
+
: {}),
|
|
235
280
|
interactions: {
|
|
236
281
|
resolve: (sessionId, requestId, resolvedAt) => interactions.resolve(sessionId, requestId, resolvedAt),
|
|
237
282
|
validate: (sessionId, requestId, value) => {
|
|
@@ -292,6 +337,7 @@ export async function composeRelayApp(options) {
|
|
|
292
337
|
await restoreActiveSessions();
|
|
293
338
|
});
|
|
294
339
|
app.addHook('onClose', async () => {
|
|
340
|
+
planMeasurementRefresh?.stopAll();
|
|
295
341
|
runtime?.stopAll();
|
|
296
342
|
planStatusSource.closeAll();
|
|
297
343
|
database.close();
|
|
@@ -0,0 +1,41 @@
|
|
|
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
|
+
export function createPlanMeasurementSnapshot(input) {
|
|
7
|
+
return Object.freeze({
|
|
8
|
+
capturedAt: input.capturedAt,
|
|
9
|
+
weeklyRemainingPercent: weeklyRemainingPercent(input.rateLimits),
|
|
10
|
+
threadTokens: totalThreadTokens(input.tokenUsage),
|
|
11
|
+
});
|
|
12
|
+
}
|
|
13
|
+
/** Returns the longest valid rate-limit window, which is the weekly window. */
|
|
14
|
+
export function weeklyRateLimitWindow(windows) {
|
|
15
|
+
if (!windows)
|
|
16
|
+
return null;
|
|
17
|
+
const valid = windows.filter((window) => Number.isFinite(window.durationSeconds) &&
|
|
18
|
+
window.durationSeconds > 0 &&
|
|
19
|
+
Number.isFinite(window.usedPercent) &&
|
|
20
|
+
window.usedPercent >= 0 &&
|
|
21
|
+
window.usedPercent <= 100);
|
|
22
|
+
return valid.reduce((weekly, window) => (!weekly || window.durationSeconds > weekly.durationSeconds ? window : weekly), null);
|
|
23
|
+
}
|
|
24
|
+
export function weeklyRemainingPercent(windows) {
|
|
25
|
+
const weekly = weeklyRateLimitWindow(windows);
|
|
26
|
+
return weekly ? 100 - weekly.usedPercent : null;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Adds only the three independent cumulative counters in the normalized
|
|
30
|
+
* adapter contract. A missing or invalid counter makes token usage unavailable
|
|
31
|
+
* instead of silently treating it as zero.
|
|
32
|
+
*/
|
|
33
|
+
export function totalThreadTokens(usage) {
|
|
34
|
+
if (!usage || !Object.values(usage).every(isNonNegativeSafeInteger))
|
|
35
|
+
return null;
|
|
36
|
+
const total = usage.inputTokens + usage.cachedInputTokens + usage.outputTokens;
|
|
37
|
+
return Number.isSafeInteger(total) ? total : null;
|
|
38
|
+
}
|
|
39
|
+
function isNonNegativeSafeInteger(value) {
|
|
40
|
+
return Number.isSafeInteger(value) && value >= 0;
|
|
41
|
+
}
|
|
@@ -142,7 +142,8 @@ function buildSteps(headings) {
|
|
|
142
142
|
}
|
|
143
143
|
function makeStep(heading) {
|
|
144
144
|
const description = descriptionFor(heading);
|
|
145
|
-
|
|
145
|
+
const measurement = measurementFor(heading);
|
|
146
|
+
if (!description || measurement === null)
|
|
146
147
|
return null;
|
|
147
148
|
if (heading.level === 1) {
|
|
148
149
|
const reviewStatus = heading.properties.get('REVIEW_STATUS');
|
|
@@ -158,6 +159,7 @@ function makeStep(heading) {
|
|
|
158
159
|
reviewStatus: reviewStatus,
|
|
159
160
|
skills: skills.split(/\s+/).filter(Boolean),
|
|
160
161
|
description,
|
|
162
|
+
...(measurement ? { measurement } : {}),
|
|
161
163
|
children: [],
|
|
162
164
|
};
|
|
163
165
|
}
|
|
@@ -170,9 +172,54 @@ function makeStep(heading) {
|
|
|
170
172
|
state: heading.state,
|
|
171
173
|
priority: heading.priority,
|
|
172
174
|
description,
|
|
175
|
+
...(measurement ? { measurement } : {}),
|
|
173
176
|
children: [],
|
|
174
177
|
};
|
|
175
178
|
}
|
|
179
|
+
const measurementProperties = {
|
|
180
|
+
STARTED_AT: 'startedAt',
|
|
181
|
+
UPDATED_AT: 'updatedAt',
|
|
182
|
+
COMPLETED_AT: 'completedAt',
|
|
183
|
+
ELAPSED_SECONDS: 'elapsedSeconds',
|
|
184
|
+
WEEKLY_REMAINING_START: 'weeklyRemainingStart',
|
|
185
|
+
WEEKLY_REMAINING_CURRENT: 'weeklyRemainingCurrent',
|
|
186
|
+
WEEKLY_REMAINING_END: 'weeklyRemainingEnd',
|
|
187
|
+
WEEKLY_PERCENT_USED: 'weeklyPercentUsed',
|
|
188
|
+
TOKENS_START: 'tokensStart',
|
|
189
|
+
TOKENS_CURRENT: 'tokensCurrent',
|
|
190
|
+
TOKENS_END: 'tokensEnd',
|
|
191
|
+
TOKENS_USED: 'tokensUsed',
|
|
192
|
+
};
|
|
193
|
+
function measurementFor(heading) {
|
|
194
|
+
const measurement = {};
|
|
195
|
+
for (const [property, field] of Object.entries(measurementProperties)) {
|
|
196
|
+
const value = heading.properties.get(property);
|
|
197
|
+
if (value === undefined)
|
|
198
|
+
continue;
|
|
199
|
+
if (property.endsWith('_AT')) {
|
|
200
|
+
if (!isUtcIsoInstant(value))
|
|
201
|
+
return null;
|
|
202
|
+
measurement[field] = value;
|
|
203
|
+
continue;
|
|
204
|
+
}
|
|
205
|
+
if (!/^\d+$/.test(value))
|
|
206
|
+
return null;
|
|
207
|
+
const number = Number(value);
|
|
208
|
+
if (!Number.isSafeInteger(number) || (property.startsWith('WEEKLY_') && number > 100))
|
|
209
|
+
return null;
|
|
210
|
+
measurement[field] = number;
|
|
211
|
+
}
|
|
212
|
+
return Object.keys(measurement).length === 0 ? undefined : measurement;
|
|
213
|
+
}
|
|
214
|
+
function isUtcIsoInstant(value) {
|
|
215
|
+
if (!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{3})?Z$/.test(value))
|
|
216
|
+
return false;
|
|
217
|
+
const instant = new Date(value);
|
|
218
|
+
if (Number.isNaN(instant.getTime()))
|
|
219
|
+
return false;
|
|
220
|
+
const canonical = instant.toISOString();
|
|
221
|
+
return value === canonical || value === canonical.replace('.000Z', 'Z');
|
|
222
|
+
}
|
|
176
223
|
function descriptionFor(heading) {
|
|
177
224
|
const fields = heading.level === 1 ? ['Effort', 'Goal', 'Notes'] : ['Why', 'Change', 'Tests', 'Done when'];
|
|
178
225
|
if (!fields.every((field) => heading.descriptions.has(field)))
|
|
@@ -0,0 +1,21 @@
|
|
|
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
|
+
export function registerGetPlanMeasurement(app, deps) {
|
|
7
|
+
app.get('/api/sessions/:id/plan-measurement', async (request, reply) => {
|
|
8
|
+
const id = request.params.id;
|
|
9
|
+
const authorization = typeof request.headers.authorization === 'string'
|
|
10
|
+
? request.headers.authorization
|
|
11
|
+
: undefined;
|
|
12
|
+
if (!deps.exists(id) || !deps.authorize(id, authorization))
|
|
13
|
+
return reply.code(404).send({ code: 'SESSION_NOT_FOUND' });
|
|
14
|
+
try {
|
|
15
|
+
return reply.send(await deps.read(id));
|
|
16
|
+
}
|
|
17
|
+
catch {
|
|
18
|
+
return reply.code(503).send({ code: 'PLAN_MEASUREMENT_UNAVAILABLE' });
|
|
19
|
+
}
|
|
20
|
+
});
|
|
21
|
+
}
|
|
@@ -7,11 +7,17 @@ import { buildResumeCommand } from '../application/resume-command.js';
|
|
|
7
7
|
export function registerListRecentThreads(app, deps) {
|
|
8
8
|
app.get('/api/sessions/recent-threads', async () => {
|
|
9
9
|
const threads = await deps.list();
|
|
10
|
-
return threads.map(({ id, cwd, profile, recencyAt }) =>
|
|
11
|
-
id
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
10
|
+
return threads.map(({ id, cwd, profile, recencyAt }) => {
|
|
11
|
+
const metadata = deps.metadata?.(id);
|
|
12
|
+
return {
|
|
13
|
+
id,
|
|
14
|
+
cwd,
|
|
15
|
+
recencyAt,
|
|
16
|
+
...(metadata?.model === undefined ? {} : { model: metadata.model }),
|
|
17
|
+
...(metadata?.skillProfile === undefined ? {} : { skillProfile: metadata.skillProfile }),
|
|
18
|
+
...(metadata?.orgPlanFilename === undefined ? {} : { orgPlanFilename: metadata.orgPlanFilename }),
|
|
19
|
+
resumeCommand: buildResumeCommand({ profile, threadId: id, workspacePath: cwd }),
|
|
20
|
+
};
|
|
21
|
+
});
|
|
16
22
|
});
|
|
17
23
|
}
|
|
@@ -15,6 +15,7 @@ export function registerStartTurn(app, deps) {
|
|
|
15
15
|
return reply.code(409).send({ code: 'SESSION_NOT_READY' });
|
|
16
16
|
const started = await deps.start(session, text);
|
|
17
17
|
deps.save(started);
|
|
18
|
+
deps.onStarted?.(started);
|
|
18
19
|
return reply.code(202).send(started);
|
|
19
20
|
});
|
|
20
21
|
}
|
|
@@ -31,5 +31,7 @@ export function codexChildEnvironment(environment) {
|
|
|
31
31
|
const inherited = { ...process.env };
|
|
32
32
|
delete inherited.GESTALT_MOBILE_ORG_PLAN_STATUS_FILE;
|
|
33
33
|
delete inherited.GESTALT_MOBILE_ORG_PLAN_STATUS_DIRECTORY;
|
|
34
|
+
delete inherited.GESTALT_MOBILE_ORG_PLAN_MEASUREMENT_URL;
|
|
35
|
+
delete inherited.GESTALT_MOBILE_ORG_PLAN_MEASUREMENT_TOKEN;
|
|
34
36
|
return { ...inherited, ...environment };
|
|
35
37
|
}
|
|
@@ -4,6 +4,8 @@
|
|
|
4
4
|
* SPDX-License-Identifier: AGPL-3.0-or-later
|
|
5
5
|
*/
|
|
6
6
|
import { RelaySession, } from '../../features/sessions/model/relay-session.js';
|
|
7
|
+
import { randomUUID } from 'node:crypto';
|
|
8
|
+
import { createPlanMeasurementSnapshot, } from '../../features/plans/application/measurement-snapshot.js';
|
|
7
9
|
import { gestaltQuizDynamicTool } from '../../../shared/contracts/quiz.js';
|
|
8
10
|
export class CodexSessionRuntime {
|
|
9
11
|
launch;
|
|
@@ -14,7 +16,8 @@ export class CodexSessionRuntime {
|
|
|
14
16
|
resolveSkills;
|
|
15
17
|
planStatusSource;
|
|
16
18
|
onPlanStatus;
|
|
17
|
-
|
|
19
|
+
planMeasurementBaseUrl;
|
|
20
|
+
constructor(launch, processes = new Map(), onNotification, onServerRequest, onProcessExit, resolveSkills, planStatusSource, onPlanStatus, planMeasurementBaseUrl) {
|
|
18
21
|
this.launch = launch;
|
|
19
22
|
this.processes = processes;
|
|
20
23
|
this.onNotification = onNotification;
|
|
@@ -23,11 +26,13 @@ export class CodexSessionRuntime {
|
|
|
23
26
|
this.resolveSkills = resolveSkills;
|
|
24
27
|
this.planStatusSource = planStatusSource;
|
|
25
28
|
this.onPlanStatus = onPlanStatus;
|
|
29
|
+
this.planMeasurementBaseUrl = planMeasurementBaseUrl;
|
|
26
30
|
}
|
|
27
31
|
pendingRequests = new Map();
|
|
28
32
|
exitUnsubscribers = new Map();
|
|
29
33
|
threadIds = new Map();
|
|
30
34
|
planStatusLeases = new Map();
|
|
35
|
+
planMeasurementTokens = new Map();
|
|
31
36
|
async start(session, now, settings = {}) {
|
|
32
37
|
const process = await this.launchForSession(session);
|
|
33
38
|
try {
|
|
@@ -113,6 +118,24 @@ export class CodexSessionRuntime {
|
|
|
113
118
|
throw new Error('CODEX_SESSION_NOT_RUNNING');
|
|
114
119
|
await process.rpc.request('turn/interrupt', { threadId: session.threadId, turnId });
|
|
115
120
|
}
|
|
121
|
+
async readPlanMeasurement(session) {
|
|
122
|
+
const process = this.processes.get(session.id);
|
|
123
|
+
if (!process || !session.threadId)
|
|
124
|
+
throw new Error('CODEX_SESSION_NOT_RUNNING');
|
|
125
|
+
const [rateLimits, thread] = await Promise.all([
|
|
126
|
+
process.rpc.request('account/rateLimits/read', {}),
|
|
127
|
+
process.rpc.request('thread/read', { threadId: session.threadId, includeTurns: true }),
|
|
128
|
+
]);
|
|
129
|
+
return createPlanMeasurementSnapshot({
|
|
130
|
+
capturedAt: new Date().toISOString(),
|
|
131
|
+
rateLimits: rateLimitWindows(rateLimits),
|
|
132
|
+
tokenUsage: threadTokenUsage(thread),
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
authorizePlanMeasurement(sessionId, authorization) {
|
|
136
|
+
const token = this.planMeasurementTokens.get(sessionId);
|
|
137
|
+
return Boolean(token && authorization === `Bearer ${token}`);
|
|
138
|
+
}
|
|
116
139
|
async readHistory(session) {
|
|
117
140
|
const process = this.processes.get(session.id);
|
|
118
141
|
if (!process || !session.threadId)
|
|
@@ -169,6 +192,7 @@ export class CodexSessionRuntime {
|
|
|
169
192
|
this.exitUnsubscribers.set(sessionId, process.onExit?.(() => {
|
|
170
193
|
this.processes.delete(sessionId);
|
|
171
194
|
this.threadIds.delete(sessionId);
|
|
195
|
+
this.planMeasurementTokens.delete(sessionId);
|
|
172
196
|
this.exitUnsubscribers.delete(sessionId);
|
|
173
197
|
this.releasePlanStatus(sessionId);
|
|
174
198
|
this.onProcessExit?.(sessionId);
|
|
@@ -181,14 +205,24 @@ export class CodexSessionRuntime {
|
|
|
181
205
|
if (lease)
|
|
182
206
|
this.planStatusLeases.set(session.id, lease);
|
|
183
207
|
try {
|
|
208
|
+
const token = randomUUID();
|
|
209
|
+
this.planMeasurementTokens.set(session.id, token);
|
|
184
210
|
return this.launch({
|
|
185
211
|
profile: session.profile,
|
|
186
212
|
cwd: session.workspacePath,
|
|
187
213
|
skillsConfig: await this.resolveSkills?.(session),
|
|
188
|
-
...(lease
|
|
214
|
+
...((lease || this.planMeasurementBaseUrl)
|
|
189
215
|
? {
|
|
190
216
|
environment: {
|
|
191
|
-
|
|
217
|
+
...(lease
|
|
218
|
+
? { GESTALT_MOBILE_ORG_PLAN_STATUS_DIRECTORY: lease.statusDirectory }
|
|
219
|
+
: {}),
|
|
220
|
+
...(this.planMeasurementBaseUrl
|
|
221
|
+
? {
|
|
222
|
+
GESTALT_MOBILE_ORG_PLAN_MEASUREMENT_URL: `${this.planMeasurementBaseUrl}/api/sessions/${session.id}/plan-measurement`,
|
|
223
|
+
GESTALT_MOBILE_ORG_PLAN_MEASUREMENT_TOKEN: token,
|
|
224
|
+
}
|
|
225
|
+
: {}),
|
|
192
226
|
},
|
|
193
227
|
}
|
|
194
228
|
: {}),
|
|
@@ -204,3 +238,41 @@ export class CodexSessionRuntime {
|
|
|
204
238
|
this.planStatusLeases.delete(sessionId);
|
|
205
239
|
}
|
|
206
240
|
}
|
|
241
|
+
function rateLimitWindows(value) {
|
|
242
|
+
const limits = asRecord(value)?.rateLimits;
|
|
243
|
+
if (!Array.isArray(limits))
|
|
244
|
+
return undefined;
|
|
245
|
+
return limits.flatMap((limit) => {
|
|
246
|
+
const record = asRecord(limit);
|
|
247
|
+
const durationMinutes = record?.windowDurationMins;
|
|
248
|
+
const usedPercent = record?.usedPercent;
|
|
249
|
+
return typeof durationMinutes === 'number' && typeof usedPercent === 'number'
|
|
250
|
+
? [{ durationSeconds: durationMinutes * 60, usedPercent }]
|
|
251
|
+
: [];
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
function threadTokenUsage(value) {
|
|
255
|
+
const turns = asRecord(asRecord(value)?.thread)?.turns;
|
|
256
|
+
if (!Array.isArray(turns))
|
|
257
|
+
return undefined;
|
|
258
|
+
let inputTokens = 0;
|
|
259
|
+
let cachedInputTokens = 0;
|
|
260
|
+
let outputTokens = 0;
|
|
261
|
+
for (const turn of turns) {
|
|
262
|
+
const usage = asRecord(asRecord(turn)?.tokenUsage);
|
|
263
|
+
if (!usage)
|
|
264
|
+
return undefined;
|
|
265
|
+
const input = usage.inputTokens;
|
|
266
|
+
const cached = usage.cachedInputTokens;
|
|
267
|
+
const output = usage.outputTokens;
|
|
268
|
+
if (typeof input !== 'number' || typeof cached !== 'number' || typeof output !== 'number')
|
|
269
|
+
return undefined;
|
|
270
|
+
inputTokens += input;
|
|
271
|
+
cachedInputTokens += cached;
|
|
272
|
+
outputTokens += output;
|
|
273
|
+
}
|
|
274
|
+
return { inputTokens, cachedInputTokens, outputTokens };
|
|
275
|
+
}
|
|
276
|
+
function asRecord(value) {
|
|
277
|
+
return value && typeof value === 'object' ? value : undefined;
|
|
278
|
+
}
|
|
@@ -3,12 +3,14 @@
|
|
|
3
3
|
* Designed by Denis Roio <jaromil@dyne.org>
|
|
4
4
|
* SPDX-License-Identifier: AGPL-3.0-or-later
|
|
5
5
|
*/
|
|
6
|
-
const schema = `CREATE TABLE IF NOT EXISTS relay_sessions (id TEXT PRIMARY KEY, workspace_id TEXT NOT NULL, workspace_path TEXT NOT NULL, profile TEXT NOT NULL, model TEXT, branch TEXT, thread_id TEXT, state TEXT NOT NULL, desired_state TEXT NOT NULL, active_turn_id TEXT, protocol_version TEXT, failure_count INTEGER NOT NULL DEFAULT 0, effective_skill_selection_json TEXT, next_sequence INTEGER NOT NULL DEFAULT 1, created_at TEXT NOT NULL, updated_at TEXT NOT NULL); CREATE TABLE IF NOT EXISTS pending_interactions (session_id TEXT NOT NULL REFERENCES relay_sessions(id) ON DELETE CASCADE, request_id TEXT NOT NULL, kind TEXT NOT NULL, payload_json TEXT NOT NULL, resolved_at TEXT, PRIMARY KEY (session_id, request_id)); CREATE TABLE IF NOT EXISTS session_events (session_id TEXT NOT NULL REFERENCES relay_sessions(id) ON DELETE CASCADE, sequence INTEGER NOT NULL, occurred_at TEXT NOT NULL, type TEXT NOT NULL, payload_json TEXT NOT NULL, PRIMARY KEY (session_id, sequence)); CREATE TABLE IF NOT EXISTS idempotency_results (scope TEXT NOT NULL, key TEXT NOT NULL, status_code INTEGER NOT NULL, body_json TEXT NOT NULL, PRIMARY KEY (scope, key));`;
|
|
6
|
+
const schema = `CREATE TABLE IF NOT EXISTS relay_sessions (id TEXT PRIMARY KEY, workspace_id TEXT NOT NULL, workspace_path TEXT NOT NULL, profile TEXT NOT NULL, model TEXT, branch TEXT, thread_id TEXT, state TEXT NOT NULL, desired_state TEXT NOT NULL, active_turn_id TEXT, protocol_version TEXT, failure_count INTEGER NOT NULL DEFAULT 0, effective_skill_selection_json TEXT, last_org_plan_json TEXT, next_sequence INTEGER NOT NULL DEFAULT 1, created_at TEXT NOT NULL, updated_at TEXT NOT NULL); CREATE TABLE IF NOT EXISTS pending_interactions (session_id TEXT NOT NULL REFERENCES relay_sessions(id) ON DELETE CASCADE, request_id TEXT NOT NULL, kind TEXT NOT NULL, payload_json TEXT NOT NULL, resolved_at TEXT, PRIMARY KEY (session_id, request_id)); CREATE TABLE IF NOT EXISTS session_events (session_id TEXT NOT NULL REFERENCES relay_sessions(id) ON DELETE CASCADE, sequence INTEGER NOT NULL, occurred_at TEXT NOT NULL, type TEXT NOT NULL, payload_json TEXT NOT NULL, PRIMARY KEY (session_id, sequence)); CREATE TABLE IF NOT EXISTS idempotency_results (scope TEXT NOT NULL, key TEXT NOT NULL, status_code INTEGER NOT NULL, body_json TEXT NOT NULL, PRIMARY KEY (scope, key));`;
|
|
7
7
|
export function migrate(database) {
|
|
8
8
|
database.exec(schema);
|
|
9
9
|
const columns = database.prepare('PRAGMA table_info(relay_sessions)').all();
|
|
10
10
|
if (!columns.some((column) => column.name === 'effective_skill_selection_json'))
|
|
11
11
|
database.exec('ALTER TABLE relay_sessions ADD COLUMN effective_skill_selection_json TEXT');
|
|
12
|
+
if (!columns.some((column) => column.name === 'last_org_plan_json'))
|
|
13
|
+
database.exec('ALTER TABLE relay_sessions ADD COLUMN last_org_plan_json TEXT');
|
|
12
14
|
if (!columns.some((column) => column.name === 'model'))
|
|
13
15
|
database.exec('ALTER TABLE relay_sessions ADD COLUMN model TEXT');
|
|
14
16
|
if (!columns.some((column) => column.name === 'branch'))
|
|
@@ -11,10 +11,10 @@ export class SqliteSessionRepository {
|
|
|
11
11
|
}
|
|
12
12
|
save(session) {
|
|
13
13
|
this.db
|
|
14
|
-
.prepare('INSERT INTO relay_sessions (id,workspace_id,workspace_path,profile,model,branch,thread_id,state,desired_state,active_turn_id,protocol_version,failure_count,effective_skill_selection_json,created_at,updated_at) VALUES (
|
|
14
|
+
.prepare('INSERT INTO relay_sessions (id,workspace_id,workspace_path,profile,model,branch,thread_id,state,desired_state,active_turn_id,protocol_version,failure_count,effective_skill_selection_json,last_org_plan_json,created_at,updated_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) ON CONFLICT(id) DO UPDATE SET workspace_id=excluded.workspace_id,workspace_path=excluded.workspace_path,profile=excluded.profile,model=excluded.model,branch=excluded.branch,thread_id=excluded.thread_id,state=excluded.state,desired_state=excluded.desired_state,active_turn_id=excluded.active_turn_id,protocol_version=excluded.protocol_version,failure_count=excluded.failure_count,effective_skill_selection_json=excluded.effective_skill_selection_json,last_org_plan_json=excluded.last_org_plan_json,updated_at=excluded.updated_at')
|
|
15
15
|
.run(session.id, session.workspaceId, session.workspacePath, session.profile, session.model ?? null, session.branch ?? null, session.threadId, session.state, session.desiredState, session.activeTurnId, session.protocolVersion, session.failureCount, session.effectiveSkillSelection === undefined
|
|
16
16
|
? null
|
|
17
|
-
: JSON.stringify(session.effectiveSkillSelection), session.createdAt, session.updatedAt);
|
|
17
|
+
: JSON.stringify(session.effectiveSkillSelection), session.lastOrgPlan === undefined ? null : JSON.stringify(session.lastOrgPlan), session.createdAt, session.updatedAt);
|
|
18
18
|
}
|
|
19
19
|
find(id) {
|
|
20
20
|
const row = this.db.prepare('SELECT * FROM relay_sessions WHERE id = ?').get(id);
|
|
@@ -31,6 +31,9 @@ function map(row) {
|
|
|
31
31
|
const effectiveSkillSelection = row.effective_skill_selection_json
|
|
32
32
|
? createEffectiveSkillSelection(JSON.parse(row.effective_skill_selection_json))
|
|
33
33
|
: undefined;
|
|
34
|
+
const lastOrgPlan = row.last_org_plan_json
|
|
35
|
+
? JSON.parse(row.last_org_plan_json)
|
|
36
|
+
: undefined;
|
|
34
37
|
return {
|
|
35
38
|
id: row.id,
|
|
36
39
|
workspaceId: row.workspace_id,
|
|
@@ -45,6 +48,7 @@ function map(row) {
|
|
|
45
48
|
protocolVersion: row.protocol_version,
|
|
46
49
|
failureCount: row.failure_count,
|
|
47
50
|
...(effectiveSkillSelection === undefined ? {} : { effectiveSkillSelection }),
|
|
51
|
+
...(lastOrgPlan === undefined ? {} : { lastOrgPlan }),
|
|
48
52
|
pendingInteractions: [],
|
|
49
53
|
createdAt: row.created_at,
|
|
50
54
|
updatedAt: row.updated_at,
|
|
@@ -259,7 +259,7 @@ class ActiveLease {
|
|
|
259
259
|
const previousStatusPath = this.activeStatusPath;
|
|
260
260
|
this.activeStatusPath = statusPath;
|
|
261
261
|
this.onActiveStatusPath(statusPath);
|
|
262
|
-
this.listener({ kind: 'updated', plan: result.plan, identity });
|
|
262
|
+
this.listener({ kind: 'updated', plan: result.plan, identity, planPath, reason: signal.reason });
|
|
263
263
|
if (previousStatusPath && previousStatusPath !== statusPath)
|
|
264
264
|
await rm(previousStatusPath, { force: true }).catch(() => { });
|
|
265
265
|
}
|
|
@@ -288,12 +288,19 @@ function parseSignal(source) {
|
|
|
288
288
|
return null;
|
|
289
289
|
if (typeof signal.reason !== 'string' || !isRfc3339Utc(signal.updatedAt))
|
|
290
290
|
return null;
|
|
291
|
-
return {
|
|
291
|
+
return {
|
|
292
|
+
planPath: signal.planPath,
|
|
293
|
+
updatedAt: signal.updatedAt,
|
|
294
|
+
reason: isPlanSignalReason(signal.reason) ? signal.reason : null,
|
|
295
|
+
};
|
|
292
296
|
}
|
|
293
297
|
catch {
|
|
294
298
|
return null;
|
|
295
299
|
}
|
|
296
300
|
}
|
|
301
|
+
function isPlanSignalReason(value) {
|
|
302
|
+
return value === 'authoring-start' || value === 'work-start' || value === 'checkpoint' || value === 'update';
|
|
303
|
+
}
|
|
297
304
|
function isRfc3339Utc(value) {
|
|
298
305
|
return (typeof value === 'string' &&
|
|
299
306
|
/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z$/.test(value) &&
|
|
@@ -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 { execFile } from 'node:child_process';
|
|
7
|
+
import { promisify } from 'node:util';
|
|
8
|
+
const execute = promisify(execFile);
|
|
9
|
+
export const PLAN_MEASUREMENT_COMMAND_TIMEOUT_MS = 15_000;
|
|
10
|
+
/** Invokes the explicitly configured Org Plan helper without a shell. */
|
|
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 });
|
|
13
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
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
|
+
export const PLAN_MEASUREMENT_REFRESH_MS = 60_000;
|
|
7
|
+
/**
|
|
8
|
+
* Refreshes the current WIP step at a bounded cadence. A refresh is deliberately
|
|
9
|
+
* owned by the session that owns the plan, so replacing, closing, or stopping a
|
|
10
|
+
* session cannot leave a timer writing another session's plan.
|
|
11
|
+
*/
|
|
12
|
+
export class PlanMeasurementRefresh {
|
|
13
|
+
readSnapshot;
|
|
14
|
+
checkpoint;
|
|
15
|
+
active = new Map();
|
|
16
|
+
timers = new Map();
|
|
17
|
+
inFlight = new Set();
|
|
18
|
+
constructor(readSnapshot, checkpoint) {
|
|
19
|
+
this.readSnapshot = readSnapshot;
|
|
20
|
+
this.checkpoint = checkpoint;
|
|
21
|
+
}
|
|
22
|
+
accept(sessionId, update) {
|
|
23
|
+
const next = activeMeasurement(update);
|
|
24
|
+
if (!next)
|
|
25
|
+
return this.stop(sessionId);
|
|
26
|
+
const current = this.active.get(sessionId);
|
|
27
|
+
this.active.set(sessionId, next);
|
|
28
|
+
if (current?.planPath === next.planPath && current.stepId === next.stepId)
|
|
29
|
+
return;
|
|
30
|
+
this.clearTimer(sessionId);
|
|
31
|
+
this.schedule(sessionId);
|
|
32
|
+
}
|
|
33
|
+
stop(sessionId) {
|
|
34
|
+
this.active.delete(sessionId);
|
|
35
|
+
this.clearTimer(sessionId);
|
|
36
|
+
}
|
|
37
|
+
stopAll() {
|
|
38
|
+
for (const sessionId of this.active.keys())
|
|
39
|
+
this.stop(sessionId);
|
|
40
|
+
}
|
|
41
|
+
refreshNow(sessionId) {
|
|
42
|
+
this.clearTimer(sessionId);
|
|
43
|
+
void this.refresh(sessionId);
|
|
44
|
+
}
|
|
45
|
+
schedule(sessionId) {
|
|
46
|
+
this.timers.set(sessionId, setTimeout(() => {
|
|
47
|
+
this.timers.delete(sessionId);
|
|
48
|
+
void this.refresh(sessionId);
|
|
49
|
+
}, PLAN_MEASUREMENT_REFRESH_MS));
|
|
50
|
+
}
|
|
51
|
+
async refresh(sessionId) {
|
|
52
|
+
const active = this.active.get(sessionId);
|
|
53
|
+
if (!active || this.inFlight.has(sessionId))
|
|
54
|
+
return;
|
|
55
|
+
this.inFlight.add(sessionId);
|
|
56
|
+
try {
|
|
57
|
+
await this.checkpoint(active.planPath, active.stepId, await this.readSnapshot(sessionId));
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
// A transient Codex or helper failure makes this tick unavailable only.
|
|
61
|
+
}
|
|
62
|
+
finally {
|
|
63
|
+
this.inFlight.delete(sessionId);
|
|
64
|
+
if (this.active.get(sessionId) === active && !this.timers.has(sessionId))
|
|
65
|
+
this.schedule(sessionId);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
clearTimer(sessionId) {
|
|
69
|
+
const timer = this.timers.get(sessionId);
|
|
70
|
+
if (timer)
|
|
71
|
+
clearTimeout(timer);
|
|
72
|
+
this.timers.delete(sessionId);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
function activeMeasurement(update) {
|
|
76
|
+
if (update.kind !== 'updated')
|
|
77
|
+
return null;
|
|
78
|
+
const step = findStep(update.plan, update.plan.currentStepId);
|
|
79
|
+
return step?.state === 'WIP' ? { planPath: update.planPath, stepId: step.id } : null;
|
|
80
|
+
}
|
|
81
|
+
function findStep(plan, id) {
|
|
82
|
+
const visit = (steps) => {
|
|
83
|
+
for (const step of steps) {
|
|
84
|
+
if (step.id === id)
|
|
85
|
+
return step;
|
|
86
|
+
const child = visit(step.children);
|
|
87
|
+
if (child)
|
|
88
|
+
return child;
|
|
89
|
+
}
|
|
90
|
+
return undefined;
|
|
91
|
+
};
|
|
92
|
+
return visit(plan.steps);
|
|
93
|
+
}
|