gestalt-mobile 0.37.0 → 0.38.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.
package/README.md CHANGED
@@ -32,6 +32,17 @@ The command prints the loopback URL when it is ready. Open that URL in a
32
32
  browser. Press Ctrl-C, or send SIGINT or SIGTERM, to stop the HTTP server,
33
33
  active Codex subprocesses, and database cleanly.
34
34
 
35
+ Use `--icon <path>` to replace the installed PWA application icon with a local
36
+ SVG or PNG. Relative paths resolve from the directory where Mobile is started:
37
+
38
+ ```sh
39
+ gestalt-mobile --cwd . --icon ./branding/project-icon.svg
40
+ ```
41
+
42
+ Without `--icon`, Mobile keeps its packaged Gestalt icons. SVG icons are
43
+ declared scalable; PNG dimensions are read from the file. A square PNG of at
44
+ least 512×512 is recommended for consistent launcher presentation.
45
+
35
46
  ### Run this checkout
36
47
 
37
48
  `npm run start` builds and runs the current checkout with the same managed
@@ -149,6 +160,7 @@ checks with `npm run format:check`, `npm run license:check`, `npm run check`,
149
160
  | `--public-origin <origin>` | Loopback Vite origin | Exact browser origin for passkeys; required for non-loopback hosts |
150
161
  | `--disable-passkey-auth` | Off | Disable passkey access control and serve every client directly |
151
162
  | `--data-dir <path>` | XDG state directory | Directory containing `relay.sqlite` |
163
+ | `--icon <path>` | Gestalt icons | SVG or PNG used when installing the PWA |
152
164
  | `--skills <profile>` | | Apply a saved global profile to every session |
153
165
  | `--skills list` | | List global profiles without starting the server |
154
166
  | `--help` | | Print usage without starting the application |
@@ -19,6 +19,7 @@ import { registerOrgPlanAttentionRoutes } from './features/org-plan-attention/re
19
19
  import { registerAutopilotRoutes } from './features/autopilot/register-routes.js';
20
20
  import { registerFileRoutes } from './features/files/register-routes.js';
21
21
  import { registerMaintenanceRoutes } from './features/maintenance/register-routes.js';
22
+ import { registerPwaRoutes } from './features/pwa/register-routes.js';
22
23
  export async function buildApp(deps) {
23
24
  // Keep the default boundary finite even when an endpoint forgot a narrower schema.
24
25
  const app = fastify({ logger: false, bodyLimit: 1024 * 1024 });
@@ -32,6 +33,8 @@ export async function buildApp(deps) {
32
33
  return payload;
33
34
  });
34
35
  await app.register(fastifyCookie);
36
+ if (deps.pwaIcon)
37
+ registerPwaRoutes(app, deps.pwaIcon);
35
38
  if (deps.staticDir)
36
39
  await app.register(fastifyStatic, { root: deps.staticDir });
37
40
  if (deps.auth)
@@ -14,6 +14,7 @@ import { composeRelayApp } from './composition.js';
14
14
  import { CliUsageError, parseConfig } from './config.js';
15
15
  import { LauncherProfileCatalog } from './platform/catalog/launcher-profile-catalog.js';
16
16
  import { FilesystemSkillProfileStore } from './platform/skills/filesystem-skill-profile-store.js';
17
+ import { loadPwaIcon } from './platform/pwa/load-pwa-icon.js';
17
18
  const runFile = promisify(execFile);
18
19
  export const usage = `Usage: gestalt-mobile [options]
19
20
 
@@ -24,6 +25,7 @@ Options:
24
25
  --public-origin <origin> Exact HTTPS browser origin for passkeys (localhost may use HTTP)
25
26
  --disable-passkey-auth Disable passkey access control (unsafe on shared or network hosts)
26
27
  --data-dir <path> Directory for persistent application data
28
+ --icon <path> SVG or PNG used as the installed PWA application icon
27
29
  --skills <profile> Use a global skill profile for every session
28
30
  --skills list List saved global skill profiles without starting the server
29
31
  --help Show this help
@@ -205,6 +207,14 @@ export async function runCli(dependencies = {}) {
205
207
  stderr.write(`Unknown skill profile: ${config.skillsProfile}\n\n${usage}\n`);
206
208
  return 2;
207
209
  }
210
+ let pwaIcon;
211
+ try {
212
+ pwaIcon = config.iconPath ? await loadPwaIcon(config.iconPath) : undefined;
213
+ }
214
+ catch (error) {
215
+ stderr.write(`${error instanceof Error ? error.message : 'Invalid --icon file'}\n\n${usage}\n`);
216
+ return 2;
217
+ }
208
218
  const diagnosis = await (dependencies.runStartupDoctor ?? runStartupDoctor)(cwd);
209
219
  if (diagnosis.report)
210
220
  stderr.write(diagnosis.report);
@@ -219,6 +229,7 @@ export async function runCli(dependencies = {}) {
219
229
  relyingParty: config.relyingParty,
220
230
  passkeyAuthEnabled: config.passkeyAuthEnabled,
221
231
  staticDir: packagedClientDir(moduleUrl),
232
+ pwaIcon,
222
233
  profiles: new LauncherProfileCatalog(),
223
234
  installedCodexVersion,
224
235
  installedKimiVersion,
@@ -149,18 +149,18 @@ export async function composeRelayApp(options) {
149
149
  reconcile: async (sessionId) => {
150
150
  const session = sessions.find(sessionId);
151
151
  if (!session)
152
- return;
152
+ return false;
153
153
  // Kimi sessions own no child-process projection; one detached history
154
154
  // read supplies the whole reconciliation, mirroring the codex tail.
155
155
  if (session.provider === 'kimi') {
156
156
  if (!kimiRuntime)
157
- return;
157
+ return false;
158
158
  const history = await kimiRuntime.readHistory(session);
159
159
  // Forget may commit while the detached history reader is still in
160
160
  // flight. Revalidate ownership before handing its result to the
161
161
  // registry, whose publisher intentionally keeps the journal strict.
162
162
  if (!sessions.find(sessionId))
163
- return;
163
+ return false;
164
164
  const occurredAt = new Date().toISOString();
165
165
  activity.observe({
166
166
  sessionId,
@@ -169,16 +169,22 @@ export async function composeRelayApp(options) {
169
169
  ...(session.threadId ? { threadId: session.threadId } : {}),
170
170
  ...(history.activeTurnId ? { turnId: history.activeTurnId } : {}),
171
171
  });
172
- return;
172
+ return true;
173
173
  }
174
174
  if (!runtime)
175
- return;
175
+ return false;
176
176
  const history = await runtime.readHistory(session);
177
- // Forget may commit while the detached history reader is still in
178
- // flight. Revalidate ownership before handing its result to the
179
- // registry, whose publisher intentionally keeps the journal strict.
177
+ const children = await runtime.listDirectChildren(session);
178
+ const childProcesses = new Map();
179
+ await mapWithConcurrency([...children], 4, async (child) => {
180
+ childProcesses.set(child.id, await runtime.inspectChildProcesses(session, child));
181
+ });
182
+ // The writer read is also asynchronous; it cannot publish after the
183
+ // durable owner has gone away either.
180
184
  if (!sessions.find(sessionId))
181
- return;
185
+ return false;
186
+ // Publish only after the entire authoritative read succeeds. A fresh
187
+ // root with stale child topology is not a valid supervision snapshot.
182
188
  const occurredAt = new Date().toISOString();
183
189
  activity.observe({
184
190
  sessionId,
@@ -187,19 +193,11 @@ export async function composeRelayApp(options) {
187
193
  ...(session.threadId ? { threadId: session.threadId } : {}),
188
194
  ...(history.activeTurnId ? { turnId: history.activeTurnId } : {}),
189
195
  });
190
- const children = await runtime.listDirectChildren(session);
191
- const childProcesses = new Map();
192
- await mapWithConcurrency([...children], 4, async (child) => {
193
- childProcesses.set(child.id, await runtime.inspectChildProcesses(session, child));
194
- });
195
- // The writer read is also asynchronous; it cannot publish after the
196
- // durable owner has gone away either.
197
- if (!sessions.find(sessionId))
198
- return;
199
196
  activity.childrenReconciled(sessionId, occurredAt, children.map((child) => ({
200
197
  ...child,
201
198
  processes: childProcesses.get(child.id) ?? [],
202
199
  })));
200
+ return true;
203
201
  },
204
202
  });
205
203
  const autopilot = new AutopilotCoordinator({
@@ -1028,6 +1026,7 @@ export async function composeRelayApp(options) {
1028
1026
  },
1029
1027
  logger: console,
1030
1028
  staticDir: options.staticDir,
1029
+ pwaIcon: options.pwaIcon,
1031
1030
  recentThreads: recentThreadsForRelay,
1032
1031
  bootstrap: {
1033
1032
  workspaces,
@@ -15,6 +15,7 @@ const optionNames = new Set([
15
15
  '--data-dir',
16
16
  '--public-origin',
17
17
  '--skills',
18
+ '--icon',
18
19
  ]);
19
20
  const flagNames = new Set(['--disable-passkey-auth']);
20
21
  export function parseConfig(args, cwd = process.cwd()) {
@@ -57,8 +58,14 @@ export function parseConfig(args, cwd = process.cwd()) {
57
58
  root: resolve(cwd, values.get('--cwd') ?? '.'),
58
59
  dataDir: values.get('--data-dir'),
59
60
  skillsProfile: values.get('--skills'),
61
+ iconPath: values.has('--icon') ? resolveIconPath(cwd, values.get('--icon')) : undefined,
60
62
  };
61
63
  }
64
+ function resolveIconPath(cwd, value) {
65
+ if (!/\.(?:png|svg)$/i.test(value))
66
+ throw new CliUsageError('--icon must reference an SVG or PNG file');
67
+ return resolve(cwd, value);
68
+ }
62
69
  export function normalizePublicOrigin(value) {
63
70
  if (!/^[a-z][a-z\d+.-]*:\/\/[^/?#\s\\\\]+\/?$/i.test(value))
64
71
  throw new CliUsageError('--public-origin must be a bare origin without a path, query, or fragment');
@@ -157,7 +157,9 @@ export class AgentActivityRegistry {
157
157
  const now = this.options.now?.() ?? new Date().toISOString();
158
158
  this.reconciling(sessionId, now);
159
159
  try {
160
- await this.options.reconcile?.(sessionId);
160
+ const observed = await this.options.reconcile?.(sessionId);
161
+ if (observed !== true)
162
+ throw new Error('AGENT_ACTIVITY_RECONCILE_NO_EVIDENCE');
161
163
  if (this.#generation.get(sessionId) === generation)
162
164
  this.reconciled(sessionId, this.options.now?.() ?? now);
163
165
  }
@@ -91,7 +91,7 @@ export function decideAutopilot(input) {
91
91
  return { kind: 'safetyPause', reason: 'actionRateExceeded' };
92
92
  if (!activity || activity.confidence !== 'fresh')
93
93
  return { kind: 'reconcile' };
94
- if (Date.parse(now) - Date.parse(activity.root.lastActivityAt) > policy.staleAfterMs)
94
+ if (Date.parse(now) - Date.parse(activity.root.observedAt) > policy.staleAfterMs)
95
95
  return { kind: 'reconcile' };
96
96
  const disposition = classifyAgentActivity(activity);
97
97
  if (disposition === 'attention')
@@ -417,9 +417,11 @@ export class AutopilotCoordinator {
417
417
  case 'scheduleContinuation':
418
418
  if (session?.activeTurnId || this.deps.pendingInteraction(sessionId))
419
419
  break;
420
+ const plan = this.deps.plan(sessionId)?.plan;
420
421
  if (this.deps.executorController &&
421
- this.deps.plan(sessionId)?.plan &&
422
- this.currentExecutor(sessionId, this.deps.plan(sessionId).plan, prior.consecutiveNoProgress, prior.executor)) {
422
+ plan &&
423
+ !this.requiresRootBoundary(prior, plan) &&
424
+ this.currentExecutor(sessionId, plan, prior.consecutiveNoProgress, prior.executor)) {
423
425
  this.enqueue(sessionId, async () => {
424
426
  await this.enforceSupervisedLifecycle(sessionId, 'stateChanged');
425
427
  });
@@ -1012,6 +1014,7 @@ export class AutopilotCoordinator {
1012
1014
  if (this.deps.executorController &&
1013
1015
  retained &&
1014
1016
  !current.executor?.replacement &&
1017
+ !this.requiresRootBoundary(current, retained.plan) &&
1015
1018
  this.currentExecutor(sessionId, retained.plan, current.consecutiveNoProgress, current.executor)) {
1016
1019
  const now = this.deps.now();
1017
1020
  const cancelled = this.cancelScheduledControl(current, now);
@@ -1159,6 +1162,10 @@ export class AutopilotCoordinator {
1159
1162
  !state?.requestedEnabled ||
1160
1163
  this.deps.pendingInteraction(sessionId))
1161
1164
  return false;
1165
+ // Checkpoints are root-owned report boundaries. Once an executor has made
1166
+ // one eligible, never mechanically resume it past the unreported result.
1167
+ if (this.requiresRootBoundary(state, retained.plan))
1168
+ return false;
1162
1169
  const executor = this.currentExecutor(sessionId, retained.plan, state.consecutiveNoProgress, state.executor);
1163
1170
  const executorChanged = executor ? this.persistExecutor(sessionId, executor) : false;
1164
1171
  if (executor && (await this.reconcileSplitBrain(sessionId, executor)))
@@ -1167,6 +1174,18 @@ export class AutopilotCoordinator {
1167
1174
  this.supersedeExecutorCommands(sessionId, retained.identity, retained.plan, executor);
1168
1175
  if (executorChanged && this.wakeForExecutorChange(sessionId, executor))
1169
1176
  return true;
1177
+ if (executor && this.executorTurnActive(executor, this.deps.activity(sessionId))) {
1178
+ // A malformed root yield must not send a duplicate continuation into a
1179
+ // still-running executor. A valid parked lease already owns its wake;
1180
+ // otherwise retain one bounded reconciliation watchdog as the fallback.
1181
+ const parkedLease = state.supervision?.waitLease;
1182
+ const parkedWakeLive = state.supervision?.outcome === 'parked' &&
1183
+ parkedLease &&
1184
+ this.parkedSubscriptions.get(sessionId) === parkedLease.id;
1185
+ if (!parkedWakeLive)
1186
+ this.armExecutorRefresh(sessionId, this.deps.policy.executorContinuationMaxMs);
1187
+ return true;
1188
+ }
1170
1189
  const decision = decideSupervisedLifecycle({
1171
1190
  plan: retained.plan,
1172
1191
  event,
@@ -1249,6 +1268,21 @@ export class AutopilotCoordinator {
1249
1268
  }
1250
1269
  }
1251
1270
  }
1271
+ requiresRootBoundary(state, plan) {
1272
+ const reportedL2 = new Set(state.checkpoints?.reportedL2Ids ?? []);
1273
+ const reportedL1 = new Set(state.checkpoints?.reportedL1Ids ?? []);
1274
+ const epochs = new Map(state.checkpoints?.completionEpochs?.map((entry) => [entry.target, entry]) ?? []);
1275
+ const epochNeedsReport = (target) => {
1276
+ const epoch = epochs.get(target);
1277
+ return Boolean(epoch?.reopened || epoch?.completed === false);
1278
+ };
1279
+ return plan.steps.some((l1) => l1.children.some((l2) => l2.state === 'DONE' &&
1280
+ (!reportedL2.has(JSON.stringify([l1.id, l2.id])) ||
1281
+ epochNeedsReport(checkpointTarget('l2', l1.id, l2.id)))) ||
1282
+ (l1.state === 'DONE' &&
1283
+ l1.reviewStatus === 'REVIEWED' &&
1284
+ (!reportedL1.has(l1.id) || epochNeedsReport(checkpointTarget('l1', l1.id)))));
1285
+ }
1252
1286
  currentExecutor(sessionId, plan, continuationCount, persisted) {
1253
1287
  const stepIndex = plan.steps.findIndex((step) => step.id === plan.currentStepId || step.state === 'WIP');
1254
1288
  const index = stepIndex >= 0 ? stepIndex : plan.steps.findIndex((step) => step.state !== 'DONE');
@@ -1987,6 +2021,14 @@ export class AutopilotCoordinator {
1987
2021
  child.state === 'awaitingAgent' ||
1988
2022
  child.ownedProcesses?.some((process) => process.state === 'running' || process.state === 'detached-active')));
1989
2023
  }
2024
+ executorTurnActive(executor, activity) {
2025
+ if (activity?.confidence !== 'fresh')
2026
+ return false;
2027
+ const child = activity.subagents.find((candidate) => (candidate.threadId ?? candidate.id) === executor.threadId &&
2028
+ candidate.taskPath === executor.taskPath &&
2029
+ (candidate.continuationGeneration ?? 1) === executor.continuationGeneration);
2030
+ return child?.state === 'working' || child?.state === 'awaitingAgent';
2031
+ }
1990
2032
  /** Builds both GET and pre-commit event payloads from the same prospective facts. */
1991
2033
  snapshotFor(next, prospectiveControl) {
1992
2034
  const session = this.deps.session(next.sessionId);
@@ -2180,7 +2222,7 @@ export class AutopilotCoordinator {
2180
2222
  // yield to. A later activity/watchdog event will evaluate it again.
2181
2223
  const activity = this.deps.activity(sessionId);
2182
2224
  if (activity?.confidence === 'fresh' &&
2183
- Date.parse(this.deps.now()) - Date.parse(activity.root.lastActivityAt) <=
2225
+ Date.parse(this.deps.now()) - Date.parse(activity.root.observedAt) <=
2184
2226
  this.deps.policy.staleAfterMs) {
2185
2227
  this.evaluate(sessionId);
2186
2228
  }
@@ -0,0 +1,36 @@
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
+ const manifestBase = {
7
+ id: '/',
8
+ name: 'Gestalt Mobile',
9
+ short_name: 'Gestalt',
10
+ description: 'A mobile interface for durable Codex development sessions.',
11
+ start_url: '/',
12
+ scope: '/',
13
+ display: 'standalone',
14
+ background_color: '#141414',
15
+ theme_color: '#141414',
16
+ categories: ['productivity', 'utilities'],
17
+ };
18
+ /** Overrides only install metadata; ordinary client assets stay package-static. */
19
+ export function registerPwaRoutes(app, icon) {
20
+ const iconPath = `/install-icon.${icon.extension}`;
21
+ app.get('/manifest.webmanifest', async (_request, reply) => reply
22
+ .header('Cache-Control', 'no-cache')
23
+ .type('application/manifest+json')
24
+ .send({
25
+ ...manifestBase,
26
+ icons: [
27
+ {
28
+ src: iconPath,
29
+ sizes: icon.sizes,
30
+ type: icon.contentType,
31
+ purpose: 'any',
32
+ },
33
+ ],
34
+ }));
35
+ app.get(iconPath, async (_request, reply) => reply.header('Cache-Control', 'no-cache').type(icon.contentType).send(icon.bytes));
36
+ }
@@ -0,0 +1,36 @@
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 { readFile } from 'node:fs/promises';
7
+ import { extname } from 'node:path';
8
+ const maxIconBytes = 5 * 1024 * 1024;
9
+ const pngSignature = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
10
+ export async function loadPwaIcon(path) {
11
+ let bytes;
12
+ try {
13
+ bytes = await readFile(path);
14
+ }
15
+ catch {
16
+ throw new Error(`Unable to read --icon file: ${path}`);
17
+ }
18
+ if (bytes.length === 0 || bytes.length > maxIconBytes)
19
+ throw new Error('--icon must be a non-empty SVG or PNG no larger than 5 MiB');
20
+ const extension = extname(path).toLowerCase();
21
+ if (extension === '.svg') {
22
+ if (!/<svg(?:\s|>)/i.test(bytes.toString('utf8')))
23
+ throw new Error('--icon SVG does not contain an <svg> root');
24
+ return { bytes, contentType: 'image/svg+xml', extension: 'svg', sizes: 'any' };
25
+ }
26
+ if (extension !== '.png' ||
27
+ bytes.length < 24 ||
28
+ !bytes.subarray(0, 8).equals(pngSignature) ||
29
+ bytes.toString('ascii', 12, 16) !== 'IHDR')
30
+ throw new Error('--icon PNG has an invalid header');
31
+ const width = bytes.readUInt32BE(16);
32
+ const height = bytes.readUInt32BE(20);
33
+ if (width === 0 || height === 0)
34
+ throw new Error('--icon PNG has invalid dimensions');
35
+ return { bytes, contentType: 'image/png', extension: 'png', sizes: `${width}x${height}` };
36
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gestalt-mobile",
3
- "version": "0.37.0",
3
+ "version": "0.38.0",
4
4
  "description": "Mobile-first web relay for durable Codex development sessions",
5
5
  "keywords": [
6
6
  "codex",