gestalt-mobile 0.17.1 → 0.17.3

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.
@@ -16,7 +16,7 @@ SPDX-License-Identifier: AGPL-3.0-or-later
16
16
  <link rel="icon" href="/icons/gestalt-mobile-192.png" />
17
17
  <link rel="apple-touch-icon" href="/icons/gestalt-mobile-180.png" />
18
18
  <title>Gestalt Mobile</title>
19
- <script type="module" crossorigin src="/assets/index-DgD-0I3M.js"></script>
19
+ <script type="module" crossorigin src="/assets/index-Ba_4zoY1.js"></script>
20
20
  <link rel="stylesheet" crossorigin href="/assets/index-e2lJ5XqL.css">
21
21
  </head>
22
22
  <body>
@@ -32,7 +32,7 @@ import { GitSummaryCache } from './platform/git/git-summary-cache.js';
32
32
  import { SessionSupervisor } from './platform/runtime/session-supervisor.js';
33
33
  import { mapWithConcurrency } from './platform/runtime/concurrency.js';
34
34
  import { RelaySession } from './features/sessions/model/relay-session.js';
35
- import { toPendingInteraction } from './platform/codex/server-request.js';
35
+ import { resolvedServerRequestId, toPendingInteraction } from './platform/codex/server-request.js';
36
36
  import { isValidInteractionResponse, isValidQuizInteractionResponse, } from './features/sessions/interaction/response-validator.js';
37
37
  import { promoteRecentThread } from './features/sessions/promote-recent-thread/use-case.js';
38
38
  import { FilesystemSkillProfileStore } from './platform/skills/filesystem-skill-profile-store.js';
@@ -113,9 +113,25 @@ export async function composeRelayApp(options) {
113
113
  const gitSummaries = new GitSummaryCache(inspectGit);
114
114
  let recoverExitedSession = () => { };
115
115
  let planMeasurementRefresh;
116
+ const publishInteractionResolved = (sessionId, requestId, occurredAt, outcome) => {
117
+ const interaction = interactions
118
+ .snapshot(sessionId)
119
+ .find((item) => item.requestId === requestId);
120
+ events.publish(journal.append(sessionId, 'interaction.resolved', { requestId, turnId: interaction?.turnId ?? null, resolvedAt: occurredAt, outcome }, occurredAt));
121
+ };
122
+ const dismissPendingInteractions = (sessionId, occurredAt) => {
123
+ for (const interaction of interactions.list(sessionId)) {
124
+ if (interactions.resolve(sessionId, interaction.requestId, occurredAt, 'dismissed'))
125
+ publishInteractionResolved(sessionId, interaction.requestId, occurredAt, 'dismissed');
126
+ }
127
+ };
116
128
  const runtime = options.startAppServers
117
129
  ? new CodexSessionRuntime(options.launchAppServer ?? launchCodexAppServer, undefined, (sessionId, notification) => {
118
130
  const occurredAt = new Date().toISOString();
131
+ const resolvedRequestId = resolvedServerRequestId(notification);
132
+ if (resolvedRequestId &&
133
+ interactions.resolve(sessionId, resolvedRequestId, occurredAt, 'dismissed'))
134
+ publishInteractionResolved(sessionId, resolvedRequestId, occurredAt, 'dismissed');
119
135
  const currentSession = sessions.find(sessionId);
120
136
  const normalized = normalizeCodexNotification(sessionId, 0, occurredAt, notification, currentSession?.workspacePath, currentSession?.activeTurnId);
121
137
  if (!normalized)
@@ -148,7 +164,10 @@ export async function composeRelayApp(options) {
148
164
  sessions.save(updated);
149
165
  events.publish(journal.append(sessionId, 'interaction.requested', interaction, updated.updatedAt));
150
166
  return true;
151
- }, (sessionId) => recoverExitedSession(sessionId), resolveSkills, planStatusSource, (sessionId, update) => {
167
+ }, (sessionId) => {
168
+ dismissPendingInteractions(sessionId, new Date().toISOString());
169
+ recoverExitedSession(sessionId);
170
+ }, resolveSkills, planStatusSource, (sessionId, update) => {
152
171
  supervisedPlans.accept(sessionId, update);
153
172
  planMeasurementRefresh?.accept(sessionId, update);
154
173
  if (update.kind === 'updated') {
@@ -291,15 +310,27 @@ export async function composeRelayApp(options) {
291
310
  skillCatalog,
292
311
  defaultSkillProfile: options.explicitSkillProfile,
293
312
  activate: runtime
294
- ? async (session, settings) => runtime.start(session, new Date().toISOString(), settings)
313
+ ? async (session, settings) => {
314
+ const now = new Date().toISOString();
315
+ dismissPendingInteractions(session.id, now);
316
+ return runtime.start(session, now, settings);
317
+ }
295
318
  : undefined,
296
319
  startTurn: runtime
297
320
  ? async (session, text, clientUserMessageId) => runtime.startTurn(session, text, clientUserMessageId, new Date().toISOString())
298
321
  : undefined,
299
322
  ensureWriter: runtime
300
- ? (session) => runtime.ensureWriter(session, new Date().toISOString())
323
+ ? (session) => {
324
+ dismissPendingInteractions(session.id, new Date().toISOString());
325
+ return runtime.ensureWriter(session, new Date().toISOString());
326
+ }
327
+ : undefined,
328
+ releaseWriter: runtime
329
+ ? (id) => {
330
+ dismissPendingInteractions(id, new Date().toISOString());
331
+ return runtime.release(id);
332
+ }
301
333
  : undefined,
302
- releaseWriter: runtime ? (id) => runtime.release(id) : undefined,
303
334
  onTurnStarted: (session) => planMeasurementRefresh?.refreshNow(session.id),
304
335
  models,
305
336
  readHistory: runtime ? (session) => runtime.readHistory(session) : undefined,
@@ -325,17 +356,15 @@ export async function composeRelayApp(options) {
325
356
  close: runtime
326
357
  ? (id) => {
327
358
  planMeasurementRefresh?.stop(id);
359
+ dismissPendingInteractions(id, new Date().toISOString());
328
360
  return runtime.release(id);
329
361
  }
330
362
  : undefined,
331
363
  replyInteraction: runtime
332
- ? (sessionId, requestId, value) => runtime.resolveServerRequest(sessionId, requestId, value)
364
+ ? (sessionId, requestId, value) => runtime.resolveServerRequest(sessionId, requestId, value) ? 'accepted' : 'cleared'
333
365
  : undefined,
334
366
  interactionResolved: (sessionId, requestId, occurredAt, outcome) => {
335
- const interaction = interactions
336
- .snapshot(sessionId)
337
- .find((item) => item.requestId === requestId);
338
- events.publish(journal.append(sessionId, 'interaction.resolved', { requestId, turnId: interaction?.turnId ?? null, resolvedAt: occurredAt, outcome }, occurredAt));
367
+ publishInteractionResolved(sessionId, requestId, occurredAt, outcome);
339
368
  },
340
369
  },
341
370
  sessionEvents: {
@@ -438,6 +467,8 @@ export async function composeRelayApp(options) {
438
467
  });
439
468
  app.addHook('onClose', async () => {
440
469
  planMeasurementRefresh?.stopAll();
470
+ for (const session of sessions.list())
471
+ dismissPendingInteractions(session.id, new Date().toISOString());
441
472
  runtime?.stopAll();
442
473
  planStatusSource.closeAll();
443
474
  database.close();
@@ -18,8 +18,21 @@ export function registerRespondInteraction(app, deps) {
18
18
  return reply.code(409).send({ code: 'INTERACTION_NOT_PENDING' });
19
19
  if (deps.validate && !deps.validate(id, requestId, value))
20
20
  return reply.code(400).send({ code: 'INTERACTION_RESPONSE_INVALID' });
21
- if (!deps.reply(id, requestId, value))
22
- return reply.code(409).send({ code: 'INTERACTION_NOT_PENDING' });
21
+ const delivery = deps.reply(id, requestId, value);
22
+ if (delivery === false || delivery === 'unavailable')
23
+ return reply.code(409).send({ code: 'INTERACTION_DELIVERY_UNAVAILABLE' });
24
+ if (delivery === 'cleared') {
25
+ const resolvedAt = deps.now();
26
+ const outcome = 'dismissed';
27
+ if (deps.resolve(id, requestId, resolvedAt, outcome)) {
28
+ deps.resolved?.(id, requestId, resolvedAt, outcome);
29
+ return reply.code(202).send({ accepted: true, resolvedAt, outcome });
30
+ }
31
+ const raced = deps.alreadyResolved?.(id, requestId);
32
+ return raced
33
+ ? reply.code(202).send({ accepted: true, ...raced })
34
+ : reply.code(409).send({ code: 'INTERACTION_NOT_PENDING' });
35
+ }
23
36
  const resolvedAt = deps.now();
24
37
  const outcome = safeOutcome(value);
25
38
  if (!deps.resolve(id, requestId, resolvedAt, outcome))
@@ -81,6 +81,36 @@ function canonicalSkillPath(value) {
81
81
  }
82
82
  return value;
83
83
  }
84
+ /**
85
+ * Return the durable identity of a skill inside Codex's versioned plugin cache.
86
+ * The concrete version directory is installation state, while marketplace,
87
+ * plugin, and skill-relative path remain stable across plugin upgrades.
88
+ */
89
+ function versionNeutralPluginSkillPath(value) {
90
+ const match = value.match(/^(.*[\\/]plugins[\\/]cache[\\/][^\\/]+[\\/][^\\/]+)[\\/][^\\/]+([\\/]skills[\\/].+[\\/]SKILL\.md)$/);
91
+ return match ? `${match[1]}${match[2]}` : undefined;
92
+ }
93
+ function rebindVersionedPluginSkills(discovered, selection) {
94
+ const discoveredPaths = new Set(discovered.map((skill) => canonicalSkillPath(skill.path)));
95
+ const discoveredByDurablePath = new Map();
96
+ for (const skill of discovered) {
97
+ const durablePath = versionNeutralPluginSkillPath(canonicalSkillPath(skill.path));
98
+ if (durablePath === undefined)
99
+ continue;
100
+ const matches = discoveredByDurablePath.get(durablePath) ?? [];
101
+ matches.push(skill);
102
+ discoveredByDurablePath.set(durablePath, matches);
103
+ }
104
+ return createSkillSelection(selection.map((entry) => {
105
+ if (discoveredPaths.has(entry.path))
106
+ return entry;
107
+ const durablePath = versionNeutralPluginSkillPath(entry.path);
108
+ if (durablePath === undefined)
109
+ return entry;
110
+ const matches = discoveredByDurablePath.get(durablePath) ?? [];
111
+ return matches.length === 1 ? { ...entry, path: matches[0].path } : entry;
112
+ }));
113
+ }
84
114
  /**
85
115
  * Validate a complete selection and return its canonical deterministic order.
86
116
  * This is lexical only: resolving symlinks is I/O and belongs to a platform
@@ -137,11 +167,12 @@ export function compileSkillOverride(input) {
137
167
  const effective = selectEffectiveSkillSelection(input);
138
168
  if (effective.selection === undefined)
139
169
  return { source: 'native', skillsConfig: undefined, warnings: [] };
170
+ const reboundSelection = rebindVersionedPluginSkills(input.discovered, effective.selection);
140
171
  const discoveredPaths = new Set(input.discovered.map((skill) => canonicalSkillPath(skill.path)));
141
- const warnings = effective.selection
172
+ const warnings = reboundSelection
142
173
  .filter((entry) => !discoveredPaths.has(entry.path))
143
174
  .map((entry) => `Saved skill path is no longer discovered: ${entry.path}`);
144
- const configured = applySkillSelectionSnapshot(input.discovered, effective.selection);
175
+ const configured = applySkillSelectionSnapshot(input.discovered, reboundSelection);
145
176
  return {
146
177
  source: effective.source,
147
178
  skillsConfig: configured
@@ -21,6 +21,15 @@ export function toPendingInteraction(input) {
21
21
  }[input.method];
22
22
  return kind ? { requestId: String(input.id), kind, payload: input.params } : null;
23
23
  }
24
+ export function resolvedServerRequestId(input) {
25
+ if (input.method !== 'serverRequest/resolved' || !isRecord(input.params))
26
+ return null;
27
+ const requestId = input.params.requestId;
28
+ if (!((typeof requestId === 'string' && requestId.length > 0 && requestId.length <= 256) ||
29
+ (typeof requestId === 'number' && Number.isSafeInteger(requestId))))
30
+ return null;
31
+ return String(requestId);
32
+ }
24
33
  function isRecord(value) {
25
34
  return typeof value === 'object' && value !== null && !Array.isArray(value);
26
35
  }
@@ -11,6 +11,7 @@ import { gestaltQuizDynamicTool } from '../../../shared/contracts/quiz.js';
11
11
  import { threadPlanName } from './thread-plan-name.js';
12
12
  import { WriterAcquisitionError, } from '../../features/sessions/application/writer-acquisition.js';
13
13
  import { isCodexThreadWriterBusy, isMissingCodexThreadRollout } from './json-rpc-client.js';
14
+ import { resolvedServerRequestId } from './server-request.js';
14
15
  /** One private owner for every resource acquired for a live Codex child. */
15
16
  class SessionResource {
16
17
  sessionId;
@@ -38,7 +39,6 @@ class SessionResource {
38
39
  return false;
39
40
  this.disposed = true;
40
41
  for (const pending of this.pendingRequests.values()) {
41
- clearTimeout(pending.timer);
42
42
  pending.reject(new Error('CODEX_SERVER_REQUEST_CANCELLED'));
43
43
  }
44
44
  this.pendingRequests.clear();
@@ -65,13 +65,14 @@ export class CodexSessionRuntime {
65
65
  planStatusSource;
66
66
  onPlanStatus;
67
67
  planMeasurementBaseUrl;
68
- requestTimeoutMs;
69
68
  maxPendingRequests;
70
69
  readerCwd;
71
70
  constructor(launch,
72
71
  // Kept as an ignored compatibility slot while callers migrate from the old
73
72
  // correlated-map constructor shape. Live ownership is `sessions` only.
74
- _legacyProcesses = undefined, onNotification, onServerRequest, onProcessExit, resolveSkills, planStatusSource, onPlanStatus, planMeasurementBaseUrl, requestTimeoutMs = 30_000, maxPendingRequests = 64, readerCwd) {
73
+ _legacyProcesses = undefined, onNotification, onServerRequest, onProcessExit, resolveSkills, planStatusSource, onPlanStatus, planMeasurementBaseUrl,
74
+ // Compatibility slot: blocking app-server requests wait for explicit input.
75
+ _legacyRequestTimeoutMs = undefined, maxPendingRequests = 64, readerCwd) {
75
76
  this.launch = launch;
76
77
  this.onNotification = onNotification;
77
78
  this.onServerRequest = onServerRequest;
@@ -80,10 +81,10 @@ export class CodexSessionRuntime {
80
81
  this.planStatusSource = planStatusSource;
81
82
  this.onPlanStatus = onPlanStatus;
82
83
  this.planMeasurementBaseUrl = planMeasurementBaseUrl;
83
- this.requestTimeoutMs = requestTimeoutMs;
84
84
  this.maxPendingRequests = maxPendingRequests;
85
85
  this.readerCwd = readerCwd;
86
86
  void _legacyProcesses;
87
+ void _legacyRequestTimeoutMs;
87
88
  }
88
89
  sessions = new Map();
89
90
  historyReads = new Map();
@@ -138,7 +139,6 @@ export class CodexSessionRuntime {
138
139
  if (!resource || !pending)
139
140
  return false;
140
141
  resource.pendingRequests.delete(requestId);
141
- clearTimeout(pending.timer);
142
142
  pending.resolve(result);
143
143
  return true;
144
144
  }
@@ -337,18 +337,17 @@ export class CodexSessionRuntime {
337
337
  }
338
338
  }
339
339
  holdServerRequest(resource, request) {
340
- if (!resource.active || !this.onServerRequest?.(resource.sessionId, request)) {
341
- return Promise.reject(new Error('CODEX_SERVER_REQUEST_UNSUPPORTED'));
342
- }
340
+ if (!resource.active)
341
+ return Promise.reject(new Error('CODEX_SERVER_REQUEST_CANCELLED'));
343
342
  if (resource.pendingRequests.size >= this.maxPendingRequests)
344
343
  return Promise.reject(new Error('CODEX_SERVER_REQUEST_LIMIT'));
345
344
  const requestId = String(request.id);
345
+ if (resource.pendingRequests.has(requestId))
346
+ return Promise.reject(new Error('CODEX_SERVER_REQUEST_DUPLICATE'));
347
+ if (!this.onServerRequest?.(resource.sessionId, request))
348
+ return Promise.reject(new Error('CODEX_SERVER_REQUEST_UNSUPPORTED'));
346
349
  return new Promise((resolve, reject) => {
347
- const timer = setTimeout(() => {
348
- if (resource.pendingRequests.delete(requestId))
349
- reject(new Error('CODEX_SERVER_REQUEST_TIMEOUT'));
350
- }, this.requestTimeoutMs);
351
- resource.pendingRequests.set(requestId, { resolve, reject, timer });
350
+ resource.pendingRequests.set(requestId, { resolve, reject });
352
351
  });
353
352
  }
354
353
  async startThread(process, session, settings = {}) {
@@ -394,8 +393,18 @@ export class CodexSessionRuntime {
394
393
  this.sessions.delete(session.id);
395
394
  });
396
395
  const notificationUnsubscribe = process.rpc.onNotification((notification) => {
397
- if (resource.active)
398
- this.onNotification?.(session.id, notification);
396
+ if (!resource.active)
397
+ return;
398
+ const resolvedRequestId = resolvedServerRequestId(notification);
399
+ if (resolvedRequestId) {
400
+ const pending = resource.pendingRequests.get(resolvedRequestId);
401
+ resource.pendingRequests.delete(resolvedRequestId);
402
+ // App-server has already cleared the request, so settle the local handler too.
403
+ // The late JSON-RPC error response is harmless and prevents an orphaned promise.
404
+ // The notification callback reconciles the durable interaction record.
405
+ pending?.reject(new Error('CODEX_SERVER_REQUEST_CLEARED'));
406
+ }
407
+ this.onNotification?.(session.id, notification);
399
408
  });
400
409
  const requestUnsubscribe = process.rpc.onServerRequest((request) => this.holdServerRequest(resource, request));
401
410
  const exitUnsubscribe = process.onExit?.(() => {
@@ -4,7 +4,9 @@
4
4
  * SPDX-License-Identifier: AGPL-3.0-or-later
5
5
  */
6
6
  function safeInteractionOutcome(value) {
7
- return value === 'approved' || value === 'denied' || value === 'answered' ? value : 'answered';
7
+ return value === 'approved' || value === 'denied' || value === 'answered' || value === 'dismissed'
8
+ ? value
9
+ : 'answered';
8
10
  }
9
11
  export class SqlitePendingInteractionStore {
10
12
  db;
@@ -13,7 +15,15 @@ export class SqlitePendingInteractionStore {
13
15
  }
14
16
  add(sessionId, interaction) {
15
17
  this.db
16
- .prepare('INSERT INTO pending_interactions (session_id,request_id,kind,payload_json,turn_id,requested_at) VALUES (?,?,?,?,?,?)')
18
+ .prepare(`INSERT INTO pending_interactions (session_id,request_id,kind,payload_json,turn_id,requested_at)
19
+ VALUES (?,?,?,?,?,?)
20
+ ON CONFLICT(session_id,request_id) DO UPDATE SET
21
+ kind = excluded.kind,
22
+ payload_json = excluded.payload_json,
23
+ turn_id = excluded.turn_id,
24
+ requested_at = excluded.requested_at,
25
+ resolved_at = NULL,
26
+ outcome = NULL`)
17
27
  .run(sessionId, interaction.requestId, interaction.kind, JSON.stringify(interaction.payload), interaction.turnId ?? null, interaction.requestedAt ?? null);
18
28
  }
19
29
  resolve(sessionId, requestId, resolvedAt, outcome = 'answered') {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gestalt-mobile",
3
- "version": "0.17.1",
3
+ "version": "0.17.3",
4
4
  "description": "Mobile-first web relay for durable Codex development sessions",
5
5
  "keywords": [
6
6
  "codex",