gestalt-mobile 0.30.2 → 0.31.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-v8UC90bI.js"></script>
20
- <link rel="stylesheet" crossorigin href="/assets/index-CKhqx71S.css">
19
+ <script type="module" crossorigin src="/assets/index-DKzUE8x1.js"></script>
20
+ <link rel="stylesheet" crossorigin href="/assets/index-DDwVphGb.css">
21
21
  </head>
22
22
  <body>
23
23
  <div id="app"></div>
@@ -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-'));
@@ -2342,6 +2402,7 @@ describe('production composition', () => {
2342
2402
  agentActivity: { root: { state: 'working' } },
2343
2403
  });
2344
2404
  expect(activityEvents.filter((message) => message.event.type === 'agent.activity.updated')).toHaveLength(initialActivityEvents + 1);
2405
+ expect(JSON.stringify(activityEvents)).not.toMatch(/ownerThreadId|ownerTaskPath|itemId|cpuPercent|rssBytes|resultArtifact/);
2345
2406
  const activitySequence = activityEvents.find((message) => message.event.type === 'agent.activity.updated').event.sequence;
2346
2407
  socket.close();
2347
2408
  const replaySocket = new WebSocket(`ws://127.0.0.1:${address.port}/api/sessions/${sessionId}/events?after=${activitySequence - 1}`, {
@@ -2500,6 +2561,36 @@ describe('production composition', () => {
2500
2561
  expect((await app.inject(`/api/sessions/${sessionId}/history`)).json().interactions).toEqual(expect.arrayContaining([
2501
2562
  expect.objectContaining({ requestId: '9', kind: 'orgPlanAttention', outcome: 'failed' }),
2502
2563
  ]));
2564
+ const quiz = handle.request({
2565
+ id: 71,
2566
+ method: 'item/tool/call',
2567
+ params: {
2568
+ tool: 'gestalt_quiz',
2569
+ arguments: {
2570
+ questions: [
2571
+ {
2572
+ id: 'mode',
2573
+ header: 'Mode',
2574
+ question: 'How should this run?',
2575
+ choices: [
2576
+ { label: 'Solo', description: 'Use one agent.' },
2577
+ { label: 'Team', description: 'Use several agents.' },
2578
+ ],
2579
+ allowCustom: false,
2580
+ },
2581
+ ],
2582
+ },
2583
+ },
2584
+ });
2585
+ const clearedQuiz = quiz.catch((error) => error);
2586
+ handle.notify({
2587
+ method: 'serverRequest/resolved',
2588
+ params: { threadId: 'thread-1', requestId: 71 },
2589
+ });
2590
+ expect(await clearedQuiz).toEqual(expect.objectContaining({ message: 'CODEX_SERVER_REQUEST_CLEARED' }));
2591
+ expect((await app.inject(`/api/sessions/${sessionId}/history`)).json().interactions).toEqual(expect.arrayContaining([
2592
+ expect.objectContaining({ requestId: '71', kind: 'quiz', resolvedAt: null }),
2593
+ ]));
2503
2594
  await app.close();
2504
2595
  });
2505
2596
  });
@@ -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,6 +52,7 @@ 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';
@@ -95,6 +97,7 @@ export async function composeRelayApp(options) {
95
97
  const withPendingInteractions = (session) => (session ? { ...session, pendingInteractions: interactions.list(session.id) } : null);
96
98
  const events = new SessionEventBus();
97
99
  let notifyAutopilotActivity = () => undefined;
100
+ let publishSessionStatus = () => undefined;
98
101
  const attentionTransitions = {
99
102
  subscribe: (sessionId, listener) => events.subscribe(sessionId, (event) => {
100
103
  if (event.type !== 'org-plan.attention-required' &&
@@ -116,7 +119,8 @@ export async function composeRelayApp(options) {
116
119
  };
117
120
  options.onAttentionTransitions?.(attentionTransitions);
118
121
  const activity = new AgentActivityRegistry((snapshot, occurredAt) => {
119
- events.publish(journal.append(snapshot.sessionId, 'agent.activity.updated', snapshot, occurredAt));
122
+ events.publish(journal.append(snapshot.sessionId, 'agent.activity.updated', toAgentActivityDto(snapshot), occurredAt));
123
+ publishSessionStatus(snapshot.sessionId, occurredAt);
120
124
  notifyAutopilotActivity(snapshot.sessionId);
121
125
  }, {
122
126
  // Evidence arms one bounded reconciliation; healthy sessions are never polled.
@@ -287,6 +291,19 @@ export async function composeRelayApp(options) {
287
291
  },
288
292
  });
289
293
  notifyAutopilotActivity = (sessionId) => autopilot.activityChanged(sessionId);
294
+ publishSessionStatus = (sessionId, occurredAt) => {
295
+ const session = sessions.find(sessionId);
296
+ if (!session)
297
+ return;
298
+ events.publish(journal.append(sessionId, 'session.status.updated', deriveSessionStatus({
299
+ session,
300
+ plan: supervisedPlans.find(sessionId),
301
+ activity: activity.snapshot(sessionId, occurredAt),
302
+ autopilot: autopilot.snapshot(sessionId),
303
+ pendingAttention: interactions.list(sessionId).length > 0,
304
+ observedAt: occurredAt,
305
+ }), occurredAt));
306
+ };
290
307
  options.onAutopilotCoordinator?.(autopilot);
291
308
  const workspaces = new FilesystemWorkspaceCatalog(root);
292
309
  const workspaceFiles = new FilesystemWorkspaceFiles();
@@ -337,6 +354,7 @@ export async function composeRelayApp(options) {
337
354
  if (interaction?.kind === 'orgPlanAttention')
338
355
  events.publish(journal.append(sessionId, 'org-plan.attention-resolved', { requestId, turnId: interaction.turnId ?? null, resolvedAt: occurredAt, outcome }, occurredAt));
339
356
  autopilot.semanticEvent(sessionId, 'interactionChanged');
357
+ publishSessionStatus(sessionId, occurredAt);
340
358
  };
341
359
  const publishAttentionSettlement = (sessionId, requestId, occurredAt, outcome) => {
342
360
  const remaining = interactions.list(sessionId);
@@ -352,6 +370,10 @@ export async function composeRelayApp(options) {
352
370
  };
353
371
  const dismissPendingInteractions = (sessionId, occurredAt, outcome = 'dismissed') => {
354
372
  for (const interaction of interactions.list(sessionId)) {
373
+ // Quiz answers remain useful after Codex clears the original dynamic-tool
374
+ // request: the client can deliver them as a follow-up prompt instead.
375
+ if (interaction.kind === 'quiz')
376
+ continue;
355
377
  if (interactions.resolve(sessionId, interaction.requestId, occurredAt, outcome))
356
378
  publishInteractionResolved(sessionId, interaction.requestId, occurredAt, outcome);
357
379
  }
@@ -381,17 +403,18 @@ export async function composeRelayApp(options) {
381
403
  if (update.kind === 'updated' && update.reason === 'supervision-start')
382
404
  autopilot.supervisionStarted(sessionId);
383
405
  autopilot.planStatusChanged(sessionId);
406
+ publishSessionStatus(sessionId, new Date().toISOString());
384
407
  };
385
408
  runtime = options.startAppServers
386
409
  ? new CodexSessionRuntime(options.launchAppServer ?? launchCodexAppServer, undefined, (sessionId, notification, origin) => {
387
410
  const occurredAt = new Date().toISOString();
388
- for (const activityFact of decodeAgentActivityFacts(sessionId, occurredAt, notification))
389
- activity.observe(activityFact);
411
+ const activityFacts = decodeAgentActivityFacts(sessionId, occurredAt, notification);
390
412
  const resolvedRequestId = resolvedServerRequestId(notification);
391
413
  if (resolvedRequestId) {
392
414
  const interaction = interactions.find(sessionId, resolvedRequestId);
393
415
  const outcome = interaction?.kind === 'orgPlanAttention' ? 'failed' : 'dismissed';
394
- if (interactions.resolve(sessionId, resolvedRequestId, occurredAt, outcome)) {
416
+ if (interaction?.kind !== 'quiz' &&
417
+ interactions.resolve(sessionId, resolvedRequestId, occurredAt, outcome)) {
395
418
  if (outcome === 'failed')
396
419
  publishAttentionSettlement(sessionId, resolvedRequestId, occurredAt, outcome);
397
420
  else
@@ -411,8 +434,11 @@ export async function composeRelayApp(options) {
411
434
  ? { ...origin, kind: 'root' }
412
435
  : origin;
413
436
  const normalized = normalizeCodexNotification(sessionId, 0, occurredAt, notification, currentSession?.workspacePath, currentSession?.activeTurnId, resolvedOrigin);
414
- if (!normalized)
437
+ if (!normalized) {
438
+ for (const activityFact of activityFacts)
439
+ activity.observe(activityFact);
415
440
  return;
441
+ }
416
442
  let completedSession;
417
443
  if (normalized.type === 'turnCompleted') {
418
444
  const turnId = normalized.payload.turn?.id;
@@ -424,6 +450,11 @@ export async function composeRelayApp(options) {
424
450
  }
425
451
  planMeasurementRefresh?.refreshNow(sessionId);
426
452
  }
453
+ // Activity publication also derives and broadcasts the session verdict.
454
+ // Complete the durable turn first so its status cannot retain the just-finished
455
+ // activeTurnId while the completion fact makes the root appear idle.
456
+ for (const activityFact of activityFacts)
457
+ activity.observe(activityFact);
427
458
  events.publish(journal.append(sessionId, normalized.type, normalized.payload, normalized.occurredAt));
428
459
  if (completedSession)
429
460
  events.publish(journal.append(sessionId, 'session.updated', completedSession, occurredAt));
@@ -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
  })),