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
@@ -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-CRULnRRB.js"></script>
20
- <link rel="stylesheet" crossorigin href="/assets/index-BtlbAtm9.css">
19
+ <script type="module" crossorigin src="/assets/index-CedbthUQ.js"></script>
20
+ <link rel="stylesheet" crossorigin href="/assets/index-CKhqx71S.css">
21
21
  </head>
22
22
  <body>
23
23
  <div id="app"></div>
@@ -12,7 +12,7 @@ import { fileURLToPath } from 'node:url';
12
12
  import { promisify } from 'node:util';
13
13
  import { composeRelayApp } from './composition.js';
14
14
  import { CliUsageError, parseConfig } from './config.js';
15
- import { CodexProfileCatalog } from './platform/catalog/codex-profile-catalog.js';
15
+ import { LauncherProfileCatalog } from './platform/catalog/launcher-profile-catalog.js';
16
16
  import { FilesystemSkillProfileStore } from './platform/skills/filesystem-skill-profile-store.js';
17
17
  const runFile = promisify(execFile);
18
18
  export const usage = `Usage: gestalt-mobile [options]
@@ -28,6 +28,26 @@ Options:
28
28
  --skills list List saved global skill profiles without starting the server
29
29
  --help Show this help
30
30
  --version Show the installed version`;
31
+ export async function runStartupDoctor(cwd) {
32
+ if (process.env.GESTALT_STARTUP_DIAGNOSTICS_DONE === '1')
33
+ return { ok: true, report: '' };
34
+ try {
35
+ const result = await runFile('gestalt', ['doctor'], {
36
+ cwd,
37
+ env: process.env,
38
+ timeout: 60_000,
39
+ maxBuffer: 1024 * 1024,
40
+ });
41
+ return { ok: true, report: `${result.stdout}${result.stderr}` };
42
+ }
43
+ catch (error) {
44
+ const failure = error;
45
+ return {
46
+ ok: false,
47
+ report: `${failure.stdout ?? ''}${failure.stderr ?? ''}${failure.message ? `${failure.message}\n` : ''}`,
48
+ };
49
+ }
50
+ }
31
51
  export function packageRoot(moduleUrl) {
32
52
  let directory = dirname(fileURLToPath(moduleUrl));
33
53
  while (true) {
@@ -155,13 +175,18 @@ export async function runCli(dependencies = {}) {
155
175
  stderr.write(`Unknown skill profile: ${config.skillsProfile}\n\n${usage}\n`);
156
176
  return 2;
157
177
  }
178
+ const diagnosis = await (dependencies.runStartupDoctor ?? runStartupDoctor)(cwd);
179
+ if (diagnosis.report)
180
+ stderr.write(diagnosis.report);
181
+ if (!diagnosis.ok)
182
+ stderr.write('WARNING: Startup diagnostics reported a problem; Gestalt Mobile will continue so it can show recovery controls.\n');
158
183
  const app = await (dependencies.compose ?? composeRelayApp)({
159
184
  root: config.root,
160
185
  dataDir: config.dataDir,
161
186
  relyingParty: config.relyingParty,
162
187
  passkeyAuthEnabled: config.passkeyAuthEnabled,
163
188
  staticDir: packagedClientDir(moduleUrl),
164
- profiles: new CodexProfileCatalog(),
189
+ profiles: new LauncherProfileCatalog(),
165
190
  installedCodexVersion: await (dependencies.probeCodexVersion ?? probeCodexVersion)(),
166
191
  startAppServers: true,
167
192
  homeDirectory: dependencies.homeDirectory,
@@ -210,7 +210,7 @@ async function composeAuthorizedApp(options) {
210
210
  if (!store.sessionDevice(authorizationSessionId('test-session'), '2026-08-02T00:00:00.000Z'))
211
211
  store.saveSession(authorizationSessionId('test-session'), {
212
212
  deviceId: device.id,
213
- expiresAt: '2026-09-01T00:00:00.000Z',
213
+ expiresAt: '2099-09-01T00:00:00.000Z',
214
214
  });
215
215
  store.close();
216
216
  const app = await composeRelayApp({ ...options, homeDirectory });
@@ -35,7 +35,7 @@ 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
37
  import { decodeAgentActivityFacts } from './platform/codex/activity-facts.js';
38
- import { resolvedServerRequestId, toPendingInteraction } from './platform/codex/server-request.js';
38
+ import { isAutopilotWaitLeaseCall, resolvedServerRequestId, toPendingInteraction, } from './platform/codex/server-request.js';
39
39
  import { parseOrgPlanCheckpoint, toOrgPlanCheckpointToolResponse, } from '../shared/contracts/org-plan-checkpoint.js';
40
40
  import { validOrgPlanCheckpoint } from './features/org-plan-checkpoint/application/validate.js';
41
41
  import { isValidInteractionResponse, isValidQuizInteractionResponse, } from './features/sessions/interaction/response-validator.js';
@@ -51,9 +51,10 @@ import { checkpointPlanMeasurement } from './platform/plans/plan-measurement-com
51
51
  import { PlanMeasurementRefresh } from './platform/plans/plan-measurement-refresh.js';
52
52
  import { SqliteAutopilotStore } from './platform/persistence/sqlite-autopilot-store.js';
53
53
  import { AutopilotCoordinator } from './features/autopilot/application/service.js';
54
- import { AUTOPILOT_CONTINUATION_PROMPT, AUTOPILOT_EXECUTOR_CONTINUATION_PROMPT, defaultAutopilotPolicy, } from './features/autopilot/application/policy.js';
54
+ import { AUTOPILOT_CONTINUATION_PROMPT, AUTOPILOT_EXECUTOR_CONTINUATION_PROMPT, autopilotExecutorLaunchPrompt, defaultAutopilotPolicy, } from './features/autopilot/application/policy.js';
55
55
  import { createRelyingPartyConfig } from './config.js';
56
56
  import { parseOrgPlanAttention } from '../shared/contracts/org-plan-attention.js';
57
+ import { autopilotWaitLeaseToolResponse, } from '../shared/contracts/autopilot-wait-lease.js';
57
58
  const generatedProtocolVersion = 'codex-cli 0.144.3';
58
59
  export async function composeRelayApp(options) {
59
60
  const passkeyAuthEnabled = options.passkeyAuthEnabled ?? true;
@@ -219,7 +220,7 @@ export async function composeRelayApp(options) {
219
220
  throw new Error('AUTOPILOT_START_UNAVAILABLE');
220
221
  await options.autopilotBeforeTurnAccepted?.({ sessionId, controlId });
221
222
  const continuationPrompt = launchIdentity
222
- ? `${AUTOPILOT_CONTINUATION_PROMPT} Launch task_name ${launchIdentity.taskName} for canonical ${launchIdentity.canonicalPosition}; retain the canonical label in status and review output.`
223
+ ? autopilotExecutorLaunchPrompt(launchIdentity)
223
224
  : AUTOPILOT_CONTINUATION_PROMPT;
224
225
  const started = await runtime.startTurn(writer.session, continuationPrompt, controlId, new Date().toISOString());
225
226
  if (!started.activeTurnId)
@@ -335,6 +336,7 @@ export async function composeRelayApp(options) {
335
336
  events.publish(journal.append(sessionId, 'interaction.resolved', { requestId, turnId: interaction?.turnId ?? null, resolvedAt: occurredAt, outcome }, occurredAt));
336
337
  if (interaction?.kind === 'orgPlanAttention')
337
338
  events.publish(journal.append(sessionId, 'org-plan.attention-resolved', { requestId, turnId: interaction.turnId ?? null, resolvedAt: occurredAt, outcome }, occurredAt));
339
+ autopilot.semanticEvent(sessionId, 'interactionChanged');
338
340
  };
339
341
  const publishAttentionSettlement = (sessionId, requestId, occurredAt, outcome) => {
340
342
  const remaining = interactions.list(sessionId);
@@ -428,8 +430,33 @@ export async function composeRelayApp(options) {
428
430
  }, (sessionId, request, origin) => {
429
431
  const rawInteraction = toPendingInteraction(request);
430
432
  const session = withPendingInteractions(sessions.find(sessionId));
431
- if (!rawInteraction || !session)
433
+ if (!session)
434
+ return false;
435
+ if (!rawInteraction && isAutopilotWaitLeaseCall(request)) {
436
+ const rootOwned = origin.kind === 'root' && origin.physicalTurnId === session.activeTurnId;
437
+ if (rootOwned)
438
+ autopilot.rejectProbe(sessionId);
439
+ return (runtime?.resolveServerRequest(sessionId, String(request.id), autopilotWaitLeaseToolResponse()) === true);
440
+ }
441
+ if (!rawInteraction)
432
442
  return false;
443
+ if (rawInteraction.kind === 'autopilotWaitLease') {
444
+ const lease = rawInteraction.payload;
445
+ const rootOwned = origin.kind === 'root' && origin.physicalTurnId === session.activeTurnId;
446
+ const accepted = rootOwned &&
447
+ autopilot.reportProbe(sessionId, {
448
+ id: lease.reportId,
449
+ kind: 'wait',
450
+ leaseId: lease.leaseId,
451
+ wakeConditions: lease.wakeConditions,
452
+ });
453
+ if (!accepted) {
454
+ if (rootOwned)
455
+ autopilot.rejectProbe(sessionId);
456
+ return (runtime?.resolveServerRequest(sessionId, rawInteraction.requestId, autopilotWaitLeaseToolResponse()) === true);
457
+ }
458
+ return (runtime?.resolveServerRequest(sessionId, rawInteraction.requestId, autopilotWaitLeaseToolResponse()) === true);
459
+ }
433
460
  if (rawInteraction.kind === 'orgPlanCheckpoint') {
434
461
  const checkpoint = parseOrgPlanCheckpoint(rawInteraction.payload);
435
462
  const retained = supervisedPlans.find(sessionId);
@@ -468,6 +495,7 @@ export async function composeRelayApp(options) {
468
495
  ? { attentionReason: interaction.payload.reason }
469
496
  : {}),
470
497
  });
498
+ autopilot.semanticEvent(sessionId, 'interactionChanged');
471
499
  const updated = RelaySession.rehydrate(session).requestInteraction(interaction, new Date().toISOString()).snapshot;
472
500
  sessions.save(updated);
473
501
  events.publish(journal.append(sessionId, 'interaction.requested', interaction, updated.updatedAt));
@@ -3,9 +3,16 @@
3
3
  * Designed by Denis Roio <jaromil@dyne.org>
4
4
  * SPDX-License-Identifier: AGPL-3.0-or-later
5
5
  */
6
- export const AUTOPILOT_PROMPT_VERSION = 'v3';
7
- export const AUTOPILOT_CONTINUATION_PROMPT = 'Inspect the active supervised Org Plan. Refer to every L1 as L<a> and each nested L2 as L<a>.<b>, using one-based positions. For a subagent dedicated to that position, use the collaboration-safe task_name l<a> or l<a>_<b> and refer to it by its canonical L label. Invoke gestalt_org_plan_attention only for a decision-table blocker; otherwise immediately perform the next legal lifecycle action. Do not send a status-only response.';
6
+ export const AUTOPILOT_PROMPT_VERSION = 'v6';
7
+ export const AUTOPILOT_CONTINUATION_PROMPT = 'Inspect the active supervised Org Plan. Refer to every L1 as L<a> and each nested L2 as L<a>.<b>, using one-based positions. For a subagent dedicated to that position, use the collaboration-safe task_name l<a> or l<a>_<b> and refer to it by its canonical L label. If the active L1 is still WIP and its executor stopped or is idle after a partial report or L2 checkpoint, call followup_task on that same executor before sending any response. Treat a status question as an interruption: answer briefly, then perform that continuation in the same turn. Invoke gestalt_org_plan_attention only for a decision-table blocker. When the Autopilot probe is active, register gestalt_autopilot_wait_lease with an observable wake condition, declare genuine attention, or immediately do actionable work; never acknowledge waiting in prose. Do not send a status-only response.';
8
8
  export const AUTOPILOT_EXECUTOR_CONTINUATION_PROMPT = 'Continue the same assigned Org L1 from its durable state. A prior turn ending did not complete the objective. Consume any supplied process result, take the next legal L2 action, and report only at the L1 review boundary or through structured attention.';
9
+ /** Builds a physical launch instruction while keeping durable L1 identity canonical. */
10
+ export function autopilotExecutorLaunchPrompt(identity) {
11
+ const launch = `Launch task_name ${identity.taskName} for canonical ${identity.canonicalPosition}; retain the canonical label in status and review output.`;
12
+ if (identity.generation === 1)
13
+ return `${AUTOPILOT_CONTINUATION_PROMPT} ${launch}`;
14
+ return `${AUTOPILOT_CONTINUATION_PROMPT} ${launch} This is replacement generation ${identity.generation}: the durable ${identity.canonicalTaskName} slot may remain reserved. Do not reuse that task_name or attempt to change an existing agent's model in place. Interrupt the previous physical executor if it is still running, then spawn ${identity.taskName} with agent_type worker and an explicit model selected by the supervisor. Transfer sole ${identity.canonicalPosition} ownership and continue from its durable Org Plan and worktree state.`;
15
+ }
9
16
  export const defaultAutopilotPolicy = Object.freeze({
10
17
  quiescenceMs: 1_000,
11
18
  staleAfterMs: 30_000,
@@ -74,6 +81,14 @@ export function decideAutopilot(input) {
74
81
  // validated Org attention record may turn an incomplete plan into a human stop.
75
82
  if (hasPendingInteraction)
76
83
  return { kind: 'observe' };
84
+ const semanticProgressObserved = Boolean(input.semanticProgressKey &&
85
+ state.supervision &&
86
+ input.semanticProgressKey !== state.supervision.progressKey);
87
+ const semanticLoopConfirmed = (state.supervision?.unchangedContinuations ?? 3) >= 3;
88
+ if ((input.automaticActionCount ?? 0) > policy.actionLimit &&
89
+ !semanticProgressObserved &&
90
+ semanticLoopConfirmed)
91
+ return { kind: 'safetyPause', reason: 'actionRateExceeded' };
77
92
  if (!activity || activity.confidence !== 'fresh')
78
93
  return { kind: 'reconcile' };
79
94
  if (Date.parse(now) - Date.parse(activity.root.lastActivityAt) > policy.staleAfterMs)
@@ -7,12 +7,15 @@ import { createHash } from 'node:crypto';
7
7
  import { autopilotSnapshot, disabledAutopilot, } from '../domain/autopilot-session.js';
8
8
  import { classifyExecutorOutcome, decideSupervisedLifecycle, executorIdentity, validStructuredBlock, } from '../domain/supervised-lifecycle.js';
9
9
  import { classifyAgentActivity, decideAutopilot, executionComplete, } from './policy.js';
10
+ import { consumeObservableWake, recordAutomaticContinuation, recoverSafetyPause, reportProbe, semanticProgressKey, startSupervisionProtocol, } from '../domain/supervision-protocol.js';
10
11
  export class AutopilotCoordinator {
11
12
  deps;
12
13
  timers = new Map();
13
14
  completionTimers = new Map();
14
15
  executorTimers = new Map();
15
16
  publishedSnapshots = new Map();
17
+ planEventKeys = new Map();
18
+ activityEventKeys = new Map();
16
19
  /** Serializes asynchronous watchdog and timer work per relay session. */
17
20
  operations = new Map();
18
21
  constructor(deps) {
@@ -36,7 +39,7 @@ export class AutopilotCoordinator {
36
39
  !session ||
37
40
  !session.threadId ||
38
41
  !state.requestedEnabled ||
39
- ['disabled', 'attentionRequired', 'completed'].includes(state.state))
42
+ ['disabled', 'attentionRequired', 'completed', 'safetyPaused'].includes(state.state))
40
43
  return;
41
44
  // A relay restart deliberately drops its writer before rehydrating the
42
45
  // coordinator. The plan-status watcher is authoritative and asynchronous,
@@ -78,6 +81,88 @@ export class AutopilotCoordinator {
78
81
  }
79
82
  dispose(sessionId) {
80
83
  this.cancelTimer(sessionId);
84
+ this.planEventKeys.delete(sessionId);
85
+ this.activityEventKeys.delete(sessionId);
86
+ }
87
+ /** Accepts a session-owned structured probe response; no transcript text is inspected. */
88
+ reportProbe(sessionId, report) {
89
+ const state = this.deps.store.find(sessionId);
90
+ if (!state?.requestedEnabled)
91
+ return false;
92
+ const nextProtocol = reportProbe(state.supervision ?? startSupervisionProtocol(this.progressKey(sessionId)), report);
93
+ if (nextProtocol === state.supervision)
94
+ return false;
95
+ const now = this.deps.now();
96
+ this.persist({
97
+ ...state,
98
+ supervision: nextProtocol,
99
+ ...(nextProtocol.outcome === 'attentionRequired'
100
+ ? {
101
+ state: 'attentionRequired',
102
+ requestedEnabled: false,
103
+ stopReason: 'attentionRequired',
104
+ }
105
+ : {}),
106
+ updatedAt: now,
107
+ });
108
+ if (nextProtocol.outcome === 'parked' || nextProtocol.outcome === 'attentionRequired')
109
+ this.cancelTimer(sessionId);
110
+ return true;
111
+ }
112
+ /** Fails closed only for the currently active bounded probe. */
113
+ rejectProbe(sessionId) {
114
+ const state = this.deps.store.find(sessionId);
115
+ if (!state?.requestedEnabled || state.supervision?.outcome !== 'probeRequired')
116
+ return false;
117
+ const now = this.deps.now();
118
+ const cancelled = this.cancelScheduledControl(state, now);
119
+ this.cancelTimer(sessionId);
120
+ this.persist({
121
+ ...state,
122
+ state: 'safetyPaused',
123
+ requestedEnabled: false,
124
+ generation: state.generation + 1,
125
+ nextEvaluationAt: null,
126
+ ...(cancelled ? { lastControlId: null } : {}),
127
+ stopReason: 'safetyPaused',
128
+ supervision: {
129
+ ...state.supervision,
130
+ outcome: 'safetyPaused',
131
+ safetyPauseReason: 'invalidProbeReport',
132
+ },
133
+ updatedAt: now,
134
+ }, cancelled, [
135
+ {
136
+ sessionId,
137
+ type: 'autopilot.safety-paused',
138
+ payload: { reason: 'invalidProbeReport' },
139
+ occurredAt: now,
140
+ },
141
+ ]);
142
+ return true;
143
+ }
144
+ /** A durable matching wake grants the one retry and schedules no work by itself. */
145
+ observableWake(sessionId, wake) {
146
+ const state = this.deps.store.find(sessionId);
147
+ if (!state?.requestedEnabled || !state.supervision)
148
+ return false;
149
+ const nextProtocol = consumeObservableWake(state.supervision, wake);
150
+ if (nextProtocol === state.supervision)
151
+ return false;
152
+ this.persist({ ...state, supervision: nextProtocol, updatedAt: this.deps.now() });
153
+ this.evaluate(sessionId);
154
+ return true;
155
+ }
156
+ /** Wakes a parked lease only when this semantic event was explicitly requested. */
157
+ semanticEvent(sessionId, condition) {
158
+ const lease = this.deps.store.find(sessionId)?.supervision?.waitLease;
159
+ if (!lease || !lease.wakeConditions.includes(condition))
160
+ return false;
161
+ return this.observableWake(sessionId, {
162
+ leaseId: lease.id,
163
+ condition,
164
+ progressKey: this.progressKey(sessionId),
165
+ });
81
166
  }
82
167
  enable(sessionId) {
83
168
  const session = this.deps.session(sessionId);
@@ -107,6 +192,7 @@ export class AutopilotCoordinator {
107
192
  stopReason: null,
108
193
  executor: undefined,
109
194
  blocking: undefined,
195
+ supervision: recoverSafetyPause(prior.supervision ?? startSupervisionProtocol(this.progressKey(sessionId)), this.progressKey(sessionId)),
110
196
  updatedAt: now,
111
197
  };
112
198
  this.persist(next);
@@ -137,6 +223,7 @@ export class AutopilotCoordinator {
137
223
  ...(cancelled ? { lastControlId: null } : {}),
138
224
  stopReason: 'manualDisabled',
139
225
  blocking: undefined,
226
+ supervision: recoverSafetyPause(prior.supervision ?? startSupervisionProtocol(this.progressKey(sessionId)), this.progressKey(sessionId)),
140
227
  updatedAt: now,
141
228
  };
142
229
  this.persist(next, cancelled);
@@ -260,6 +347,17 @@ export class AutopilotCoordinator {
260
347
  updatedAt: now,
261
348
  };
262
349
  break;
350
+ case 'safetyPause':
351
+ next = {
352
+ ...prior,
353
+ state: 'safetyPaused',
354
+ requestedEnabled: false,
355
+ generation: prior.generation + 1,
356
+ nextEvaluationAt: null,
357
+ stopReason: 'safetyPaused',
358
+ updatedAt: now,
359
+ };
360
+ break;
263
361
  case 'disable':
264
362
  next = {
265
363
  ...prior,
@@ -275,12 +373,14 @@ export class AutopilotCoordinator {
275
373
  }
276
374
  if (decision.kind === 'requestAttention' ||
277
375
  decision.kind === 'complete' ||
278
- decision.kind === 'disable')
376
+ decision.kind === 'disable' ||
377
+ decision.kind === 'safetyPause')
279
378
  this.cancelTimer(sessionId);
280
379
  if (next !== prior) {
281
380
  const cancelled = decision.kind === 'requestAttention' ||
282
381
  decision.kind === 'complete' ||
283
- decision.kind === 'disable'
382
+ decision.kind === 'disable' ||
383
+ decision.kind === 'safetyPause'
284
384
  ? this.cancelScheduledControl(prior, now)
285
385
  : undefined;
286
386
  this.persist(cancelled ? { ...next, lastControlId: null } : next, cancelled);
@@ -375,6 +475,7 @@ export class AutopilotCoordinator {
375
475
  occurredAt,
376
476
  },
377
477
  ]);
478
+ this.semanticEvent(sessionId, 'checkpointChanged');
378
479
  return true;
379
480
  }
380
481
  /** Handles only plan lifecycle safety; ordinary plan mutations are ignored. */
@@ -391,8 +492,16 @@ export class AutopilotCoordinator {
391
492
  this.cancel(sessionId, 'planReplaced');
392
493
  return;
393
494
  }
394
- if (executionComplete(plan.plan))
495
+ if (this.semanticEvent(sessionId, 'planChanged') ||
496
+ this.semanticEvent(sessionId, 'reviewChanged'))
497
+ return;
498
+ if (executionComplete(plan.plan)) {
499
+ const eventKey = `plan:${fingerprint(plan.plan)}`;
500
+ if (this.planEventKeys.get(sessionId) === eventKey)
501
+ return;
502
+ this.planEventKeys.set(sessionId, eventKey);
395
503
  this.evaluate(sessionId);
504
+ }
396
505
  }
397
506
  /**
398
507
  * Records a validated, session-private supervision request. It intentionally
@@ -447,6 +556,25 @@ export class AutopilotCoordinator {
447
556
  const activity = this.deps.activity(sessionId);
448
557
  if (!activity || activity.confidence !== 'fresh')
449
558
  return;
559
+ const eventKey = JSON.stringify({
560
+ root: [activity.root.state, activity.root.reason],
561
+ children: activity.subagents.map((child) => [
562
+ child.id,
563
+ child.state,
564
+ child.reason,
565
+ child.continuationGeneration ?? 0,
566
+ (child.ownedProcesses ?? []).map((process) => [
567
+ process.processId,
568
+ process.state,
569
+ process.ownership,
570
+ ]),
571
+ ]),
572
+ });
573
+ if (this.activityEventKeys.get(sessionId) === eventKey)
574
+ return;
575
+ this.activityEventKeys.set(sessionId, eventKey);
576
+ if (this.semanticEvent(sessionId, 'agentActivityChanged'))
577
+ return;
450
578
  const disposition = classifyAgentActivity(activity);
451
579
  if (disposition === 'attention') {
452
580
  this.evaluate(sessionId);
@@ -589,6 +717,39 @@ export class AutopilotCoordinator {
589
717
  await this.enforceSupervisedLifecycle(sessionId, 'stateChanged');
590
718
  return;
591
719
  }
720
+ const protocol = recordAutomaticContinuation(current.supervision ?? startSupervisionProtocol(this.progressKey(sessionId)), this.progressKey(sessionId));
721
+ if (protocol.outcome === 'probeRequired') {
722
+ this.persist({ ...current, supervision: protocol, updatedAt: this.deps.now() }, undefined, [
723
+ {
724
+ sessionId,
725
+ type: 'autopilot.probe-required',
726
+ payload: { progressKey: protocol.progressKey },
727
+ occurredAt: this.deps.now(),
728
+ },
729
+ ]);
730
+ }
731
+ if (protocol.outcome === 'safetyPaused') {
732
+ const now = this.deps.now();
733
+ const cancelled = this.cancelScheduledControl(current, now);
734
+ this.persist({
735
+ ...current,
736
+ state: 'safetyPaused',
737
+ requestedEnabled: false,
738
+ stopReason: 'safetyPaused',
739
+ ...(cancelled ? { generation: current.generation + 1, lastControlId: null } : {}),
740
+ nextEvaluationAt: null,
741
+ supervision: protocol,
742
+ updatedAt: now,
743
+ }, cancelled, [
744
+ {
745
+ sessionId,
746
+ type: 'autopilot.safety-paused',
747
+ payload: { progressKey: protocol.progressKey },
748
+ occurredAt: now,
749
+ },
750
+ ]);
751
+ return;
752
+ }
592
753
  const id = current.lastControlId;
593
754
  if (!id)
594
755
  return;
@@ -646,8 +807,9 @@ export class AutopilotCoordinator {
646
807
  this.deps.pendingInteraction(sessionId))
647
808
  return false;
648
809
  const executor = this.currentExecutor(sessionId, retained.plan, state.consecutiveNoProgress, state.executor);
649
- if (executor)
650
- this.persistExecutor(sessionId, executor);
810
+ const executorChanged = executor ? this.persistExecutor(sessionId, executor) : false;
811
+ if (executorChanged && this.wakeForExecutorChange(sessionId, executor))
812
+ return true;
651
813
  const decision = decideSupervisedLifecycle({
652
814
  plan: retained.plan,
653
815
  event,
@@ -672,7 +834,11 @@ export class AutopilotCoordinator {
672
834
  return true;
673
835
  case 'monitorProcess': {
674
836
  const processId = decision.action.process.processId;
675
- controller.transferProcess(sessionId, decision.action.process.ownerThreadId, processId);
837
+ const alreadyMonitoring = executor?.ownedProcesses.some((process) => process.processId === processId &&
838
+ process.ownership === 'supervisor' &&
839
+ process.state === 'detached-active');
840
+ if (!alreadyMonitoring)
841
+ controller.transferProcess(sessionId, decision.action.process.ownerThreadId, processId);
676
842
  if (executor)
677
843
  this.persistExecutor(sessionId, {
678
844
  ...executor,
@@ -680,10 +846,11 @@ export class AutopilotCoordinator {
680
846
  ? { ...process, ownership: 'supervisor', state: 'detached-active' }
681
847
  : process),
682
848
  });
683
- this.audit(sessionId, 'autopilot.process-monitoring', {
684
- threadId: decision.action.process.ownerThreadId,
685
- processId,
686
- });
849
+ if (!alreadyMonitoring)
850
+ this.audit(sessionId, 'autopilot.process-monitoring', {
851
+ threadId: decision.action.process.ownerThreadId,
852
+ processId,
853
+ });
687
854
  this.armExecutorRefresh(sessionId, decision.action.pollAfterMs);
688
855
  return true;
689
856
  }
@@ -785,8 +952,22 @@ export class AutopilotCoordinator {
785
952
  persistExecutor(sessionId, executor) {
786
953
  const current = this.deps.store.find(sessionId);
787
954
  if (!current || JSON.stringify(current.executor) === JSON.stringify(executor))
788
- return;
955
+ return false;
789
956
  this.persist({ ...current, executor, updatedAt: this.deps.now() });
957
+ return true;
958
+ }
959
+ wakeForExecutorChange(sessionId, executor) {
960
+ const processes = executor.ownedProcesses;
961
+ const conditions = [
962
+ ...(processes.some((process) => process.state === 'exited-awaiting-result')
963
+ ? ['processExited', 'processResultAvailable']
964
+ : []),
965
+ ...(processes.some((process) => process.state === 'terminated-for-budget')
966
+ ? ['processLimitBreached']
967
+ : []),
968
+ 'executorChanged',
969
+ ];
970
+ return conditions.some((condition) => this.semanticEvent(sessionId, condition));
790
971
  }
791
972
  freshExecutorIdentity(sessionId) {
792
973
  const plan = this.deps.plan(sessionId)?.plan;
@@ -961,8 +1142,11 @@ export class AutopilotCoordinator {
961
1142
  return 'unknown';
962
1143
  }
963
1144
  decision(sessionId, state) {
1145
+ if (state.supervision?.outcome === 'parked')
1146
+ return { kind: 'observe' };
964
1147
  const plan = this.deps.plan(sessionId);
965
1148
  const now = this.deps.now();
1149
+ const currentProgressKey = this.progressKey(sessionId);
966
1150
  return decideAutopilot({
967
1151
  state,
968
1152
  plan: plan?.plan ?? null,
@@ -971,10 +1155,53 @@ export class AutopilotCoordinator {
971
1155
  hasActiveAttention: validStructuredBlock(this.deps.attention?.(sessionId) ?? state.blocking),
972
1156
  lastTurnOutcome: this.lastTurnOutcome(sessionId, state.lastControlId),
973
1157
  automaticActionCount: this.deps.store.automaticActionsSince?.(sessionId, new Date(Date.parse(now) - this.deps.policy.actionWindowMs).toISOString()) ?? 0,
1158
+ semanticProgressKey: currentProgressKey,
974
1159
  now,
975
1160
  policy: this.deps.policy,
976
1161
  });
977
1162
  }
1163
+ progressKey(sessionId) {
1164
+ const retained = this.deps.plan(sessionId);
1165
+ const activity = this.deps.activity(sessionId);
1166
+ const interactions = this.deps.pendingInteraction(sessionId)
1167
+ ? [{ id: 'pending', kind: 'session', state: 'pending' }]
1168
+ : [];
1169
+ return semanticProgressKey({
1170
+ plan: {
1171
+ identity: retained?.identity ?? 'none',
1172
+ fingerprint: retained ? fingerprint(retained.plan) : 'none',
1173
+ currentPosition: retained?.plan.currentStepId ?? null,
1174
+ },
1175
+ review: { status: null },
1176
+ checkpoint: {
1177
+ pendingTurnId: this.deps.store.find(sessionId)?.checkpoints?.pendingTurnId ?? null,
1178
+ terminalReviewAccepted: this.deps.store.find(sessionId)?.checkpoints?.terminalReviewAccepted ?? false,
1179
+ },
1180
+ pendingInteractions: interactions,
1181
+ executor: {
1182
+ generation: this.deps.store.find(sessionId)?.executor?.continuationGeneration ?? 0,
1183
+ state: this.deps.store.find(sessionId)?.executor?.outcome ?? null,
1184
+ },
1185
+ ownedProcesses: (this.deps.store.find(sessionId)?.executor?.ownedProcesses ?? []).map((process) => ({
1186
+ id: process.processId,
1187
+ state: process.state,
1188
+ ownerGeneration: this.deps.store.find(sessionId)?.executor?.continuationGeneration ?? 0,
1189
+ })),
1190
+ agentActivity: [
1191
+ ...(activity
1192
+ ? [
1193
+ {
1194
+ agentId: 'root',
1195
+ // Activity time is liveness-only; semantic progress uses the
1196
+ // sequenced state projection, never the observation clock.
1197
+ sequence: 0,
1198
+ state: activity.root.state,
1199
+ },
1200
+ ]
1201
+ : []),
1202
+ ],
1203
+ });
1204
+ }
978
1205
  async reconcile(sessionId, generation) {
979
1206
  try {
980
1207
  const result = await this.deps.reconcile(sessionId);
@@ -4,6 +4,7 @@
4
4
  * SPDX-License-Identifier: AGPL-3.0-or-later
5
5
  */
6
6
  import { orgPlanAgentDisplayName } from '../../../../shared/org-plan-position.js';
7
+ import { parseSupervisionProtocolState, } from './supervision-protocol.js';
7
8
  export const blockingResumeConditions = {
8
9
  planChange: 'planRevision',
9
10
  hardBlock: 'externalStateChanged',
@@ -25,11 +26,19 @@ export function parsePersistedSupervisedLifecycle(value) {
25
26
  return undefined;
26
27
  const executorValue = record(root.executor);
27
28
  const checkpointsValue = record(root.checkpoints);
29
+ const supervisionValue = root.supervision;
30
+ const supervision = supervisionValue === undefined ? undefined : parseSupervisionProtocolState(supervisionValue);
31
+ if (supervisionValue !== undefined && !supervision)
32
+ return undefined;
28
33
  const checkpoints = checkpointsValue ? parseCheckpoints(checkpointsValue) : undefined;
29
34
  if (checkpointsValue && !checkpoints)
30
35
  return undefined;
31
36
  if (!executorValue)
32
- return { ...(blocking ? { blocking } : {}), ...(checkpoints ? { checkpoints } : {}) };
37
+ return {
38
+ ...(blocking ? { blocking } : {}),
39
+ ...(supervision ? { supervision } : {}),
40
+ ...(checkpoints ? { checkpoints } : {}),
41
+ };
33
42
  const processesValue = executorValue.ownedProcesses;
34
43
  if (!Array.isArray(processesValue) || processesValue.length > 64)
35
44
  return undefined;
@@ -135,6 +144,7 @@ export function parsePersistedSupervisedLifecycle(value) {
135
144
  continuationCount,
136
145
  },
137
146
  ...(blocking ? { blocking } : {}),
147
+ ...(supervision ? { supervision } : {}),
138
148
  ...(checkpoints ? { checkpoints } : {}),
139
149
  };
140
150
  }