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.
@@ -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 ? (id) => runtime.release(id) : undefined,
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
- if (!description)
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
+ }
@@ -0,0 +1,6 @@
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 {};
@@ -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
- constructor(launch, processes = new Map(), onNotification, onServerRequest, onProcessExit, resolveSkills, planStatusSource, onPlanStatus) {
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
- GESTALT_MOBILE_ORG_PLAN_STATUS_DIRECTORY: lease.statusDirectory,
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 { planPath: signal.planPath, updatedAt: signal.updatedAt };
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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gestalt-mobile",
3
- "version": "0.7.0",
3
+ "version": "0.8.0",
4
4
  "description": "Mobile-first web relay for durable Codex development sessions",
5
5
  "keywords": [
6
6
  "codex",