gestalt-mobile 0.30.3 → 0.32.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.
@@ -16,8 +16,8 @@ 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-BK3qsn4l.js"></script>
20
- <link rel="stylesheet" crossorigin href="/assets/index-DHqGG2Sf.css">
19
+ <script type="module" crossorigin src="/assets/index-CxAG4Fmv.js"></script>
20
+ <link rel="stylesheet" crossorigin href="/assets/index-CcWoGWyP.css">
21
21
  </head>
22
22
  <body>
23
23
  <div id="app"></div>
@@ -18,6 +18,7 @@ import { registerSkillRoutes } from './features/skills/register-routes.js';
18
18
  import { registerOrgPlanAttentionRoutes } from './features/org-plan-attention/register-routes.js';
19
19
  import { registerAutopilotRoutes } from './features/autopilot/register-routes.js';
20
20
  import { registerFileRoutes } from './features/files/register-routes.js';
21
+ import { registerMaintenanceRoutes } from './features/maintenance/register-routes.js';
21
22
  export async function buildApp(deps) {
22
23
  // Keep the default boundary finite even when an endpoint forgot a narrower schema.
23
24
  const app = fastify({ logger: false, bodyLimit: 1024 * 1024 });
@@ -52,6 +53,7 @@ export async function buildApp(deps) {
52
53
  registerFileRoutes(app, deps);
53
54
  registerGitRoutes(app, deps);
54
55
  registerSkillRoutes(app, deps);
56
+ registerMaintenanceRoutes(app, deps);
55
57
  registerProblemHandler(app, Boolean(deps.staticDir));
56
58
  return app;
57
59
  }
@@ -690,7 +690,9 @@ describe('production composition', () => {
690
690
  url: `/api/sessions/${fixture.sessionId}/autopilot`,
691
691
  payload: { enabled: true },
692
692
  })).statusCode).toBe(200);
693
- await vi.waitFor(() => expect(timers).toHaveLength(1));
693
+ // Initial supervision schedules its control and keeps a separate,
694
+ // bounded reconciliation watchdog while activity is still stale.
695
+ await vi.waitFor(() => expect(timers).toHaveLength(2));
694
696
  const staleTimer = timers[0];
695
697
  if (action === 'close') {
696
698
  await writeFile(join(fixture.workspacePath, 'autopilot.org'), completedAutopilotPlanText());
@@ -1366,6 +1368,64 @@ describe('production composition', () => {
1366
1368
  });
1367
1369
  });
1368
1370
  describeCompositionConcern('lifecycle', () => {
1371
+ it('publishes an idle websocket status after saving a completed turn', async () => {
1372
+ const root = await mkdtemp(join(tmpdir(), 'gestalt-mobile-root-'));
1373
+ const dataDir = await mkdtemp(join(tmpdir(), 'gestalt-mobile-state-'));
1374
+ ownTemporaryPaths(root, dataDir);
1375
+ await mkdir(join(root, 'workspace'));
1376
+ const handles = [];
1377
+ const app = await composeAuthorizedApp({
1378
+ root,
1379
+ dataDir,
1380
+ relyingParty,
1381
+ installedCodexVersion: 'codex-cli 0.144.3',
1382
+ startAppServers: true,
1383
+ launchAppServer: liveAppServer(handles),
1384
+ profiles: {
1385
+ list: async () => [],
1386
+ require: async () => ({
1387
+ name: 'default',
1388
+ state: 'ok',
1389
+ status: 'ready',
1390
+ }),
1391
+ },
1392
+ });
1393
+ const sessionId = await createComposedSession(app);
1394
+ const handle = handles.find((candidate) => candidate.notify);
1395
+ expect(handle?.notify).toBeDefined();
1396
+ await app.listen({ host: '127.0.0.1', port: 0 });
1397
+ const address = app.server.address();
1398
+ if (!address || typeof address === 'string')
1399
+ throw new Error('Expected TCP listener');
1400
+ const socket = new WebSocket(`ws://127.0.0.1:${address.port}/api/sessions/${sessionId}/events?after=0`, {
1401
+ headers: {
1402
+ origin: relyingParty.publicOrigin,
1403
+ cookie: 'gestalt_mobile_session=test-session',
1404
+ },
1405
+ });
1406
+ const messages = [];
1407
+ socket.on('message', (data) => messages.push(JSON.parse(String(data))));
1408
+ await once(socket, 'open');
1409
+ const started = await app.inject({
1410
+ method: 'POST',
1411
+ url: `/api/sessions/${sessionId}/turns`,
1412
+ payload: { text: 'complete this turn' },
1413
+ });
1414
+ expect(started.statusCode).toBe(202);
1415
+ const turnId = started.json().activeTurnId;
1416
+ handle.notify({ method: 'turn/completed', params: { turn: { id: turnId } } });
1417
+ await vi.waitFor(() => {
1418
+ const status = messages
1419
+ .filter((message) => message.event.type === 'session.status.updated')
1420
+ .at(-1)?.event.payload;
1421
+ expect(status).toMatchObject({ state: 'idle' });
1422
+ });
1423
+ expect((await app.inject(`/api/sessions/${sessionId}`)).json()).toMatchObject({
1424
+ activeTurnId: null,
1425
+ });
1426
+ socket.close();
1427
+ await app.close();
1428
+ });
1369
1429
  it('forgets a session while activity reconciliation is pending without publishing late activity', async () => {
1370
1430
  const root = await mkdtemp(join(tmpdir(), 'gestalt-mobile-root-'));
1371
1431
  const dataDir = await mkdtemp(join(tmpdir(), 'gestalt-mobile-state-'));
@@ -1699,6 +1759,7 @@ describe('production composition', () => {
1699
1759
  'protected',
1700
1760
  ],
1701
1761
  ['POST', '/api/git/clone', '/api/git/clone', 'protected'],
1762
+ ['POST', '/api/maintenance/update-restart', '/api/maintenance/update-restart', 'protected'],
1702
1763
  ['GET', '/api/skills', '/api/skills', 'protected'],
1703
1764
  ['HEAD', '/api/skills', '/api/skills', 'protected'],
1704
1765
  ['GET', '/api/skill-profiles', '/api/skill-profiles', 'protected'],
@@ -2342,6 +2403,7 @@ describe('production composition', () => {
2342
2403
  agentActivity: { root: { state: 'working' } },
2343
2404
  });
2344
2405
  expect(activityEvents.filter((message) => message.event.type === 'agent.activity.updated')).toHaveLength(initialActivityEvents + 1);
2406
+ expect(JSON.stringify(activityEvents)).not.toMatch(/ownerThreadId|ownerTaskPath|itemId|cpuPercent|rssBytes|resultArtifact/);
2345
2407
  const activitySequence = activityEvents.find((message) => message.event.type === 'agent.activity.updated').event.sequence;
2346
2408
  socket.close();
2347
2409
  const replaySocket = new WebSocket(`ws://127.0.0.1:${address.port}/api/sessions/${sessionId}/events?after=${activitySequence - 1}`, {
@@ -34,6 +34,7 @@ import { SessionSupervisor } from './platform/runtime/session-supervisor.js';
34
34
  import { mapWithConcurrency } from './platform/runtime/concurrency.js';
35
35
  import { RelaySession, } from './features/sessions/model/relay-session.js';
36
36
  import { AgentActivityRegistry } from './features/agent-activity/registry.js';
37
+ import { toAgentActivityDto } from './features/agent-activity/activity-dto.js';
37
38
  import { decodeAgentActivityFacts } from './platform/codex/activity-facts.js';
38
39
  import { isAutopilotWaitLeaseCall, resolvedServerRequestId, toPendingInteraction, } from './platform/codex/server-request.js';
39
40
  import { parseOrgPlanCheckpoint, toOrgPlanCheckpointToolResponse, } from '../shared/contracts/org-plan-checkpoint.js';
@@ -51,9 +52,11 @@ import { checkpointPlanMeasurement } from './platform/plans/plan-measurement-com
51
52
  import { PlanMeasurementRefresh } from './platform/plans/plan-measurement-refresh.js';
52
53
  import { SqliteAutopilotStore } from './platform/persistence/sqlite-autopilot-store.js';
53
54
  import { AutopilotCoordinator } from './features/autopilot/application/service.js';
55
+ import { deriveSessionStatus } from './features/sessions/session-status.js';
54
56
  import { AUTOPILOT_CONTINUATION_PROMPT, AUTOPILOT_EXECUTOR_CONTINUATION_PROMPT, autopilotExecutorLaunchPrompt, defaultAutopilotPolicy, } from './features/autopilot/application/policy.js';
55
57
  import { createRelyingPartyConfig } from './config.js';
56
58
  import { parseOrgPlanAttention } from '../shared/contracts/org-plan-attention.js';
59
+ import { GestaltUpdateRestartScheduler } from './platform/process/gestalt-update-restart.js';
57
60
  import { autopilotWaitLeaseToolResponse, } from '../shared/contracts/autopilot-wait-lease.js';
58
61
  const generatedProtocolVersion = 'codex-cli 0.144.3';
59
62
  export async function composeRelayApp(options) {
@@ -95,6 +98,7 @@ export async function composeRelayApp(options) {
95
98
  const withPendingInteractions = (session) => (session ? { ...session, pendingInteractions: interactions.list(session.id) } : null);
96
99
  const events = new SessionEventBus();
97
100
  let notifyAutopilotActivity = () => undefined;
101
+ let publishSessionStatus = () => undefined;
98
102
  const attentionTransitions = {
99
103
  subscribe: (sessionId, listener) => events.subscribe(sessionId, (event) => {
100
104
  if (event.type !== 'org-plan.attention-required' &&
@@ -116,7 +120,8 @@ export async function composeRelayApp(options) {
116
120
  };
117
121
  options.onAttentionTransitions?.(attentionTransitions);
118
122
  const activity = new AgentActivityRegistry((snapshot, occurredAt) => {
119
- events.publish(journal.append(snapshot.sessionId, 'agent.activity.updated', snapshot, occurredAt));
123
+ events.publish(journal.append(snapshot.sessionId, 'agent.activity.updated', toAgentActivityDto(snapshot), occurredAt));
124
+ publishSessionStatus(snapshot.sessionId, occurredAt);
120
125
  notifyAutopilotActivity(snapshot.sessionId);
121
126
  }, {
122
127
  // Evidence arms one bounded reconciliation; healthy sessions are never polled.
@@ -287,6 +292,19 @@ export async function composeRelayApp(options) {
287
292
  },
288
293
  });
289
294
  notifyAutopilotActivity = (sessionId) => autopilot.activityChanged(sessionId);
295
+ publishSessionStatus = (sessionId, occurredAt) => {
296
+ const session = sessions.find(sessionId);
297
+ if (!session)
298
+ return;
299
+ events.publish(journal.append(sessionId, 'session.status.updated', deriveSessionStatus({
300
+ session,
301
+ plan: supervisedPlans.find(sessionId),
302
+ activity: activity.snapshot(sessionId, occurredAt),
303
+ autopilot: autopilot.snapshot(sessionId),
304
+ pendingAttention: interactions.list(sessionId).length > 0,
305
+ observedAt: occurredAt,
306
+ }), occurredAt));
307
+ };
290
308
  options.onAutopilotCoordinator?.(autopilot);
291
309
  const workspaces = new FilesystemWorkspaceCatalog(root);
292
310
  const workspaceFiles = new FilesystemWorkspaceFiles();
@@ -337,6 +355,7 @@ export async function composeRelayApp(options) {
337
355
  if (interaction?.kind === 'orgPlanAttention')
338
356
  events.publish(journal.append(sessionId, 'org-plan.attention-resolved', { requestId, turnId: interaction.turnId ?? null, resolvedAt: occurredAt, outcome }, occurredAt));
339
357
  autopilot.semanticEvent(sessionId, 'interactionChanged');
358
+ publishSessionStatus(sessionId, occurredAt);
340
359
  };
341
360
  const publishAttentionSettlement = (sessionId, requestId, occurredAt, outcome) => {
342
361
  const remaining = interactions.list(sessionId);
@@ -385,12 +404,12 @@ export async function composeRelayApp(options) {
385
404
  if (update.kind === 'updated' && update.reason === 'supervision-start')
386
405
  autopilot.supervisionStarted(sessionId);
387
406
  autopilot.planStatusChanged(sessionId);
407
+ publishSessionStatus(sessionId, new Date().toISOString());
388
408
  };
389
409
  runtime = options.startAppServers
390
410
  ? new CodexSessionRuntime(options.launchAppServer ?? launchCodexAppServer, undefined, (sessionId, notification, origin) => {
391
411
  const occurredAt = new Date().toISOString();
392
- for (const activityFact of decodeAgentActivityFacts(sessionId, occurredAt, notification))
393
- activity.observe(activityFact);
412
+ const activityFacts = decodeAgentActivityFacts(sessionId, occurredAt, notification);
394
413
  const resolvedRequestId = resolvedServerRequestId(notification);
395
414
  if (resolvedRequestId) {
396
415
  const interaction = interactions.find(sessionId, resolvedRequestId);
@@ -416,8 +435,11 @@ export async function composeRelayApp(options) {
416
435
  ? { ...origin, kind: 'root' }
417
436
  : origin;
418
437
  const normalized = normalizeCodexNotification(sessionId, 0, occurredAt, notification, currentSession?.workspacePath, currentSession?.activeTurnId, resolvedOrigin);
419
- if (!normalized)
438
+ if (!normalized) {
439
+ for (const activityFact of activityFacts)
440
+ activity.observe(activityFact);
420
441
  return;
442
+ }
421
443
  let completedSession;
422
444
  if (normalized.type === 'turnCompleted') {
423
445
  const turnId = normalized.payload.turn?.id;
@@ -429,6 +451,11 @@ export async function composeRelayApp(options) {
429
451
  }
430
452
  planMeasurementRefresh?.refreshNow(sessionId);
431
453
  }
454
+ // Activity publication also derives and broadcasts the session verdict.
455
+ // Complete the durable turn first so its status cannot retain the just-finished
456
+ // activeTurnId while the completion fact makes the root appear idle.
457
+ for (const activityFact of activityFacts)
458
+ activity.observe(activityFact);
432
459
  events.publish(journal.append(sessionId, normalized.type, normalized.payload, normalized.occurredAt));
433
460
  if (completedSession)
434
461
  events.publish(journal.append(sessionId, 'session.updated', completedSession, occurredAt));
@@ -616,6 +643,7 @@ export async function composeRelayApp(options) {
616
643
  };
617
644
  },
618
645
  },
646
+ maintenance: { updateRestart: new GestaltUpdateRestartScheduler() },
619
647
  skills: {
620
648
  workspaces,
621
649
  profiles: options.profiles,
@@ -0,0 +1,35 @@
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
+ /** Public roster projection: never expose process, thread, task-path, or host metrics identifiers. */
7
+ export function toAgentActivityDto(snapshot) {
8
+ return {
9
+ sessionId: snapshot.sessionId,
10
+ root: snapshot.root,
11
+ subagents: snapshot.subagents.slice(0, 64).map((child) => ({
12
+ id: child.id,
13
+ ...(child.nickname ? { nickname: child.nickname } : {}),
14
+ ...(child.role ? { role: child.role } : {}),
15
+ ...(child.model ? { model: child.model } : {}),
16
+ ...(child.canonicalTaskName ? { canonicalTaskName: child.canonicalTaskName } : {}),
17
+ ...(child.canonicalPosition ? { canonicalPosition: child.canonicalPosition } : {}),
18
+ ...(child.continuationGeneration
19
+ ? { continuationGeneration: child.continuationGeneration }
20
+ : {}),
21
+ ...(child.outcome ? { outcome: child.outcome } : {}),
22
+ ownedProcesses: (child.ownedProcesses ?? []).slice(0, 64).map((process) => ({
23
+ state: process.state,
24
+ ownership: process.ownership,
25
+ observedAt: process.observedAt,
26
+ })),
27
+ state: child.state,
28
+ reason: child.reason,
29
+ observedAt: child.observedAt,
30
+ lastActivityAt: child.lastActivityAt,
31
+ })),
32
+ aggregateSubagents: snapshot.aggregateSubagents,
33
+ confidence: snapshot.confidence,
34
+ };
35
+ }
@@ -84,6 +84,9 @@ export function projectAgentActivity(current, fact) {
84
84
  const before = children.get(fact.childId);
85
85
  const taskPath = fact.childTaskPath ?? before?.taskPath;
86
86
  const identity = taskPath ? parseOrgPlanAgentIdentity(taskPath) : null;
87
+ const terminalReviewer = taskPath?.split('/').filter(Boolean).at(-1) === 'final_review' ||
88
+ before?.canonicalTaskName === 'final_review' ||
89
+ fact.childRole === 'org-plan-reviewer';
87
90
  const outcome = childOutcome(fact.childStatus, fact.collaborationAction, before?.outcome);
88
91
  const child = Object.freeze({
89
92
  id: fact.childId,
@@ -110,7 +113,9 @@ export function projectAgentActivity(current, fact) {
110
113
  canonicalPosition: identity.canonicalPosition,
111
114
  continuationGeneration: identity.generation,
112
115
  }
113
- : {}),
116
+ : terminalReviewer
117
+ ? { canonicalTaskName: 'final_review' }
118
+ : {}),
114
119
  ...(outcome ? { outcome } : {}),
115
120
  ...(fact.childOwnedProcesses
116
121
  ? { ownedProcesses: Object.freeze([...fact.childOwnedProcesses]) }
@@ -143,14 +148,20 @@ export function projectAgentActivity(current, fact) {
143
148
  export function withActivityConfidence(snapshot, confidence) {
144
149
  return snapshot.confidence === confidence ? snapshot : Object.freeze({ ...snapshot, confidence });
145
150
  }
146
- /** Process loss revokes child identities; recovery must repopulate them from a fresh list read. */
151
+ /**
152
+ * A process disconnect makes the roster stale, but is not evidence that its
153
+ * children ceased to exist. Keep their authoritative identity and present the
154
+ * last known physical generation as disconnected until disposal or a later
155
+ * reconciliation replaces it.
156
+ */
147
157
  export function clearAgentActivityChildren(snapshot) {
148
158
  if (snapshot.subagents.length === 0 && snapshot.aggregateSubagents === 'idle')
149
159
  return snapshot;
160
+ const subagents = Object.freeze(snapshot.subagents.map((child) => Object.freeze({ ...child, state: 'disconnected', reason: 'processExited' })));
150
161
  return Object.freeze({
151
162
  ...snapshot,
152
- subagents: Object.freeze([]),
153
- aggregateSubagents: 'idle',
163
+ subagents,
164
+ aggregateSubagents: aggregate(subagents),
154
165
  });
155
166
  }
156
167
  function applyStatus(root, status) {
@@ -231,6 +242,12 @@ function semantic(snapshot) {
231
242
  nickname: child.nickname,
232
243
  role: child.role,
233
244
  model: child.model,
245
+ taskPath: child.taskPath,
246
+ canonicalTaskName: child.canonicalTaskName,
247
+ canonicalPosition: child.canonicalPosition,
248
+ continuationGeneration: child.continuationGeneration,
249
+ outcome: child.outcome,
250
+ ownedProcesses: child.ownedProcesses,
234
251
  state: child.state,
235
252
  reason: child.reason,
236
253
  })),