gestalt-mobile 0.7.0 → 0.8.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-BhydfzsN.js +12 -0
- package/dist/client/assets/{index-CmTK_fRb.css → index-DN1aV-V1.css} +1 -1
- package/dist/client/index.html +2 -2
- package/dist/server/server/app.js +3 -0
- package/dist/server/server/cli.js +1 -0
- package/dist/server/server/composition.js +37 -3
- 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/platform/codex/codex-process-launcher.js +2 -0
- package/dist/server/server/platform/codex/session-runtime.js +75 -3
- 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 +89 -0
- package/package.json +1 -1
- package/dist/client/assets/index-D9_bxMtt.js +0 -12
|
@@ -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();
|
|
@@ -109,12 +113,21 @@ export async function composeRelayApp(options) {
|
|
|
109
113
|
return true;
|
|
110
114
|
}, (sessionId) => recoverExitedSession(sessionId), resolveSkills, planStatusSource, (sessionId, update) => {
|
|
111
115
|
supervisedPlans.accept(sessionId, update);
|
|
116
|
+
planMeasurementRefresh?.accept(sessionId, update);
|
|
112
117
|
if (update.kind === 'updated') {
|
|
113
118
|
const occurredAt = new Date().toISOString();
|
|
114
|
-
events.publish(journal.append(sessionId, 'plan.updated', update.plan, occurredAt));
|
|
119
|
+
events.publish(journal.append(sessionId, 'plan.updated', { plan: update.plan, reason: update.reason }, occurredAt));
|
|
115
120
|
}
|
|
116
|
-
})
|
|
121
|
+
}, options.planMeasurementBaseUrl)
|
|
117
122
|
: null;
|
|
123
|
+
if (runtime && planMeasurementHelperPath) {
|
|
124
|
+
planMeasurementRefresh = new PlanMeasurementRefresh(async (sessionId) => {
|
|
125
|
+
const session = sessions.find(sessionId);
|
|
126
|
+
if (!session)
|
|
127
|
+
throw new Error('CODEX_SESSION_NOT_RUNNING');
|
|
128
|
+
return runtime.readPlanMeasurement(session);
|
|
129
|
+
}, (planPath, stepId, snapshot) => checkpointPlanMeasurement(planMeasurementHelperPath, planPath, stepId, snapshot));
|
|
130
|
+
}
|
|
118
131
|
const saveSession = (session) => {
|
|
119
132
|
sessions.save(session);
|
|
120
133
|
events.publish(journal.append(session.id, 'session.updated', session, session.updatedAt));
|
|
@@ -209,7 +222,12 @@ export async function composeRelayApp(options) {
|
|
|
209
222
|
release: (session) => RelaySession.rehydrate(session).release(new Date().toISOString()).snapshot,
|
|
210
223
|
remove: (id) => sessions.remove(id),
|
|
211
224
|
idempotency,
|
|
212
|
-
close: runtime
|
|
225
|
+
close: runtime
|
|
226
|
+
? (id) => {
|
|
227
|
+
planMeasurementRefresh?.stop(id);
|
|
228
|
+
return runtime.release(id);
|
|
229
|
+
}
|
|
230
|
+
: undefined,
|
|
213
231
|
replyInteraction: runtime
|
|
214
232
|
? (sessionId, requestId, value) => runtime.resolveServerRequest(sessionId, requestId, value)
|
|
215
233
|
: undefined,
|
|
@@ -228,10 +246,25 @@ export async function composeRelayApp(options) {
|
|
|
228
246
|
removeStatus: (id) => planStatusSource.remove(id, supervisedPlans.identity(id) ?? undefined),
|
|
229
247
|
clear: (id) => supervisedPlans.clear(id),
|
|
230
248
|
closed: (id) => {
|
|
249
|
+
planMeasurementRefresh?.stop(id);
|
|
231
250
|
const occurredAt = new Date().toISOString();
|
|
232
251
|
events.publish(journal.append(id, 'plan.closed', {}, occurredAt));
|
|
233
252
|
},
|
|
234
253
|
},
|
|
254
|
+
...(runtime
|
|
255
|
+
? {
|
|
256
|
+
planMeasurementRoutes: {
|
|
257
|
+
exists: (id) => sessions.find(id) !== null,
|
|
258
|
+
authorize: (id, authorization) => runtime.authorizePlanMeasurement(id, authorization),
|
|
259
|
+
read: async (id) => {
|
|
260
|
+
const session = sessions.find(id);
|
|
261
|
+
if (!session)
|
|
262
|
+
throw new Error('CODEX_SESSION_NOT_RUNNING');
|
|
263
|
+
return runtime.readPlanMeasurement(session);
|
|
264
|
+
},
|
|
265
|
+
},
|
|
266
|
+
}
|
|
267
|
+
: {}),
|
|
235
268
|
interactions: {
|
|
236
269
|
resolve: (sessionId, requestId, resolvedAt) => interactions.resolve(sessionId, requestId, resolvedAt),
|
|
237
270
|
validate: (sessionId, requestId, value) => {
|
|
@@ -292,6 +325,7 @@ export async function composeRelayApp(options) {
|
|
|
292
325
|
await restoreActiveSessions();
|
|
293
326
|
});
|
|
294
327
|
app.addHook('onClose', async () => {
|
|
328
|
+
planMeasurementRefresh?.stopAll();
|
|
295
329
|
runtime?.stopAll();
|
|
296
330
|
planStatusSource.closeAll();
|
|
297
331
|
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
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -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,89 @@
|
|
|
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
|
+
schedule(sessionId) {
|
|
42
|
+
this.timers.set(sessionId, setTimeout(() => {
|
|
43
|
+
this.timers.delete(sessionId);
|
|
44
|
+
void this.refresh(sessionId);
|
|
45
|
+
}, PLAN_MEASUREMENT_REFRESH_MS));
|
|
46
|
+
}
|
|
47
|
+
async refresh(sessionId) {
|
|
48
|
+
const active = this.active.get(sessionId);
|
|
49
|
+
if (!active || this.inFlight.has(sessionId))
|
|
50
|
+
return;
|
|
51
|
+
this.inFlight.add(sessionId);
|
|
52
|
+
try {
|
|
53
|
+
await this.checkpoint(active.planPath, active.stepId, await this.readSnapshot(sessionId));
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
// A transient Codex or helper failure makes this tick unavailable only.
|
|
57
|
+
}
|
|
58
|
+
finally {
|
|
59
|
+
this.inFlight.delete(sessionId);
|
|
60
|
+
if (this.active.get(sessionId) === active && !this.timers.has(sessionId))
|
|
61
|
+
this.schedule(sessionId);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
clearTimer(sessionId) {
|
|
65
|
+
const timer = this.timers.get(sessionId);
|
|
66
|
+
if (timer)
|
|
67
|
+
clearTimeout(timer);
|
|
68
|
+
this.timers.delete(sessionId);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
function activeMeasurement(update) {
|
|
72
|
+
if (update.kind !== 'updated')
|
|
73
|
+
return null;
|
|
74
|
+
const step = findStep(update.plan, update.plan.currentStepId);
|
|
75
|
+
return step?.state === 'WIP' ? { planPath: update.planPath, stepId: step.id } : null;
|
|
76
|
+
}
|
|
77
|
+
function findStep(plan, id) {
|
|
78
|
+
const visit = (steps) => {
|
|
79
|
+
for (const step of steps) {
|
|
80
|
+
if (step.id === id)
|
|
81
|
+
return step;
|
|
82
|
+
const child = visit(step.children);
|
|
83
|
+
if (child)
|
|
84
|
+
return child;
|
|
85
|
+
}
|
|
86
|
+
return undefined;
|
|
87
|
+
};
|
|
88
|
+
return visit(plan.steps);
|
|
89
|
+
}
|