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.
@@ -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
- events.publish(journal.append(sessionId, 'plan.updated', update.plan, occurredAt));
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 ? (id) => runtime.release(id) : undefined,
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
- 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 {};
@@ -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
- cwd,
13
- recencyAt,
14
- resumeCommand: buildResumeCommand({ profile, threadId: id, workspacePath: cwd }),
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
- 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
+ }
@@ -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 (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) 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,updated_at=excluded.updated_at')
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 { 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,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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gestalt-mobile",
3
- "version": "0.7.0",
3
+ "version": "0.9.0",
4
4
  "description": "Mobile-first web relay for durable Codex development sessions",
5
5
  "keywords": [
6
6
  "codex",