gestalt-mobile 0.35.1 → 0.35.2

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.
@@ -35,6 +35,8 @@ class SessionResource {
35
35
  spawnedAgentModels = new Map();
36
36
  attemptedAgentModelRecovery = new Set();
37
37
  ownedChildProcesses = new Map();
38
+ /** Retries from a durable Autopilot issued command reuse this opaque action id. */
39
+ completedProcessActions = new Map();
38
40
  turnThreads = new Map();
39
41
  childThreads = new Set();
40
42
  constructor(sessionId, process, planStatusLease, planMeasurementToken, unregister, onDisposed) {
@@ -84,6 +86,7 @@ export class CodexSessionRuntime {
84
86
  planMeasurementBaseUrl;
85
87
  maxPendingRequests;
86
88
  readerCwd;
89
+ serverResponseListeners = new Set();
87
90
  constructor(launch,
88
91
  // Kept as an ignored compatibility slot while callers migrate from the old
89
92
  // correlated-map constructor shape. Live ownership is `sessions` only.
@@ -170,18 +173,40 @@ export class CodexSessionRuntime {
170
173
  resolveServerRequest(sessionId, requestId, result) {
171
174
  const resource = this.sessions.get(sessionId);
172
175
  const pending = resource?.pendingRequests.get(requestId);
173
- if (!resource || !pending)
176
+ if (!resource || !pending || pending.settling)
174
177
  return false;
175
- resource.pendingRequests.delete(requestId);
178
+ pending.settling = true;
176
179
  pending.resolve(result);
177
180
  return true;
178
181
  }
182
+ rejectServerRequest(sessionId, requestId, error) {
183
+ const resource = this.sessions.get(sessionId);
184
+ const pending = resource?.pendingRequests.get(requestId);
185
+ if (!pending || pending.settling)
186
+ return false;
187
+ pending.settling = true;
188
+ pending.reject(error);
189
+ return true;
190
+ }
191
+ /**
192
+ * A response may already be queued in a blocked writable stream. Release
193
+ * the local holder without accepting any eventual late write as success.
194
+ */
195
+ abandonServerRequest(sessionId, requestId) {
196
+ const resource = this.sessions.get(sessionId);
197
+ return resource?.pendingRequests.delete(requestId) === true;
198
+ }
199
+ onServerResponseSettled(listener) {
200
+ this.serverResponseListeners.add(listener);
201
+ return () => this.serverResponseListeners.delete(listener);
202
+ }
179
203
  /** Distinguishes an offline relay writer from a live writer that cleared a request. */
180
204
  attentionWriterState(sessionId, requestId) {
181
205
  const resource = this.sessions.get(sessionId);
182
206
  if (!resource || !resource.active)
183
207
  return 'unavailable';
184
- return resource.pendingRequests.has(requestId) ? 'available' : 'cleared';
208
+ const pending = resource.pendingRequests.get(requestId);
209
+ return pending && !pending.settling ? 'available' : 'cleared';
185
210
  }
186
211
  async startTurn(session, text, clientUserMessageId, now) {
187
212
  const resource = this.sessions.get(session.id);
@@ -193,18 +218,22 @@ export class CodexSessionRuntime {
193
218
  ...(clientUserMessageId ? { clientUserMessageId } : {}),
194
219
  ...(session.model ? { model: session.model } : {}),
195
220
  }));
221
+ resource.turnThreads.set(result, session.threadId);
196
222
  return RelaySession.rehydrate(session).startTurn(result, now).snapshot;
197
223
  }
198
224
  async startExecutorTurn(session, childThreadId, text, clientUserMessageId) {
199
225
  const resource = this.sessions.get(session.id);
200
226
  if (!resource)
201
227
  throw new Error('CODEX_SESSION_NOT_RUNNING');
202
- return decodeTurnStart(await resource.process.rpc.request('turn/start', {
228
+ const result = decodeTurnStart(await resource.process.rpc.request('turn/start', {
203
229
  threadId: childThreadId,
204
230
  input: [{ type: 'text', text, text_elements: [] }],
205
231
  clientUserMessageId,
206
232
  ...(session.model ? { model: session.model } : {}),
207
233
  }));
234
+ if (resource.turnThreads.has(result) || resource.turnThreads.size < 256)
235
+ resource.turnThreads.set(result, childThreadId);
236
+ return result;
208
237
  }
209
238
  /** The sole authoritative in-process ownership probe. It never launches a child. */
210
239
  ownsWriter(sessionId) {
@@ -241,6 +270,19 @@ export class CodexSessionRuntime {
241
270
  throw new Error('CODEX_SESSION_NOT_RUNNING');
242
271
  await resource.process.rpc.request('turn/interrupt', { threadId: session.threadId, turnId });
243
272
  }
273
+ async interruptExecutor(session, threadId) {
274
+ const resource = this.sessions.get(session.id);
275
+ if (!resource)
276
+ throw new Error('CODEX_SESSION_NOT_RUNNING');
277
+ const history = await this.decodeHistory(resource.process, threadId, resource);
278
+ if (!history.activeTurnId)
279
+ return false;
280
+ await resource.process.rpc.request('turn/interrupt', {
281
+ threadId,
282
+ turnId: history.activeTurnId,
283
+ });
284
+ return true;
285
+ }
244
286
  async queueTurnInput(session, turnId, text, clientUserMessageId) {
245
287
  const resource = this.sessions.get(session.id);
246
288
  if (!resource || !session.threadId)
@@ -419,15 +461,36 @@ export class CodexSessionRuntime {
419
461
  process.state !== 'result-consumed' &&
420
462
  process.state !== 'terminated-for-budget');
421
463
  }
422
- consumeChildProcessResult(sessionId, childThreadId, processId) {
423
- this.sessions
424
- .get(sessionId)
425
- ?.ownedChildProcesses.delete(childProcessKey(childThreadId, processId));
464
+ consumeChildProcessResult(sessionId, childThreadId, processId, actionId) {
465
+ const owned = this.sessions.get(sessionId);
466
+ if (!owned || (actionId && owned.completedProcessActions.has(actionId)))
467
+ return;
468
+ owned.ownedChildProcesses.delete(childProcessKey(childThreadId, processId));
469
+ if (actionId)
470
+ owned.completedProcessActions.set(actionId, true);
426
471
  }
427
- async terminateChildProcess(session, childThreadId, processId) {
472
+ async terminateChildProcess(session, childThreadId, processId, actionId, expected) {
428
473
  const owned = this.sessions.get(session.id);
429
474
  if (!owned)
430
475
  return false;
476
+ if (actionId && owned.completedProcessActions.has(actionId))
477
+ return owned.completedProcessActions.get(actionId);
478
+ // The action journal is deliberately runtime-local. After an app-server
479
+ // restart, reconcile the live terminal list before repeating termination:
480
+ // a process id (and OS pid) can already name a later command instance.
481
+ // Absence of this exact instance means the target has already converged;
482
+ // never send a second terminate RPC merely to rediscover that fact.
483
+ if (expected) {
484
+ const active = await this.listChildBackgroundTerminals(owned, { id: childThreadId });
485
+ const current = active.find((candidate) => candidate.processId === processId &&
486
+ candidate.itemId === expected.itemId &&
487
+ (expected.osPid === undefined || candidate.osPid === expected.osPid));
488
+ if (!current) {
489
+ if (actionId)
490
+ owned.completedProcessActions.set(actionId, true);
491
+ return true;
492
+ }
493
+ }
431
494
  const result = await owned.process.rpc.request('thread/backgroundTerminals/terminate', {
432
495
  threadId: childThreadId,
433
496
  processId,
@@ -439,6 +502,8 @@ export class CodexSessionRuntime {
439
502
  if (process)
440
503
  owned.ownedChildProcesses.set(key, { ...process, state: 'terminated-for-budget' });
441
504
  }
505
+ if (actionId)
506
+ owned.completedProcessActions.set(actionId, terminated);
442
507
  return terminated;
443
508
  }
444
509
  async listChildBackgroundTerminals(owned, child) {
@@ -739,13 +804,25 @@ export class CodexSessionRuntime {
739
804
  this.onNotification?.(session.id, notification, this.resolveNotificationOrigin(resource, notification));
740
805
  });
741
806
  const requestUnsubscribe = process.rpc.onServerRequest((request) => this.holdServerRequest(resource, request));
807
+ const responseUnsubscribe = process.rpc.onServerResponseSettled?.((settlement) => {
808
+ const requestId = String(settlement.id);
809
+ if (!resource.pendingRequests.delete(requestId))
810
+ return;
811
+ this.serverResponseListeners.forEach((listener) => listener(resource.sessionId, requestId, settlement.outcome));
812
+ });
742
813
  if (!resource.active) {
743
814
  notificationUnsubscribe();
744
815
  requestUnsubscribe();
816
+ responseUnsubscribe?.();
745
817
  throw new Error('CODEX_SESSION_PROCESS_EXITED');
746
818
  }
747
819
  // The resource's private unsubscribe list is populated before it is published.
748
- resource.attach([notificationUnsubscribe, requestUnsubscribe, exitUnsubscribe]);
820
+ resource.attach([
821
+ notificationUnsubscribe,
822
+ requestUnsubscribe,
823
+ ...(responseUnsubscribe ? [responseUnsubscribe] : []),
824
+ exitUnsubscribe,
825
+ ]);
749
826
  return resource;
750
827
  }
751
828
  catch (error) {
@@ -0,0 +1,7 @@
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
+ /** Safe, shared UI copy for the bounded checkpoint transport recovery path. */
7
+ export const checkpointHandoffRecoveryMessage = 'Checkpoint handoff failed. Autopilot is reconnecting this session automatically. If it cannot recover, restore or reopen the session.';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gestalt-mobile",
3
- "version": "0.35.1",
3
+ "version": "0.35.2",
4
4
  "description": "Mobile-first web relay for durable Codex development sessions",
5
5
  "keywords": [
6
6
  "codex",