gestalt-mobile 0.8.0 → 0.10.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.
@@ -9,7 +9,29 @@ export function registerGetHistory(app, deps) {
9
9
  const session = deps.find(request.params.id);
10
10
  if (!session)
11
11
  return reply.code(404).send({ code: 'SESSION_NOT_FOUND' });
12
- const history = await deps.read(session);
12
+ let history;
13
+ try {
14
+ history = await deps.read(session);
15
+ }
16
+ catch (error) {
17
+ if (error instanceof Error && error.message === 'CODEX_SESSION_NOT_RUNNING')
18
+ return reply.code(409).type('application/problem+json').send({
19
+ type: 'urn:gestalt-mobile:error:session-history-unavailable',
20
+ title: 'Session history unavailable',
21
+ status: 409,
22
+ detail: 'GET /api/sessions/:id/history requires an active Codex session process. Open the session to restore it, then retry.',
23
+ code: 'SESSION_HISTORY_UNAVAILABLE',
24
+ retryable: true,
25
+ });
26
+ return reply.code(502).type('application/problem+json').send({
27
+ type: 'urn:gestalt-mobile:error:session-history-read-failed',
28
+ title: 'Session history read failed',
29
+ status: 502,
30
+ detail: 'GET /api/sessions/:id/history reached the relay, but Codex could not read this session history. The Codex process may have stopped during recovery; open the session again and inspect the running relay output if it persists.',
31
+ code: 'SESSION_HISTORY_READ_FAILED',
32
+ retryable: true,
33
+ });
34
+ }
13
35
  const items = toChatItems(history.turns);
14
36
  return reply.send({
15
37
  items,
@@ -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
  }
@@ -4,6 +4,7 @@
4
4
  * SPDX-License-Identifier: AGPL-3.0-or-later
5
5
  */
6
6
  import { idempotencyKey } from '../../../platform/http/idempotency.js';
7
+ import { RelaySession } from '../model/relay-session.js';
7
8
  import { canRestore } from './use-case.js';
8
9
  export function registerRestoreSession(app, deps) {
9
10
  app.post('/api/sessions/:id/restore', async (request, reply) => {
@@ -18,10 +19,29 @@ export function registerRestoreSession(app, deps) {
18
19
  return reply.code(404).send({ code: 'SESSION_NOT_FOUND' });
19
20
  if (!canRestore(session))
20
21
  return reply.code(409).send({ code: 'SESSION_CANNOT_RESTORE' });
21
- const restored = await deps.restore(session);
22
- deps.save(restored);
22
+ const recovering = RelaySession.rehydrate(session).beginRecovery(new Date().toISOString()).snapshot;
23
+ deps.save(recovering);
24
+ let restored;
25
+ try {
26
+ restored = await deps.restore(session);
27
+ }
28
+ catch {
29
+ // The pre-I/O recovery marker must never strand a saved session: keep
30
+ // its original thread and inactive state available for a later Open.
31
+ deps.save(session);
32
+ return reply.code(502).send({ code: 'RESTORE_FAILED' });
33
+ }
34
+ const response = 'session' in restored
35
+ ? {
36
+ ...restored.session,
37
+ ...(restored.replacementCreated
38
+ ? { recovery: { historyUnavailable: true, replacementCreated: true } }
39
+ : {}),
40
+ }
41
+ : restored;
42
+ deps.save(response);
23
43
  if (key)
24
- deps.idempotency?.put(scope, key, 200, JSON.stringify(restored));
25
- return reply.send(restored);
44
+ deps.idempotency?.put(scope, key, 200, JSON.stringify(response));
45
+ return reply.send(response);
26
46
  });
27
47
  }
@@ -4,6 +4,8 @@
4
4
  * SPDX-License-Identifier: AGPL-3.0-or-later
5
5
  */
6
6
  import { RelaySession } from '../model/relay-session.js';
7
+ import { threadId } from '../model/value-objects.js';
8
+ import { isMissingCodexThreadRollout } from '../../../platform/codex/json-rpc-client.js';
7
9
  export function canRestore(session) {
8
10
  return (session.threadId !== null &&
9
11
  (session.state === 'stopped' ||
@@ -15,3 +17,21 @@ export function restore(session, now) {
15
17
  throw new Error('SESSION_CANNOT_RESTORE');
16
18
  return RelaySession.rehydrate(session).beginRecovery(now).restore(now).snapshot;
17
19
  }
20
+ /**
21
+ * The runtime may replace a thread only after a failed resume is confirmed to
22
+ * be the historical-rollout case. This pure policy leaves persistence to the
23
+ * caller, so the original thread remains durable until thread/start succeeds.
24
+ */
25
+ export function canRebindMissingRollout(session, error) {
26
+ return canRestore(session) && isMissingCodexThreadRollout(error);
27
+ }
28
+ export function rebindMissingRollout(session, error, replacementThreadId, now) {
29
+ if (!canRebindMissingRollout(session, error))
30
+ throw new Error('CODEX_ROLLOUT_REBIND_NOT_ALLOWED');
31
+ threadId(replacementThreadId);
32
+ return {
33
+ session: RelaySession.rehydrate(session).bindThread(replacementThreadId, now).snapshot,
34
+ historyUnavailable: true,
35
+ replacementCreated: true,
36
+ };
37
+ }
@@ -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
  }
@@ -4,6 +4,38 @@
4
4
  * SPDX-License-Identifier: AGPL-3.0-or-later
5
5
  */
6
6
  import { createInterface } from 'node:readline';
7
+ export const CODEX_THREAD_NOT_FOUND = 'CODEX_THREAD_NOT_FOUND';
8
+ export const CODEX_JSON_RPC_ERROR = 'CODEX_JSON_RPC_ERROR';
9
+ /** A bounded representation of an app-server JSON-RPC failure. */
10
+ export class CodexJsonRpcError extends Error {
11
+ code;
12
+ kind;
13
+ constructor(code, message, kind = classifyCodexJsonRpcError(code, message)) {
14
+ super(boundMessage(message));
15
+ this.code = code;
16
+ this.kind = kind;
17
+ this.name = 'CodexJsonRpcError';
18
+ }
19
+ }
20
+ /**
21
+ * Codex 0.146 reports a rollout removed by an upgrade as -32600. Keep the
22
+ * message match deliberately narrow: other invalid-request responses must not
23
+ * be treated as permission to replace a durable thread.
24
+ */
25
+ export function isMissingCodexThreadRollout(error) {
26
+ return error instanceof CodexJsonRpcError && error.kind === CODEX_THREAD_NOT_FOUND;
27
+ }
28
+ function classifyCodexJsonRpcError(code, message) {
29
+ return code === -32600 && /^no rollout found for thread id\b/i.test(message)
30
+ ? CODEX_THREAD_NOT_FOUND
31
+ : CODEX_JSON_RPC_ERROR;
32
+ }
33
+ function boundMessage(message) {
34
+ return message
35
+ .replace(/((?:authorization|token|api[_ -]?key|password)\s*[:=]\s*)\S+/gi, '$1[REDACTED]')
36
+ .replace(/(authentication failed:\s*).*/i, '$1[REDACTED]')
37
+ .slice(0, 256);
38
+ }
7
39
  export class JsonRpcClient {
8
40
  output;
9
41
  sequence = 0;
@@ -59,8 +91,10 @@ export class JsonRpcClient {
59
91
  if (!pending)
60
92
  return;
61
93
  this.pending.delete(message.id);
62
- if (message.error)
63
- pending.reject(message.error);
94
+ if (message.error) {
95
+ const error = message.error;
96
+ pending.reject(new CodexJsonRpcError(typeof error.code === 'number' ? error.code : undefined, typeof error.message === 'string' ? error.message : 'JSON_RPC_ERROR'));
97
+ }
64
98
  else
65
99
  pending.resolve(message.result);
66
100
  }
@@ -6,6 +6,7 @@
6
6
  import { RelaySession, } from '../../features/sessions/model/relay-session.js';
7
7
  import { randomUUID } from 'node:crypto';
8
8
  import { createPlanMeasurementSnapshot, } from '../../features/plans/application/measurement-snapshot.js';
9
+ import { canRebindMissingRollout, rebindMissingRollout, } from '../../features/sessions/restore-session/use-case.js';
9
10
  import { gestaltQuizDynamicTool } from '../../../shared/contracts/quiz.js';
10
11
  export class CodexSessionRuntime {
11
12
  launch;
@@ -43,22 +44,13 @@ export class CodexSessionRuntime {
43
44
  clientInfo: { name: 'gestalt-mobile', version: '0.1.0' },
44
45
  capabilities: { experimentalApi: true },
45
46
  });
46
- const result = (await process.rpc.request('thread/start', {
47
- cwd: session.workspacePath,
48
- approvalPolicy: settings.approvalPolicy ?? 'on-request',
49
- dynamicTools: [gestaltQuizDynamicTool],
50
- ...(settings.model ? { model: settings.model } : {}),
51
- ...(settings.sandbox ? { sandbox: settings.sandbox } : {}),
52
- }));
53
- if (!result.thread?.id)
54
- throw new Error('CODEX_THREAD_ID_MISSING');
47
+ const startedThreadId = await this.startThread(process, session, settings);
55
48
  this.processes.set(session.id, process);
56
- this.threadIds.set(session.id, result.thread.id);
57
- return RelaySession.rehydrate(session).bindThread(result.thread.id, now).snapshot;
49
+ this.threadIds.set(session.id, startedThreadId);
50
+ return RelaySession.rehydrate(session).bindThread(startedThreadId, now).snapshot;
58
51
  }
59
52
  catch (error) {
60
- process.close();
61
- this.releasePlanStatus(session.id);
53
+ this.discardProcess(session.id, process);
62
54
  throw error;
63
55
  }
64
56
  }
@@ -68,6 +60,8 @@ export class CodexSessionRuntime {
68
60
  this.processes.get(sessionId)?.close();
69
61
  this.processes.delete(sessionId);
70
62
  this.threadIds.delete(sessionId);
63
+ this.clearPendingRequests(sessionId);
64
+ this.planMeasurementTokens.delete(sessionId);
71
65
  this.releasePlanStatus(sessionId);
72
66
  }
73
67
  async release(sessionId) {
@@ -156,6 +150,9 @@ export class CodexSessionRuntime {
156
150
  };
157
151
  }
158
152
  async restore(session, now) {
153
+ return (await this.restoreWithOutcome(session, now)).session;
154
+ }
155
+ async restoreWithOutcome(session, now) {
159
156
  if (!session.threadId)
160
157
  throw new Error('CODEX_THREAD_ID_MISSING');
161
158
  const process = await this.launchForSession(session);
@@ -167,18 +164,31 @@ export class CodexSessionRuntime {
167
164
  clientInfo: { name: 'gestalt-mobile', version: '0.1.0' },
168
165
  capabilities: { experimentalApi: true },
169
166
  });
170
- await process.rpc.request('thread/resume', {
171
- threadId: session.threadId,
172
- cwd: session.workspacePath,
173
- dynamicTools: [gestaltQuizDynamicTool],
174
- });
167
+ let result;
168
+ try {
169
+ await process.rpc.request('thread/resume', {
170
+ threadId: session.threadId,
171
+ cwd: session.workspacePath,
172
+ dynamicTools: [gestaltQuizDynamicTool],
173
+ });
174
+ result = {
175
+ session: RelaySession.rehydrate(session).restore(now).snapshot,
176
+ historyUnavailable: false,
177
+ replacementCreated: false,
178
+ };
179
+ }
180
+ catch (error) {
181
+ if (!canRebindMissingRollout(session, error))
182
+ throw error;
183
+ const replacementThreadId = await this.startThread(process, session, { model: session.model });
184
+ result = rebindMissingRollout(session, error, replacementThreadId, now);
185
+ }
175
186
  this.processes.set(session.id, process);
176
- this.threadIds.set(session.id, session.threadId);
177
- return RelaySession.rehydrate(session).restore(now).snapshot;
187
+ this.threadIds.set(session.id, result.session.threadId);
188
+ return result;
178
189
  }
179
190
  catch (error) {
180
- process.close();
181
- this.releasePlanStatus(session.id);
191
+ this.discardProcess(session.id, process);
182
192
  throw error;
183
193
  }
184
194
  }
@@ -192,12 +202,44 @@ export class CodexSessionRuntime {
192
202
  this.exitUnsubscribers.set(sessionId, process.onExit?.(() => {
193
203
  this.processes.delete(sessionId);
194
204
  this.threadIds.delete(sessionId);
205
+ this.clearPendingRequests(sessionId);
195
206
  this.planMeasurementTokens.delete(sessionId);
196
207
  this.exitUnsubscribers.delete(sessionId);
197
208
  this.releasePlanStatus(sessionId);
198
209
  this.onProcessExit?.(sessionId);
199
210
  }) ?? (() => { }));
200
211
  }
212
+ async startThread(process, session, settings = {}) {
213
+ const result = (await process.rpc.request('thread/start', this.threadStartParams(session, settings)));
214
+ if (typeof result.thread?.id !== 'string' || !result.thread.id) {
215
+ throw new Error('CODEX_THREAD_ID_MISSING');
216
+ }
217
+ return result.thread.id;
218
+ }
219
+ threadStartParams(session, settings) {
220
+ return {
221
+ cwd: session.workspacePath,
222
+ approvalPolicy: settings.approvalPolicy ?? 'on-request',
223
+ dynamicTools: [gestaltQuizDynamicTool],
224
+ ...(settings.model ? { model: settings.model } : {}),
225
+ ...(settings.sandbox ? { sandbox: settings.sandbox } : {}),
226
+ };
227
+ }
228
+ discardProcess(sessionId, process) {
229
+ this.exitUnsubscribers.get(sessionId)?.();
230
+ this.exitUnsubscribers.delete(sessionId);
231
+ process.close();
232
+ this.clearPendingRequests(sessionId);
233
+ this.planMeasurementTokens.delete(sessionId);
234
+ this.releasePlanStatus(sessionId);
235
+ }
236
+ clearPendingRequests(sessionId) {
237
+ const prefix = `${sessionId}:`;
238
+ for (const key of this.pendingRequests.keys()) {
239
+ if (key.startsWith(prefix))
240
+ this.pendingRequests.delete(key);
241
+ }
242
+ }
201
243
  async launchForSession(session) {
202
244
  const lease = this.planStatusSource
203
245
  ? await this.planStatusSource.open({ id: session.id, workspacePath: session.workspacePath }, (update) => this.onPlanStatus?.(session.id, update))
@@ -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,
@@ -5,6 +5,7 @@
5
5
  */
6
6
  import { chmod, mkdir, readFile, readdir, rename, rm, stat, watch, writeFile, realpath, } from 'node:fs/promises';
7
7
  import { createHash, randomUUID } from 'node:crypto';
8
+ import { isPlanSignalReason } from '../../../shared/contracts/plan-signal.js';
8
9
  import { basename, dirname, join } from 'node:path';
9
10
  import { parseSupervisedPlan } from '../../features/plans/application/parse-supervised-plan.js';
10
11
  import { isPlanPathWithinWorkspace } from '../../features/plans/application/parse-supervised-plan.js';
@@ -298,9 +299,6 @@ function parseSignal(source) {
298
299
  return null;
299
300
  }
300
301
  }
301
- function isPlanSignalReason(value) {
302
- return value === 'authoring-start' || value === 'work-start' || value === 'checkpoint' || value === 'update';
303
- }
304
302
  function isRfc3339Utc(value) {
305
303
  return (typeof value === 'string' &&
306
304
  /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z$/.test(value) &&
@@ -38,6 +38,10 @@ export class PlanMeasurementRefresh {
38
38
  for (const sessionId of this.active.keys())
39
39
  this.stop(sessionId);
40
40
  }
41
+ refreshNow(sessionId) {
42
+ this.clearTimer(sessionId);
43
+ void this.refresh(sessionId);
44
+ }
41
45
  schedule(sessionId) {
42
46
  this.timers.set(sessionId, setTimeout(() => {
43
47
  this.timers.delete(sessionId);
@@ -0,0 +1,17 @@
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
+ /** Every reason published by the bundled org-plan helper. */
7
+ export const planSignalReasons = [
8
+ 'authoring-start',
9
+ 'work-start',
10
+ 'checkpoint',
11
+ 'update',
12
+ 'supervision-start',
13
+ 'resync',
14
+ ];
15
+ export function isPlanSignalReason(value) {
16
+ return typeof value === 'string' && planSignalReasons.includes(value);
17
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gestalt-mobile",
3
- "version": "0.8.0",
3
+ "version": "0.10.0",
4
4
  "description": "Mobile-first web relay for durable Codex development sessions",
5
5
  "keywords": [
6
6
  "codex",
@@ -45,6 +45,7 @@
45
45
  "format": "prettier --write .",
46
46
  "format:check": "prettier --check .",
47
47
  "test": "vitest run",
48
+ "test:open-profile-smoke": "tsx scripts/open-profile-smoke.ts",
48
49
  "test:watch": "vitest",
49
50
  "test:package": "node scripts/smoke-packed-cli.mjs",
50
51
  "test:e2e": "playwright test",