gestalt-mobile 0.17.0 → 0.17.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.
@@ -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-Cfo4jnPb.js"></script>
19
+ <script type="module" crossorigin src="/assets/index-DgD-0I3M.js"></script>
20
20
  <link rel="stylesheet" crossorigin href="/assets/index-e2lJ5XqL.css">
21
21
  </head>
22
22
  <body>
@@ -346,6 +346,12 @@ export async function composeRelayApp(options) {
346
346
  planRoutes: {
347
347
  exists: (id) => sessions.find(id) !== null,
348
348
  find: (id) => supervisedPlans.find(id),
349
+ refresh: async (id) => {
350
+ const refreshed = await planStatusSource.refresh(id);
351
+ if (refreshed?.kind === 'updated')
352
+ return refreshed.plan;
353
+ return refreshed ? null : supervisedPlans.find(id);
354
+ },
349
355
  removeStatus: (id) => planStatusSource.remove(id, supervisedPlans.identity(id) ?? undefined),
350
356
  clear: (id) => supervisedPlans.clear(id),
351
357
  closed: (id) => {
@@ -4,11 +4,11 @@
4
4
  * SPDX-License-Identifier: AGPL-3.0-or-later
5
5
  */
6
6
  export function registerGetPlan(app, deps) {
7
- app.get('/api/sessions/:id/plan', (request, reply) => {
7
+ app.get('/api/sessions/:id/plan', async (request, reply) => {
8
8
  const id = request.params.id;
9
9
  if (!deps.exists(id))
10
10
  return reply.code(404).send({ code: 'SESSION_NOT_FOUND' });
11
- const plan = deps.find(id);
11
+ const plan = await deps.refresh(id);
12
12
  return plan ? reply.send(plan) : reply.code(204).send();
13
13
  });
14
14
  }
@@ -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
@@ -4,23 +4,29 @@
4
4
  * SPDX-License-Identifier: AGPL-3.0-or-later
5
5
  */
6
6
  import { resolve } from 'node:path';
7
- /** Caches successful workspace inspections until a Git mutation invalidates them. */
7
+ /** Coalesces concurrent inspections without retaining results across refreshes. */
8
8
  export class GitSummaryCache {
9
9
  inspectWorkspace;
10
- summaries = new Map();
10
+ inspections = new Map();
11
11
  constructor(inspectWorkspace) {
12
12
  this.inspectWorkspace = inspectWorkspace;
13
13
  }
14
14
  async inspect(workspacePath) {
15
15
  const key = resolve(workspacePath);
16
- const cached = this.summaries.get(key);
17
- if (cached !== undefined)
18
- return cached;
19
- const summary = await this.inspectWorkspace(workspacePath);
20
- this.summaries.set(key, summary);
21
- return summary;
16
+ const pending = this.inspections.get(key);
17
+ if (pending)
18
+ return pending;
19
+ const inspection = this.inspectWorkspace(workspacePath);
20
+ this.inspections.set(key, inspection);
21
+ try {
22
+ return await inspection;
23
+ }
24
+ finally {
25
+ if (this.inspections.get(key) === inspection)
26
+ this.inspections.delete(key);
27
+ }
22
28
  }
23
29
  invalidate(workspacePath) {
24
- this.summaries.delete(resolve(workspacePath));
30
+ this.inspections.delete(resolve(workspacePath));
25
31
  }
26
32
  }
@@ -44,6 +44,9 @@ export class FilesystemPlanStatusSource {
44
44
  lease.close();
45
45
  this.leases.clear();
46
46
  }
47
+ async refresh(sessionId) {
48
+ return (await this.leases.get(sessionId)?.refreshCurrent()) ?? null;
49
+ }
47
50
  async remove(sessionId, identity) {
48
51
  const statusPath = this.activeStatusPaths.get(sessionId);
49
52
  const signal = statusPath ? await this.readStatusForRollback(statusPath) : undefined;
@@ -220,9 +223,12 @@ class ActiveLease {
220
223
  this.debounce = undefined;
221
224
  const pendingStatusPath = this.pendingStatusPath;
222
225
  this.pendingStatusPath = undefined;
223
- void (pendingStatusPath ? this.refresh(pendingStatusPath) : this.refreshLatest());
226
+ void (pendingStatusPath ? this.refreshPath(pendingStatusPath) : this.refreshLatest());
224
227
  }, 25);
225
228
  }
229
+ async refreshCurrent() {
230
+ return this.activeStatusPath ? this.refreshPath(this.activeStatusPath) : this.refreshLatest();
231
+ }
226
232
  async refreshLatest() {
227
233
  try {
228
234
  const candidates = (await readdir(this.statusDirectory))
@@ -239,15 +245,13 @@ class ActiveLease {
239
245
  .filter((candidate) => candidate.signal !== null)
240
246
  .sort((left, right) => right.signal.updatedAt.localeCompare(left.signal.updatedAt) ||
241
247
  right.modifiedAt - left.modifiedAt)[0];
242
- if (latest)
243
- await this.refresh(latest.statusPath, latest.signal);
248
+ return latest ? this.refreshPath(latest.statusPath, latest.signal) : null;
244
249
  }
245
250
  catch {
246
- if (!this.closed)
247
- this.emitUnavailable();
251
+ return !this.closed ? this.emitUnavailable() : null;
248
252
  }
249
253
  }
250
- async refresh(statusPath, parsedSignal) {
254
+ async refreshPath(statusPath, parsedSignal) {
251
255
  try {
252
256
  const signal = parsedSignal ?? parseSignal(await this.planReadFilesystem.readFile(statusPath, 'utf8'));
253
257
  if (!signal)
@@ -260,7 +264,7 @@ class ActiveLease {
260
264
  throw new Error('PATH_OUTSIDE_WORKSPACE');
261
265
  const identity = createHash('sha256').update(planPath).digest('hex');
262
266
  if (await this.isDismissed(identity))
263
- return;
267
+ return null;
264
268
  const result = parseSupervisedPlan({
265
269
  source: await this.planReadFilesystem.readFile(planPath, 'utf8'),
266
270
  planPath,
@@ -285,24 +289,27 @@ class ActiveLease {
285
289
  }
286
290
  if (previousStatusPath && previousStatusPath !== statusPath)
287
291
  await rm(previousStatusPath, { force: true }).catch(() => { });
292
+ return update;
288
293
  }
289
294
  }
290
295
  else if (!this.closed) {
291
- this.emitUnavailable();
296
+ return this.emitUnavailable();
292
297
  }
298
+ return null;
293
299
  }
294
300
  catch (error) {
295
301
  if (error.code === 'ENOENT' &&
296
302
  this.activeStatusPath !== undefined &&
297
303
  statusPath !== this.activeStatusPath)
298
- return;
299
- if (!this.closed)
300
- this.emitUnavailable();
304
+ return null;
305
+ return !this.closed ? this.emitUnavailable() : null;
301
306
  }
302
307
  }
303
308
  emitUnavailable() {
304
309
  this.lastEmittedUpdate = undefined;
305
- this.listener({ kind: 'unavailable', code: 'PLAN_STATUS_UNAVAILABLE' });
310
+ const update = { kind: 'unavailable', code: 'PLAN_STATUS_UNAVAILABLE' };
311
+ this.listener(update);
312
+ return update;
306
313
  }
307
314
  }
308
315
  function parseSignal(source) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gestalt-mobile",
3
- "version": "0.17.0",
3
+ "version": "0.17.2",
4
4
  "description": "Mobile-first web relay for durable Codex development sessions",
5
5
  "keywords": [
6
6
  "codex",