gestalt-mobile 0.29.0 → 0.30.1

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.
Files changed (30) hide show
  1. package/README.md +3 -4
  2. package/dist/client/assets/index-CKhqx71S.css +1 -0
  3. package/dist/client/assets/index-CedbthUQ.js +34 -0
  4. package/dist/client/index.html +2 -2
  5. package/dist/server/server/cli.js +27 -2
  6. package/dist/server/server/composition.contracts.js +1 -1
  7. package/dist/server/server/composition.js +32 -4
  8. package/dist/server/server/features/autopilot/application/policy.js +17 -2
  9. package/dist/server/server/features/autopilot/application/service.js +239 -12
  10. package/dist/server/server/features/autopilot/domain/supervised-lifecycle.js +11 -1
  11. package/dist/server/server/features/autopilot/domain/supervision-protocol.js +203 -0
  12. package/dist/server/server/features/sessions/application/resume-command.js +1 -3
  13. package/dist/server/server/features/sessions/interaction/kind.js +1 -0
  14. package/dist/server/server/features/sessions/interaction/response-validator.js +4 -1
  15. package/dist/server/server/features/sessions/list-recent-threads/endpoint.js +2 -2
  16. package/dist/server/server/features/sessions/model/relay-session.js +2 -0
  17. package/dist/server/server/features/sessions/start-session/use-case.js +11 -6
  18. package/dist/server/server/features/skills/model/skill-profile.js +25 -8
  19. package/dist/server/server/platform/catalog/launcher-profile-catalog.js +21 -0
  20. package/dist/server/server/platform/catalog/profile-command.js +2 -13
  21. package/dist/server/server/platform/codex/codex-process-launcher.js +1 -1
  22. package/dist/server/server/platform/codex/server-request.js +13 -0
  23. package/dist/server/server/platform/codex/session-runtime.js +3 -0
  24. package/dist/server/server/platform/persistence/sqlite-autopilot-store.js +11 -2
  25. package/dist/server/server/platform/plans/filesystem-plan-status-source.js +35 -0
  26. package/dist/server/shared/contracts/autopilot-wait-lease.js +62 -0
  27. package/package.json +2 -2
  28. package/dist/client/assets/index-BtlbAtm9.css +0 -1
  29. package/dist/client/assets/index-CRULnRRB.js +0 -33
  30. package/dist/server/server/platform/catalog/codex-profile-catalog.js +0 -42
@@ -0,0 +1,203 @@
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
+ /** Facts which may wake a parked supervisor without inspecting conversation text. */
7
+ export const observableWakeConditions = [
8
+ 'planChanged',
9
+ 'reviewChanged',
10
+ 'checkpointChanged',
11
+ 'interactionChanged',
12
+ 'executorChanged',
13
+ 'processExited',
14
+ 'processResultAvailable',
15
+ 'processLimitBreached',
16
+ 'agentActivityChanged',
17
+ ];
18
+ /**
19
+ * A canonical, durable representation rather than a clock- or transcript-derived score.
20
+ * The string is intentionally opaque to callers; compare it only for equality.
21
+ */
22
+ export function semanticProgressKey(facts) {
23
+ return JSON.stringify({
24
+ plan: facts.plan,
25
+ review: facts.review,
26
+ checkpoint: facts.checkpoint,
27
+ pendingInteractions: [...facts.pendingInteractions]
28
+ .map(({ id, kind, state }) => ({ id, kind, state }))
29
+ .sort(compareBy('id')),
30
+ executor: facts.executor,
31
+ ownedProcesses: [...facts.ownedProcesses]
32
+ .map(({ id, state, ownerGeneration }) => ({ id, state, ownerGeneration }))
33
+ .sort(compareBy('id')),
34
+ agentActivity: latestAgentActivity(facts.agentActivity),
35
+ });
36
+ }
37
+ function latestAgentActivity(observations) {
38
+ const latest = new Map();
39
+ for (const observation of observations) {
40
+ const current = latest.get(observation.agentId);
41
+ if (!current ||
42
+ observation.sequence > current.sequence ||
43
+ (observation.sequence === current.sequence &&
44
+ observation.state.localeCompare(current.state) < 0))
45
+ latest.set(observation.agentId, observation);
46
+ }
47
+ return [...latest.values()]
48
+ .map(({ agentId, sequence, state }) => ({ agentId, sequence, state }))
49
+ .sort(compareBy('agentId'));
50
+ }
51
+ function compareBy(key) {
52
+ return (left, right) => left[key].localeCompare(right[key]);
53
+ }
54
+ export function startSupervisionProtocol(progressKey) {
55
+ return {
56
+ outcome: 'active',
57
+ progressKey,
58
+ unchangedContinuations: 0,
59
+ probeKey: null,
60
+ lastReportId: null,
61
+ waitLease: null,
62
+ retryKey: null,
63
+ safetyPauseReason: null,
64
+ };
65
+ }
66
+ /** Records an automatic continuation result; prose and timestamps are not inputs. */
67
+ export function recordAutomaticContinuation(state, progressKey) {
68
+ if (state.outcome === 'parked' ||
69
+ state.outcome === 'attentionRequired' ||
70
+ state.outcome === 'safetyPaused')
71
+ return state;
72
+ // The probe itself is the one continuation allowed while this key is
73
+ // unchanged. Reaching the scheduling boundary again without a structured
74
+ // report or semantic progress means that probe failed closed.
75
+ if (state.outcome === 'probeRequired' && progressKey === state.probeKey)
76
+ return { ...state, outcome: 'safetyPaused', safetyPauseReason: 'invalidProbeReport' };
77
+ if (state.outcome === 'retrying' && progressKey === state.retryKey)
78
+ return state.unchangedContinuations === 0
79
+ ? { ...state, unchangedContinuations: 1 }
80
+ : { ...state, outcome: 'safetyPaused', safetyPauseReason: 'retryRecurrence' };
81
+ if (progressKey !== state.progressKey)
82
+ return { ...startSupervisionProtocol(progressKey), lastReportId: state.lastReportId };
83
+ const unchangedContinuations = Math.min(3, state.unchangedContinuations + 1);
84
+ if (unchangedContinuations === 3 && state.probeKey !== progressKey)
85
+ return { ...state, unchangedContinuations, outcome: 'probeRequired', probeKey: progressKey };
86
+ return { ...state, unchangedContinuations };
87
+ }
88
+ /** Accepts only structured, bounded probe reports. Unsupported waits fail closed. */
89
+ export function reportProbe(state, report) {
90
+ if (state.lastReportId === report.id)
91
+ return state;
92
+ if (state.outcome !== 'probeRequired' || !state.probeKey)
93
+ return state;
94
+ if (report.kind === 'attention')
95
+ return { ...state, outcome: 'attentionRequired', lastReportId: report.id };
96
+ if (report.kind === 'actionable')
97
+ return {
98
+ ...startSupervisionProtocol(state.progressKey),
99
+ lastReportId: report.id,
100
+ };
101
+ const wakeConditions = uniqueSupportedWakeConditions(report.wakeConditions);
102
+ if (!report.leaseId || wakeConditions.length === 0)
103
+ return state;
104
+ return {
105
+ ...state,
106
+ outcome: 'parked',
107
+ lastReportId: report.id,
108
+ waitLease: { id: report.leaseId, probeKey: state.probeKey, wakeConditions },
109
+ };
110
+ }
111
+ /** A wake is useful only when it matches the durable lease and changed semantic state. */
112
+ export function consumeObservableWake(state, wake) {
113
+ const lease = state.waitLease;
114
+ if (state.outcome !== 'parked' ||
115
+ !lease ||
116
+ wake.leaseId !== lease.id ||
117
+ !lease.wakeConditions.includes(wake.condition) ||
118
+ wake.progressKey === lease.probeKey)
119
+ return state;
120
+ return {
121
+ ...state,
122
+ outcome: 'retrying',
123
+ progressKey: wake.progressKey,
124
+ unchangedContinuations: 0,
125
+ probeKey: null,
126
+ waitLease: null,
127
+ retryKey: wake.progressKey,
128
+ };
129
+ }
130
+ /** Manual recovery is the only transition out of durable safety pause. */
131
+ export function recoverSafetyPause(state, progressKey) {
132
+ return state.outcome === 'safetyPaused' ? startSupervisionProtocol(progressKey) : state;
133
+ }
134
+ /** Reject malformed persisted values instead of silently resuming automatic work. */
135
+ export function parseSupervisionProtocolState(value) {
136
+ if (!value || typeof value !== 'object' || Array.isArray(value))
137
+ return undefined;
138
+ const candidate = value;
139
+ const base = startSupervisionProtocol(typeof candidate.progressKey === 'string' ? candidate.progressKey : '');
140
+ if (!base.progressKey || !isProtocolOutcome(candidate.outcome))
141
+ return undefined;
142
+ const unchangedContinuations = candidate.unchangedContinuations;
143
+ if (unchangedContinuations !== 0 &&
144
+ unchangedContinuations !== 1 &&
145
+ unchangedContinuations !== 2 &&
146
+ unchangedContinuations !== 3)
147
+ return undefined;
148
+ const probeKey = nullableText(candidate.probeKey);
149
+ const lastReportId = nullableText(candidate.lastReportId);
150
+ const retryKey = nullableText(candidate.retryKey);
151
+ if (probeKey === undefined || lastReportId === undefined || retryKey === undefined)
152
+ return undefined;
153
+ const waitLease = parseWaitLease(candidate.waitLease);
154
+ if (waitLease === undefined)
155
+ return undefined;
156
+ if (candidate.safetyPauseReason !== null &&
157
+ candidate.safetyPauseReason !== 'retryRecurrence' &&
158
+ candidate.safetyPauseReason !== 'invalidProbeReport')
159
+ return undefined;
160
+ return {
161
+ ...base,
162
+ outcome: candidate.outcome,
163
+ unchangedContinuations,
164
+ probeKey,
165
+ lastReportId,
166
+ waitLease,
167
+ retryKey,
168
+ safetyPauseReason: candidate.safetyPauseReason,
169
+ };
170
+ }
171
+ function uniqueSupportedWakeConditions(conditions) {
172
+ return [...new Set(conditions)].filter((condition) => observableWakeConditions.includes(condition));
173
+ }
174
+ function isProtocolOutcome(value) {
175
+ return [
176
+ 'active',
177
+ 'probeRequired',
178
+ 'parked',
179
+ 'retrying',
180
+ 'attentionRequired',
181
+ 'safetyPaused',
182
+ ].includes(value);
183
+ }
184
+ function nullableText(value) {
185
+ return value === null ? null : typeof value === 'string' && value.length > 0 ? value : undefined;
186
+ }
187
+ function parseWaitLease(value) {
188
+ if (value === null)
189
+ return null;
190
+ if (!value || typeof value !== 'object' || Array.isArray(value))
191
+ return undefined;
192
+ const candidate = value;
193
+ if (typeof candidate.id !== 'string' ||
194
+ !candidate.id ||
195
+ typeof candidate.probeKey !== 'string' ||
196
+ !candidate.probeKey ||
197
+ !Array.isArray(candidate.wakeConditions))
198
+ return undefined;
199
+ const wakeConditions = uniqueSupportedWakeConditions(candidate.wakeConditions);
200
+ return wakeConditions.length === candidate.wakeConditions.length
201
+ ? { id: candidate.id, probeKey: candidate.probeKey, wakeConditions }
202
+ : undefined;
203
+ }
@@ -8,9 +8,7 @@ export function buildResumeCommand(session) {
8
8
  if (!session.threadId)
9
9
  throw new Error('THREAD_REQUIRED');
10
10
  return [
11
- 'codex-profile',
12
- 'cli',
13
- session.profile,
11
+ 'codex',
14
12
  'resume',
15
13
  session.threadId,
16
14
  '-C',
@@ -12,5 +12,6 @@ export function isInteractionKind(value) {
12
12
  'quiz',
13
13
  'orgPlanAttention',
14
14
  'orgPlanCheckpoint',
15
+ 'autopilotWaitLease',
15
16
  ].includes(value);
16
17
  }
@@ -11,7 +11,10 @@ export function isValidInteractionResponse(kind, value) {
11
11
  return false;
12
12
  if (kind === 'userInput')
13
13
  return isValidUserInputResponse(value);
14
- if (kind === 'quiz' || kind === 'orgPlanAttention' || kind === 'orgPlanCheckpoint')
14
+ if (kind === 'quiz' ||
15
+ kind === 'orgPlanAttention' ||
16
+ kind === 'orgPlanCheckpoint' ||
17
+ kind === 'autopilotWaitLease')
15
18
  return false;
16
19
  if (kind === 'permissionsApproval')
17
20
  return isRecord(value.permissions) && (value.scope === 'turn' || value.scope === 'session');
@@ -7,7 +7,7 @@ import { buildResumeCommand } from '../application/resume-command.js';
7
7
  export function registerListRecentThreads(app, deps) {
8
8
  app.get('/api/sessions/recent-threads', async () => {
9
9
  const threads = await deps.list();
10
- return threads.map(({ id, cwd, profile, recencyAt }) => {
10
+ return threads.map(({ id, cwd, recencyAt }) => {
11
11
  const metadata = deps.metadata?.(id);
12
12
  return {
13
13
  id,
@@ -18,7 +18,7 @@ export function registerListRecentThreads(app, deps) {
18
18
  ...(metadata?.orgPlanFilename === undefined
19
19
  ? {}
20
20
  : { orgPlanFilename: metadata.orgPlanFilename }),
21
- resumeCommand: buildResumeCommand({ profile, threadId: id, workspacePath: cwd }),
21
+ resumeCommand: buildResumeCommand({ threadId: id, workspacePath: cwd }),
22
22
  };
23
23
  });
24
24
  });
@@ -30,11 +30,13 @@ export function createSessionExecutionPolicy(input) {
30
30
  };
31
31
  }
32
32
  export function createEffectiveSkillSelection(input) {
33
+ const warnings = (input.warnings ?? []).filter((warning) => typeof warning === 'string' && warning.length > 0);
33
34
  return {
34
35
  ...(input.selectedProfileName === undefined
35
36
  ? {}
36
37
  : { selectedProfileName: normalizeSkillProfileName(input.selectedProfileName) }),
37
38
  skills: createSkillSelection(input.skills),
39
+ ...(warnings.length > 0 ? { warnings: warnings.slice(0, 50) } : {}),
38
40
  };
39
41
  }
40
42
  export class RelaySession {
@@ -5,7 +5,7 @@
5
5
  */
6
6
  import { RelaySession } from '../model/relay-session.js';
7
7
  import { DEFAULT_SESSION_MODEL } from '../application/start-settings.js';
8
- import { applySkillSelectionSnapshot, } from '../../skills/model/skill-profile.js';
8
+ import { reconcileSkillSelectionSnapshot, } from '../../skills/model/skill-profile.js';
9
9
  import { SkillProfileError } from '../../skills/model/errors.js';
10
10
  export async function startSession(input, deps) {
11
11
  const model = input.model ?? DEFAULT_SESSION_MODEL;
@@ -28,13 +28,18 @@ export async function startSession(input, deps) {
28
28
  deps.skillCatalog(input.profile).list(workspace.realPath),
29
29
  ]);
30
30
  const sourceProfile = selectedProfile ?? projectProfile;
31
+ const reconciledSkills = reconcileSkillSelectionSnapshot(catalog.skills, sourceProfile?.skills);
31
32
  const effectiveSkillSelection = {
32
33
  ...(selectedProfile ? { selectedProfileName: selectedProfile.name } : {}),
33
- skills: applySkillSelectionSnapshot(catalog.skills, sourceProfile?.skills).map((skill) => ({
34
- name: skill.name,
35
- path: skill.path,
36
- enabled: skill.enabled,
37
- })),
34
+ skills: [
35
+ ...reconciledSkills.skills.map((skill) => ({
36
+ name: skill.name,
37
+ path: skill.path,
38
+ enabled: skill.enabled,
39
+ })),
40
+ ...reconciledSkills.missing,
41
+ ],
42
+ ...(reconciledSkills.warnings.length > 0 ? { warnings: reconciledSkills.warnings } : {}),
38
43
  };
39
44
  const branch = await deps.gitBranch?.(workspace.realPath);
40
45
  const session = RelaySession.create({
@@ -167,6 +167,28 @@ export function selectEffectiveSkillSelection(input) {
167
167
  return { source: 'project', selection: createSkillSelection(input.project) };
168
168
  return { source: 'native', selection: undefined };
169
169
  }
170
+ /**
171
+ * Reconcile a saved snapshot against fresh discovery. Missing entries remain
172
+ * visible as disabled profile state, but are never passed to Codex.
173
+ */
174
+ export function reconcileSkillSelectionSnapshot(discovered, selection) {
175
+ if (selection === undefined)
176
+ return {
177
+ skills: applySkillSelectionSnapshot(discovered),
178
+ missing: [],
179
+ warnings: [],
180
+ };
181
+ const reboundSelection = rebindVersionedPluginSkills(discovered, selection);
182
+ const discoveredPaths = new Set(discovered.map((skill) => canonicalSkillPath(skill.path)));
183
+ const missing = createSkillSelection(reboundSelection
184
+ .filter((entry) => !discoveredPaths.has(entry.path))
185
+ .map((entry) => ({ ...entry, enabled: false })));
186
+ return {
187
+ skills: applySkillSelectionSnapshot(discovered, reboundSelection),
188
+ missing,
189
+ warnings: missing.map((entry) => `Skill "${entry.name}" is missing and was disabled: ${entry.path}`),
190
+ };
191
+ }
170
192
  /**
171
193
  * Compile a complete, process-local `skills.config` map from fresh discovery.
172
194
  * It neither writes configuration nor mutates the user Codex installation.
@@ -175,18 +197,13 @@ export function compileSkillOverride(input) {
175
197
  const effective = selectEffectiveSkillSelection(input);
176
198
  if (effective.selection === undefined)
177
199
  return { source: 'native', skillsConfig: undefined, warnings: [] };
178
- const reboundSelection = rebindVersionedPluginSkills(input.discovered, effective.selection);
179
- const discoveredPaths = new Set(input.discovered.map((skill) => canonicalSkillPath(skill.path)));
180
- const warnings = reboundSelection
181
- .filter((entry) => !discoveredPaths.has(entry.path))
182
- .map((entry) => `Saved skill path is no longer discovered: ${entry.path}`);
183
- const configured = applySkillSelectionSnapshot(input.discovered, reboundSelection);
200
+ const reconciled = reconcileSkillSelectionSnapshot(input.discovered, effective.selection);
184
201
  return {
185
202
  source: effective.source,
186
- skillsConfig: configured
203
+ skillsConfig: reconciled.skills
187
204
  .map((skill) => ({ path: skill.path, enabled: skill.enabled }))
188
205
  .sort((left, right) => left.path.localeCompare(right.path)),
189
- warnings,
206
+ warnings: reconciled.warnings,
190
207
  };
191
208
  }
192
209
  /** Construct the single profile contract shared by global and project YAML files. */
@@ -0,0 +1,21 @@
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 launcherProfile = {
7
+ name: 'default',
8
+ state: 'ok',
9
+ status: 'Using the Gestalt launcher environment',
10
+ };
11
+ export class LauncherProfileCatalog {
12
+ async list() {
13
+ return [launcherProfile];
14
+ }
15
+ async require(name) {
16
+ const profile = (await this.list()).find((item) => item.name === name);
17
+ if (!profile)
18
+ throw new Error('PROFILE_NOT_FOUND');
19
+ return profile;
20
+ }
21
+ }
@@ -3,19 +3,8 @@
3
3
  * Designed by Denis Roio <jaromil@dyne.org>
4
4
  * SPDX-License-Identifier: AGPL-3.0-or-later
5
5
  */
6
- import { spawnSync } from 'node:child_process';
7
- import { existsSync } from 'node:fs';
8
- import { homedir } from 'node:os';
9
- import { join } from 'node:path';
10
- const localAvailability = {
11
- codexProfileAvailable: () => !spawnSync('codex-profile', ['--version'], { stdio: 'ignore' }).error,
12
- gestaltHomeExists: () => existsSync(join(homedir(), '.codex-gestalt')),
13
- };
14
- export function profileAppServerCommand(_profile, availability = localAvailability, skillsConfig) {
15
- void _profile;
16
- const base = availability.codexProfileAvailable() && availability.gestaltHomeExists()
17
- ? { command: 'codex-profile', args: ['cli', 'gestalt', 'app-server', '--stdio'] }
18
- : { command: 'codex', args: ['app-server', '--stdio'] };
6
+ export function profileAppServerCommand(skillsConfig) {
7
+ const base = { command: 'codex', args: ['app-server', '--stdio'] };
19
8
  return skillsConfig === undefined
20
9
  ? base
21
10
  : {
@@ -7,7 +7,7 @@ import { spawn } from 'node:child_process';
7
7
  import { JsonRpcClient } from './json-rpc-client.js';
8
8
  import { profileAppServerCommand } from '../catalog/profile-command.js';
9
9
  export function launchCodexAppServer(input) {
10
- const launch = profileAppServerCommand(input.profile, undefined, input.skillsConfig);
10
+ const launch = profileAppServerCommand(input.skillsConfig);
11
11
  const child = spawn(launch.command, launch.args, {
12
12
  cwd: input.cwd,
13
13
  env: codexChildEnvironment(input.environment),
@@ -6,6 +6,7 @@
6
6
  import { GESTALT_QUIZ_TOOL_NAME, parseQuiz } from '../../../shared/contracts/quiz.js';
7
7
  import { GESTALT_ORG_PLAN_ATTENTION_TOOL_NAME, parseOrgPlanAttention, } from '../../../shared/contracts/org-plan-attention.js';
8
8
  import { GESTALT_ORG_PLAN_CHECKPOINT_TOOL_NAME, parseOrgPlanCheckpoint, } from '../../../shared/contracts/org-plan-checkpoint.js';
9
+ import { GESTALT_AUTOPILOT_WAIT_LEASE_TOOL_NAME, parseAutopilotWaitLease, } from '../../../shared/contracts/autopilot-wait-lease.js';
9
10
  export function toPendingInteraction(input) {
10
11
  if (!Number.isSafeInteger(input.id) || !isRecord(input.params))
11
12
  return null;
@@ -26,6 +27,12 @@ export function toPendingInteraction(input) {
26
27
  ? { requestId: String(input.id), kind: 'orgPlanCheckpoint', payload: checkpoint }
27
28
  : null;
28
29
  }
30
+ if (input.params.tool === GESTALT_AUTOPILOT_WAIT_LEASE_TOOL_NAME) {
31
+ const lease = parseAutopilotWaitLease(input.params.arguments);
32
+ return lease
33
+ ? { requestId: String(input.id), kind: 'autopilotWaitLease', payload: lease }
34
+ : null;
35
+ }
29
36
  return null;
30
37
  }
31
38
  const kind = {
@@ -45,6 +52,12 @@ export function resolvedServerRequestId(input) {
45
52
  return null;
46
53
  return String(requestId);
47
54
  }
55
+ export function isAutopilotWaitLeaseCall(input) {
56
+ return (Number.isSafeInteger(input.id) &&
57
+ input.method === 'item/tool/call' &&
58
+ isRecord(input.params) &&
59
+ input.params.tool === GESTALT_AUTOPILOT_WAIT_LEASE_TOOL_NAME);
60
+ }
48
61
  function isRecord(value) {
49
62
  return typeof value === 'object' && value !== null && !Array.isArray(value);
50
63
  }
@@ -10,6 +10,7 @@ import { canRebindMissingRollout, rebindMissingRollout, } from '../../features/s
10
10
  import { gestaltQuizDynamicTool } from '../../../shared/contracts/quiz.js';
11
11
  import { gestaltOrgPlanAttentionDynamicTool } from '../../../shared/contracts/org-plan-attention.js';
12
12
  import { gestaltOrgPlanCheckpointDynamicTool } from '../../../shared/contracts/org-plan-checkpoint.js';
13
+ import { gestaltAutopilotWaitLeaseDynamicTool } from '../../../shared/contracts/autopilot-wait-lease.js';
13
14
  import { countDiffLines } from '../../../shared/contracts/file-change.js';
14
15
  import { threadPlanName } from './thread-plan-name.js';
15
16
  import { WriterAcquisitionError, } from '../../features/sessions/application/writer-acquisition.js';
@@ -568,6 +569,7 @@ export class CodexSessionRuntime {
568
569
  gestaltQuizDynamicTool,
569
570
  gestaltOrgPlanAttentionDynamicTool,
570
571
  gestaltOrgPlanCheckpointDynamicTool,
572
+ gestaltAutopilotWaitLeaseDynamicTool,
571
573
  ],
572
574
  });
573
575
  result = {
@@ -658,6 +660,7 @@ export class CodexSessionRuntime {
658
660
  gestaltQuizDynamicTool,
659
661
  gestaltOrgPlanAttentionDynamicTool,
660
662
  gestaltOrgPlanCheckpointDynamicTool,
663
+ gestaltAutopilotWaitLeaseDynamicTool,
661
664
  ],
662
665
  ...(session.model ? { model: session.model } : {}),
663
666
  ...(session.executionPolicy?.sandbox ? { sandbox: session.executionPolicy.sandbox } : {}),
@@ -16,7 +16,14 @@ export class SqliteAutopilotStore {
16
16
  if (!row)
17
17
  return null;
18
18
  const lifecycle = parseLifecycle(row.lifecycle_json);
19
- if (!['disabled', 'monitoring', 'backoff', 'attentionRequired', 'completed'].includes(String(row.state)) ||
19
+ if (![
20
+ 'disabled',
21
+ 'monitoring',
22
+ 'backoff',
23
+ 'attentionRequired',
24
+ 'completed',
25
+ 'safetyPaused',
26
+ ].includes(String(row.state)) ||
20
27
  ![
21
28
  'manualDisabled',
22
29
  'planRequired',
@@ -30,6 +37,7 @@ export class SqliteAutopilotStore {
30
37
  'planRemoved',
31
38
  'planReplaced',
32
39
  'sessionEnded',
40
+ 'safetyPaused',
33
41
  'null',
34
42
  ].includes(String(row.stop_reason)) ||
35
43
  !Number.isSafeInteger(Number(row.generation)) ||
@@ -57,10 +65,11 @@ export class SqliteAutopilotStore {
57
65
  save(state) {
58
66
  this.db
59
67
  .prepare('INSERT INTO autopilot_sessions (session_id,state,requested_enabled,plan_identity,plan_fingerprint,generation,no_progress_count,next_evaluation_at,last_control_id,stop_reason,lifecycle_json,updated_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?) ON CONFLICT(session_id) DO UPDATE SET state=excluded.state,requested_enabled=excluded.requested_enabled,plan_identity=excluded.plan_identity,plan_fingerprint=excluded.plan_fingerprint,generation=excluded.generation,no_progress_count=excluded.no_progress_count,next_evaluation_at=excluded.next_evaluation_at,last_control_id=excluded.last_control_id,stop_reason=excluded.stop_reason,lifecycle_json=excluded.lifecycle_json,updated_at=excluded.updated_at')
60
- .run(state.sessionId, state.state, state.requestedEnabled ? 1 : 0, state.planIdentity, state.planFingerprint, state.generation, state.consecutiveNoProgress, state.nextEvaluationAt, state.lastControlId, state.stopReason, state.executor || state.blocking || state.checkpoints
68
+ .run(state.sessionId, state.state, state.requestedEnabled ? 1 : 0, state.planIdentity, state.planFingerprint, state.generation, state.consecutiveNoProgress, state.nextEvaluationAt, state.lastControlId, state.stopReason, state.executor || state.blocking || state.supervision || state.checkpoints
61
69
  ? JSON.stringify({
62
70
  executor: state.executor,
63
71
  blocking: state.blocking,
72
+ supervision: state.supervision,
64
73
  checkpoints: state.checkpoints,
65
74
  })
66
75
  : null, state.updatedAt);
@@ -154,9 +154,11 @@ class ActiveLease {
154
154
  onActiveStatusPath;
155
155
  onClose;
156
156
  watcher;
157
+ planWatcher;
157
158
  debounce;
158
159
  closed = false;
159
160
  activeStatusPath;
161
+ activePlanPath;
160
162
  lastEmittedUpdate;
161
163
  constructor(statusDirectory, sessionId, workspacePath, listener, planReadFilesystem, isDismissed, onActiveStatusPath, onClose) {
162
164
  this.statusDirectory = statusDirectory;
@@ -184,7 +186,10 @@ class ActiveLease {
184
186
  this.closed = true;
185
187
  if (this.debounce)
186
188
  clearTimeout(this.debounce);
189
+ if (this.planDebounce)
190
+ clearTimeout(this.planDebounce);
187
191
  void this.watcher?.return?.();
192
+ void this.planWatcher?.return?.();
188
193
  this.onClose();
189
194
  }
190
195
  async remove() {
@@ -226,6 +231,35 @@ class ActiveLease {
226
231
  void (pendingStatusPath ? this.refreshPath(pendingStatusPath) : this.refreshLatest());
227
232
  }, 25);
228
233
  }
234
+ watchActivePlan(planPath) {
235
+ if (this.activePlanPath === planPath && this.planWatcher)
236
+ return;
237
+ void this.planWatcher?.return?.();
238
+ this.activePlanPath = planPath;
239
+ void (async () => {
240
+ try {
241
+ this.planWatcher = watch(dirname(planPath), { persistent: false });
242
+ for await (const event of this.planWatcher) {
243
+ if (this.closed || basename(String(event.filename ?? '')) !== basename(planPath))
244
+ continue;
245
+ this.schedulePlanRefresh();
246
+ }
247
+ }
248
+ catch {
249
+ // The status signal remains the bounded recovery path.
250
+ }
251
+ })();
252
+ }
253
+ planDebounce;
254
+ schedulePlanRefresh() {
255
+ if (this.planDebounce)
256
+ clearTimeout(this.planDebounce);
257
+ this.planDebounce = setTimeout(() => {
258
+ this.planDebounce = undefined;
259
+ if (this.activeStatusPath)
260
+ void this.refreshPath(this.activeStatusPath);
261
+ }, 25);
262
+ }
229
263
  async refreshCurrent() {
230
264
  return this.activeStatusPath ? this.refreshPath(this.activeStatusPath) : this.refreshLatest();
231
265
  }
@@ -274,6 +308,7 @@ class ActiveLease {
274
308
  if (!this.closed && !(await this.isDismissed(identity)) && !this.closed) {
275
309
  const previousStatusPath = this.activeStatusPath;
276
310
  this.activeStatusPath = statusPath;
311
+ this.watchActivePlan(planPath);
277
312
  this.onActiveStatusPath(statusPath);
278
313
  const update = {
279
314
  kind: 'updated',
@@ -0,0 +1,62 @@
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
+ export const GESTALT_AUTOPILOT_WAIT_LEASE_TOOL_NAME = 'gestalt_autopilot_wait_lease';
7
+ export const autopilotWaitWakeConditions = [
8
+ 'planChanged',
9
+ 'reviewChanged',
10
+ 'checkpointChanged',
11
+ 'interactionChanged',
12
+ 'executorChanged',
13
+ 'processExited',
14
+ 'processResultAvailable',
15
+ 'processLimitBreached',
16
+ 'agentActivityChanged',
17
+ ];
18
+ export const gestaltAutopilotWaitLeaseDynamicTool = {
19
+ type: 'function',
20
+ name: GESTALT_AUTOPILOT_WAIT_LEASE_TOOL_NAME,
21
+ description: 'Register a bounded observable wait for the current supervised session after an Autopilot probe. This cannot request human authority and never accepts transcript prose.',
22
+ inputSchema: {
23
+ type: 'object',
24
+ additionalProperties: false,
25
+ required: ['version', 'reportId', 'leaseId', 'wakeConditions'],
26
+ properties: {
27
+ version: { const: 1 },
28
+ reportId: { type: 'string', minLength: 1, maxLength: 128 },
29
+ leaseId: { type: 'string', minLength: 1, maxLength: 128 },
30
+ wakeConditions: {
31
+ type: 'array',
32
+ minItems: 1,
33
+ maxItems: 4,
34
+ uniqueItems: true,
35
+ items: { type: 'string', enum: autopilotWaitWakeConditions },
36
+ },
37
+ },
38
+ },
39
+ };
40
+ export function parseAutopilotWaitLease(value) {
41
+ if (!record(value) || Object.keys(value).length !== 4 || value.version !== 1)
42
+ return null;
43
+ if (!bounded(value.reportId) || !bounded(value.leaseId) || !Array.isArray(value.wakeConditions))
44
+ return null;
45
+ const wakeConditions = value.wakeConditions;
46
+ if (wakeConditions.length < 1 ||
47
+ wakeConditions.length > 4 ||
48
+ new Set(wakeConditions).size !== wakeConditions.length ||
49
+ !wakeConditions.every((condition) => typeof condition === 'string' &&
50
+ autopilotWaitWakeConditions.includes(condition)))
51
+ return null;
52
+ return { version: 1, reportId: value.reportId, leaseId: value.leaseId, wakeConditions };
53
+ }
54
+ export function autopilotWaitLeaseToolResponse() {
55
+ return { success: true, contentItems: [{ type: 'inputText', text: '{"accepted":true}' }] };
56
+ }
57
+ function record(value) {
58
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
59
+ }
60
+ function bounded(value) {
61
+ return typeof value === 'string' && value.length > 0 && value.length <= 128;
62
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gestalt-mobile",
3
- "version": "0.29.0",
3
+ "version": "0.30.1",
4
4
  "description": "Mobile-first web relay for durable Codex development sessions",
5
5
  "keywords": [
6
6
  "codex",
@@ -70,7 +70,7 @@
70
70
  "@fontsource/syne": "5.2.7",
71
71
  "@simplewebauthn/browser": "13.3.0",
72
72
  "@simplewebauthn/server": "13.3.2",
73
- "fastify": "^5.10.0",
73
+ "fastify": "^5.12.3",
74
74
  "qrcode": "^1.5.4",
75
75
  "ws": "^8.21.0",
76
76
  "yaml": "^2.9.0",