gestalt-mobile 0.30.3 → 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.
@@ -3,6 +3,7 @@
3
3
  * Designed by Denis Roio <jaromil@dyne.org>
4
4
  * SPDX-License-Identifier: AGPL-3.0-or-later
5
5
  */
6
+ import { observableWakeConditions, } from './supervision-protocol.js';
6
7
  export function disabledAutopilot(sessionId, now) {
7
8
  return {
8
9
  sessionId,
@@ -18,10 +19,99 @@ export function disabledAutopilot(sessionId, now) {
18
19
  updatedAt: now,
19
20
  };
20
21
  }
21
- export function autopilotSnapshot(state, retryLimit) {
22
+ export function autopilotSnapshot(state, retryLimit, facts = {
23
+ activeTurn: false,
24
+ executorActive: false,
25
+ control: 'none',
26
+ timerArmed: false,
27
+ reconciling: false,
28
+ planMatches: false,
29
+ parkedSubscriptionActive: false,
30
+ transitionFresh: false,
31
+ observedAt: state.updatedAt,
32
+ }) {
33
+ const supervision = state.supervision?.outcome ?? 'none';
34
+ const waitCategories = supportedWakeCategories(state.supervision?.waitLease?.wakeConditions ?? []);
35
+ const parkedWait = supervision === 'parked' && waitCategories.length > 0 && facts.parkedSubscriptionActive;
36
+ const evaluationAt = state.nextEvaluationAt ? Date.parse(state.nextEvaluationAt) : Number.NaN;
37
+ const scheduled = state.state === 'backoff' &&
38
+ facts.control === 'scheduled' &&
39
+ facts.timerArmed &&
40
+ Number.isFinite(evaluationAt) &&
41
+ evaluationAt > Date.parse(facts.observedAt);
42
+ const rootWorking = facts.activeTurn;
43
+ // Runtime observations are current at observedAt. A slow or old durable
44
+ // transition must not turn an actually active root/timer/reconciliation or
45
+ // installed wait lease into a false idle signal.
46
+ const liveContinuation = parkedWait || scheduled || rootWorking || facts.executorActive || facts.reconciling;
47
+ const healthy = state.requestedEnabled &&
48
+ facts.planMatches &&
49
+ !['attentionRequired', 'safetyPaused'].includes(supervision) &&
50
+ !['attentionRequired', 'safetyPaused', 'completed', 'disabled'].includes(state.state) &&
51
+ liveContinuation;
52
+ const phase = state.state === 'disabled'
53
+ ? 'off'
54
+ : state.state === 'completed'
55
+ ? 'complete'
56
+ : state.state === 'attentionRequired'
57
+ ? 'needsYou'
58
+ : state.state === 'safetyPaused' || supervision === 'safetyPaused'
59
+ ? 'safetyPaused'
60
+ : healthy && (parkedWait || facts.executorActive)
61
+ ? 'waitingForAgentEvent'
62
+ : healthy && scheduled
63
+ ? 'continuationScheduled'
64
+ : healthy && (supervision === 'probeRequired' || facts.reconciling)
65
+ ? 'checkingState'
66
+ : healthy && rootWorking
67
+ ? 'rootWorking'
68
+ : 'degraded';
69
+ const nextExpectedAction = phase === 'rootWorking'
70
+ ? 'Wait for the root turn to settle.'
71
+ : phase === 'continuationScheduled'
72
+ ? 'Run the scheduled continuation.'
73
+ : phase === 'waitingForAgentEvent'
74
+ ? parkedWait
75
+ ? 'Wait for a subscribed agent or process event.'
76
+ : 'Wait for the active executor or owned process to settle.'
77
+ : phase === 'checkingState'
78
+ ? 'Reconcile supervised execution state.'
79
+ : phase === 'needsYou'
80
+ ? 'Respond to the pending attention request.'
81
+ : phase === 'complete'
82
+ ? 'No further plan action is required.'
83
+ : phase === 'off'
84
+ ? 'Enable Autopilot to supervise an incomplete plan.'
85
+ : phase === 'safetyPaused'
86
+ ? 'Resume manually after reviewing the safety pause.'
87
+ : 'Restore a valid continuation before relying on Autopilot.';
22
88
  return {
23
89
  state: state.state,
24
90
  enabled: state.requestedEnabled,
91
+ health: {
92
+ healthy,
93
+ phase,
94
+ supervision,
95
+ wait: {
96
+ present: Boolean(state.supervision?.waitLease),
97
+ wakeCategories: [...waitCategories].slice(0, 9),
98
+ },
99
+ observedAt: facts.observedAt,
100
+ lastTransitionAt: state.updatedAt,
101
+ nextExpectedAction,
102
+ ...(!healthy && state.requestedEnabled
103
+ ? {
104
+ degradationReason: !facts.planMatches
105
+ ? 'planMismatch'
106
+ : !facts.transitionFresh ||
107
+ (Number.isFinite(evaluationAt) && evaluationAt <= Date.parse(facts.observedAt))
108
+ ? 'staleTransition'
109
+ : state.supervision?.waitLease && !parkedWait
110
+ ? 'invalidWaitLease'
111
+ : 'missingContinuation',
112
+ }
113
+ : {}),
114
+ },
25
115
  ...(state.stopReason ? { reason: state.stopReason } : {}),
26
116
  retry: { position: state.consecutiveNoProgress, limit: retryLimit },
27
117
  ...(state.lastControlId
@@ -40,3 +130,12 @@ export function autopilotSnapshot(state, retryLimit) {
40
130
  updatedAt: state.updatedAt,
41
131
  };
42
132
  }
133
+ function supportedWakeCategories(conditions) {
134
+ if (conditions.length > observableWakeConditions.length)
135
+ return [];
136
+ const categories = conditions.filter((condition) => typeof condition === 'string' &&
137
+ observableWakeConditions.includes(condition));
138
+ return categories.length === conditions.length && new Set(categories).size === categories.length
139
+ ? categories
140
+ : [];
141
+ }
@@ -4,16 +4,30 @@
4
4
  * SPDX-License-Identifier: AGPL-3.0-or-later
5
5
  */
6
6
  import { buildResumeCommand } from '../application/resume-command.js';
7
- export function registerGetSession(app, find, activity, autopilot) {
7
+ import { toAgentActivityDto } from '../../agent-activity/activity-dto.js';
8
+ import { deriveSessionStatus } from '../session-status.js';
9
+ export function registerGetSession(app, find, activity, autopilot, plan) {
8
10
  app.get('/api/sessions/:id', async (request, reply) => {
9
11
  const session = find(request.params.id);
10
- return session
11
- ? reply.send({
12
- ...session,
13
- ...(activity ? { agentActivity: activity(session.id) } : {}),
14
- ...(autopilot ? { autopilot: autopilot(session.id) } : {}),
15
- resumeCommand: session.threadId ? buildResumeCommand(session) : null,
16
- })
17
- : reply.code(404).send({ code: 'SESSION_NOT_FOUND' });
12
+ if (!session)
13
+ return reply.code(404).send({ code: 'SESSION_NOT_FOUND' });
14
+ const activitySnapshot = activity?.(session.id) ?? null;
15
+ const autopilotSnapshot = autopilot?.(session.id) ?? null;
16
+ const retainedPlan = plan?.(session.id) ?? null;
17
+ return reply.send({
18
+ ...session,
19
+ ...(activitySnapshot ? { agentActivity: toAgentActivityDto(activitySnapshot) } : {}),
20
+ ...(autopilotSnapshot ? { autopilot: autopilotSnapshot } : {}),
21
+ ...(retainedPlan ? { plan: retainedPlan } : {}),
22
+ sessionStatus: deriveSessionStatus({
23
+ session,
24
+ plan: retainedPlan,
25
+ activity: activitySnapshot,
26
+ autopilot: autopilotSnapshot,
27
+ pendingAttention: Boolean(session.pendingInteractions?.length),
28
+ observedAt: new Date().toISOString(),
29
+ }),
30
+ resumeCommand: session.threadId ? buildResumeCommand(session) : null,
31
+ });
18
32
  });
19
33
  }
@@ -4,14 +4,26 @@
4
4
  * SPDX-License-Identifier: AGPL-3.0-or-later
5
5
  */
6
6
  import { buildResumeCommand } from '../application/resume-command.js';
7
+ import { toAgentActivityDto } from '../../agent-activity/activity-dto.js';
8
+ import { deriveSessionStatus } from '../session-status.js';
7
9
  export function registerListSessions(app, deps) {
8
10
  app.get('/api/sessions', async () => deps.list().map((session) => {
9
11
  const plan = deps.plan?.(session.id) ?? null;
12
+ const activity = deps.activity?.(session.id) ?? null;
13
+ const autopilot = deps.autopilot?.(session.id) ?? null;
10
14
  return {
11
15
  ...session,
12
- ...(deps.activity ? { agentActivity: deps.activity(session.id) } : {}),
13
- ...(deps.autopilot ? { autopilot: deps.autopilot(session.id) } : {}),
16
+ ...(activity ? { agentActivity: toAgentActivityDto(activity) } : {}),
17
+ ...(autopilot ? { autopilot } : {}),
14
18
  ...(plan ? { plan } : {}),
19
+ sessionStatus: deriveSessionStatus({
20
+ session,
21
+ plan,
22
+ activity,
23
+ autopilot,
24
+ pendingAttention: Boolean(session.pendingInteractions?.length),
25
+ observedAt: new Date().toISOString(),
26
+ }),
15
27
  resumeCommand: session.threadId ? buildResumeCommand(session) : null,
16
28
  };
17
29
  }));
@@ -48,7 +48,7 @@ export function registerSessionRoutes(app, deps) {
48
48
  ...sessions,
49
49
  reportFailure: (operation, error) => deps.logger.error(`${operation} failed: ${safeErrorLabel(error)}`),
50
50
  });
51
- registerGetSession(app, sessions.find, sessions.agentActivity, sessions.autopilotSnapshot);
51
+ registerGetSession(app, sessions.find, sessions.agentActivity, sessions.autopilotSnapshot, sessions.plan);
52
52
  if (sessions.refreshActivity)
53
53
  registerRefreshActivity(app, {
54
54
  exists: (id) => sessions.find(id) !== null,
@@ -0,0 +1,55 @@
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
+ /**
7
+ * A conservative, server-owned verdict. It deliberately uses only durable
8
+ * state and bounded activity facts: neither model prose nor opaque identity is
9
+ * accepted as evidence of progress.
10
+ */
11
+ export function deriveSessionStatus(input) {
12
+ const confidence = input.activity?.confidence ?? 'stale';
13
+ const base = (state, reason, nextExpectedAction) => ({
14
+ state,
15
+ reason,
16
+ confidence,
17
+ observedAt: input.observedAt,
18
+ nextExpectedAction,
19
+ });
20
+ if (input.pendingAttention || input.autopilot?.state === 'attentionRequired')
21
+ return base('idle', 'needsYou', 'Respond to the pending request.');
22
+ const activeProcess = input.activity?.subagents.some((agent) => agent.ownedProcesses?.some((process) => process.state === 'running' || process.state === 'detached-active'));
23
+ const rootWorking = Boolean(input.session.activeTurnId) ||
24
+ (input.activity?.confidence === 'fresh' && input.activity.root.state === 'working');
25
+ const observedChildWorking = input.activity?.subagents.some((agent) => agent.state === 'working' || agent.state === 'awaitingAgent');
26
+ // A completed plan is authoritative. Old child/process rows are historical
27
+ // unless their activity observation is fresh; an active root turn remains
28
+ // direct session evidence regardless of roster freshness.
29
+ const childWorking = input.activity?.confidence === 'fresh' ? observedChildWorking : false;
30
+ const currentProcess = input.activity?.confidence === 'fresh' ? activeProcess : false;
31
+ if (input.plan?.executionComplete || input.plan?.allDone) {
32
+ if (rootWorking)
33
+ return base('working', 'rootTurn', 'Wait for the active turn to settle.');
34
+ if (childWorking)
35
+ return base('working', 'agent', 'Wait for the active agent to settle.');
36
+ if (currentProcess)
37
+ return base('working', 'process', 'Wait for the owned process result.');
38
+ return base('idle', 'complete', 'No further plan action is required.');
39
+ }
40
+ if (rootWorking)
41
+ return base('working', 'rootTurn', 'Wait for the supervisor turn to settle.');
42
+ if (childWorking)
43
+ return base('working', 'agent', 'Wait for the active agent to settle.');
44
+ if (currentProcess)
45
+ return base('working', 'process', 'Wait for the owned process result.');
46
+ if (input.plan && input.autopilot?.health?.healthy)
47
+ return base('working', 'autopilot', input.autopilot.health.nextExpectedAction);
48
+ if (input.plan)
49
+ return base('idle', 'incompleteWithoutContinuation', 'Resume supervision or enable a healthy Autopilot continuation.');
50
+ if (input.activity?.root.state === 'disconnected')
51
+ return base('idle', 'disconnected', 'Restore or reopen the session.');
52
+ if (input.session.state === 'stopped' || input.session.state === 'released')
53
+ return base('idle', 'stopped', 'Open the session to continue.');
54
+ return base('idle', 'unknown', 'Refresh session status.');
55
+ }
@@ -294,9 +294,10 @@ export class CodexSessionRuntime {
294
294
  if (!owned)
295
295
  return [];
296
296
  const children = [];
297
+ const childIds = new Set();
297
298
  const cursors = new Set();
298
299
  let cursor;
299
- for (let page = 0; page < 4 && children.length < 64; page += 1) {
300
+ for (let page = 0; page < 4 && childIds.size < 64; page += 1) {
300
301
  const result = await owned.process.rpc.request('thread/list', {
301
302
  parentThreadId: session.threadId,
302
303
  ...(cursor ? { cursor } : {}),
@@ -320,6 +321,7 @@ export class CodexSessionRuntime {
320
321
  rawStatus === 'systemError'
321
322
  ? rawStatus
322
323
  : undefined;
324
+ childIds.add(value.id);
323
325
  return [
324
326
  {
325
327
  id: value.id,
@@ -339,19 +341,21 @@ export class CodexSessionRuntime {
339
341
  },
340
342
  ];
341
343
  }));
344
+ if (childIds.size > 64)
345
+ throw new Error('CODEX_CHILD_LIST_UNSUPPORTED');
342
346
  const next = typeof response?.nextCursor === 'string' && response.nextCursor.length <= 256
343
347
  ? response.nextCursor
344
348
  : undefined;
345
349
  if (!next)
346
- return this.withResolvedChildModels(owned, children);
347
- if (cursors.has(next) || children.length >= 64)
350
+ return this.withResolvedChildModels(owned, uniqueDirectChildren(children));
351
+ if (cursors.has(next) || childIds.size >= 64)
348
352
  throw new Error('CODEX_CHILD_LIST_UNSUPPORTED');
349
353
  cursors.add(next);
350
354
  cursor = next;
351
355
  }
352
356
  if (cursor)
353
357
  throw new Error('CODEX_CHILD_LIST_UNSUPPORTED');
354
- return this.withResolvedChildModels(owned, children);
358
+ return this.withResolvedChildModels(owned, uniqueDirectChildren(children));
355
359
  }
356
360
  async withResolvedChildModels(resource, children) {
357
361
  for (const child of children) {
@@ -784,6 +788,13 @@ export class CodexSessionRuntime {
784
788
  return origin;
785
789
  }
786
790
  }
791
+ /** `thread/list` pages can replay an id while a child transitions. Keep its newest row. */
792
+ function uniqueDirectChildren(children) {
793
+ const result = new Map();
794
+ for (const child of children)
795
+ result.set(child.id, child);
796
+ return [...result.values()].slice(0, 64);
797
+ }
787
798
  function isMethodNotFound(error) {
788
799
  return Boolean(error && typeof error === 'object' && error.code === -32601);
789
800
  }
@@ -0,0 +1,15 @@
1
+ {
2
+ "schemaVersion": 1,
3
+ "component": "mobile",
4
+ "supervisionContract": 1,
5
+ "capabilities": [
6
+ "supervision-start",
7
+ "wait-lease-tool",
8
+ "checkpoint-tool",
9
+ "controller-status",
10
+ "canonical-agent-identity",
11
+ "session-verdict",
12
+ "acknowledgement-safe-composer",
13
+ "org-plan-contract"
14
+ ]
15
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gestalt-mobile",
3
- "version": "0.30.3",
3
+ "version": "0.31.0",
4
4
  "description": "Mobile-first web relay for durable Codex development sessions",
5
5
  "keywords": [
6
6
  "codex",
@@ -26,7 +26,8 @@
26
26
  "dist/server",
27
27
  "dist/client",
28
28
  "README.md",
29
- "LICENSE"
29
+ "LICENSE",
30
+ "gestalt-supervision-capabilities.json"
30
31
  ],
31
32
  "engines": {
32
33
  "node": ">=24.0.0"