gestalt-mobile 0.28.0 → 0.30.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-Bz23puhw.js"></script>
20
- <link rel="stylesheet" crossorigin href="/assets/index-B7x1bL59.css">
19
+ <script type="module" crossorigin src="/assets/index-mKzhyiTl.js"></script>
20
+ <link rel="stylesheet" crossorigin href="/assets/index-C79AKZvE.css">
21
21
  </head>
22
22
  <body>
23
23
  <div id="app"></div>
@@ -35,7 +35,9 @@ 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
+ import { parseOrgPlanCheckpoint, toOrgPlanCheckpointToolResponse, } from '../shared/contracts/org-plan-checkpoint.js';
40
+ import { validOrgPlanCheckpoint } from './features/org-plan-checkpoint/application/validate.js';
39
41
  import { isValidInteractionResponse, isValidQuizInteractionResponse, } from './features/sessions/interaction/response-validator.js';
40
42
  import { promoteRecentThread } from './features/sessions/promote-recent-thread/use-case.js';
41
43
  import { FilesystemSkillProfileStore } from './platform/skills/filesystem-skill-profile-store.js';
@@ -49,9 +51,10 @@ import { checkpointPlanMeasurement } from './platform/plans/plan-measurement-com
49
51
  import { PlanMeasurementRefresh } from './platform/plans/plan-measurement-refresh.js';
50
52
  import { SqliteAutopilotStore } from './platform/persistence/sqlite-autopilot-store.js';
51
53
  import { AutopilotCoordinator } from './features/autopilot/application/service.js';
52
- 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';
53
55
  import { createRelyingPartyConfig } from './config.js';
54
56
  import { parseOrgPlanAttention } from '../shared/contracts/org-plan-attention.js';
57
+ import { autopilotWaitLeaseToolResponse, } from '../shared/contracts/autopilot-wait-lease.js';
55
58
  const generatedProtocolVersion = 'codex-cli 0.144.3';
56
59
  export async function composeRelayApp(options) {
57
60
  const passkeyAuthEnabled = options.passkeyAuthEnabled ?? true;
@@ -217,7 +220,7 @@ export async function composeRelayApp(options) {
217
220
  throw new Error('AUTOPILOT_START_UNAVAILABLE');
218
221
  await options.autopilotBeforeTurnAccepted?.({ sessionId, controlId });
219
222
  const continuationPrompt = launchIdentity
220
- ? `${AUTOPILOT_CONTINUATION_PROMPT} Launch task_name ${launchIdentity.taskName} for canonical ${launchIdentity.canonicalPosition}; retain the canonical label in status and review output.`
223
+ ? autopilotExecutorLaunchPrompt(launchIdentity)
221
224
  : AUTOPILOT_CONTINUATION_PROMPT;
222
225
  const started = await runtime.startTurn(writer.session, continuationPrompt, controlId, new Date().toISOString());
223
226
  if (!started.activeTurnId)
@@ -333,6 +336,7 @@ export async function composeRelayApp(options) {
333
336
  events.publish(journal.append(sessionId, 'interaction.resolved', { requestId, turnId: interaction?.turnId ?? null, resolvedAt: occurredAt, outcome }, occurredAt));
334
337
  if (interaction?.kind === 'orgPlanAttention')
335
338
  events.publish(journal.append(sessionId, 'org-plan.attention-resolved', { requestId, turnId: interaction.turnId ?? null, resolvedAt: occurredAt, outcome }, occurredAt));
339
+ autopilot.semanticEvent(sessionId, 'interactionChanged');
336
340
  };
337
341
  const publishAttentionSettlement = (sessionId, requestId, occurredAt, outcome) => {
338
342
  const remaining = interactions.list(sessionId);
@@ -379,7 +383,7 @@ export async function composeRelayApp(options) {
379
383
  autopilot.planStatusChanged(sessionId);
380
384
  };
381
385
  runtime = options.startAppServers
382
- ? new CodexSessionRuntime(options.launchAppServer ?? launchCodexAppServer, undefined, (sessionId, notification) => {
386
+ ? new CodexSessionRuntime(options.launchAppServer ?? launchCodexAppServer, undefined, (sessionId, notification, origin) => {
383
387
  const occurredAt = new Date().toISOString();
384
388
  for (const activityFact of decodeAgentActivityFacts(sessionId, occurredAt, notification))
385
389
  activity.observe(activityFact);
@@ -395,7 +399,18 @@ export async function composeRelayApp(options) {
395
399
  }
396
400
  }
397
401
  const currentSession = sessions.find(sessionId);
398
- const normalized = normalizeCodexNotification(sessionId, 0, occurredAt, notification, currentSession?.workspacePath, currentSession?.activeTurnId);
402
+ const completionTurnId = notification.method === 'turn/completed' &&
403
+ notification.params &&
404
+ typeof notification.params === 'object' &&
405
+ typeof notification.params.turn?.id === 'string'
406
+ ? notification.params.turn.id
407
+ : undefined;
408
+ // A completion with no thread identity is only root-owned when it exactly
409
+ // matches the durable active root turn; unknown child turns never fall back.
410
+ const resolvedOrigin = origin.kind === 'unknown' && completionTurnId === currentSession?.activeTurnId
411
+ ? { ...origin, kind: 'root' }
412
+ : origin;
413
+ const normalized = normalizeCodexNotification(sessionId, 0, occurredAt, notification, currentSession?.workspacePath, currentSession?.activeTurnId, resolvedOrigin);
399
414
  if (!normalized)
400
415
  return;
401
416
  let completedSession;
@@ -412,11 +427,60 @@ export async function composeRelayApp(options) {
412
427
  events.publish(journal.append(sessionId, normalized.type, normalized.payload, normalized.occurredAt));
413
428
  if (completedSession)
414
429
  events.publish(journal.append(sessionId, 'session.updated', completedSession, occurredAt));
415
- }, (sessionId, request) => {
430
+ }, (sessionId, request, origin) => {
416
431
  const rawInteraction = toPendingInteraction(request);
417
432
  const session = withPendingInteractions(sessions.find(sessionId));
418
- 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)
419
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
+ }
460
+ if (rawInteraction.kind === 'orgPlanCheckpoint') {
461
+ const checkpoint = parseOrgPlanCheckpoint(rawInteraction.payload);
462
+ const retained = supervisedPlans.find(sessionId);
463
+ const identity = supervisedPlans.identity(sessionId);
464
+ const rootOwned = origin.kind === 'root' && origin.physicalTurnId === session.activeTurnId;
465
+ if (!checkpoint ||
466
+ !retained ||
467
+ !identity ||
468
+ checkpoint.planIdentity !== identity ||
469
+ !rootOwned)
470
+ return false;
471
+ const valid = validOrgPlanCheckpoint({
472
+ checkpoint,
473
+ plan: retained,
474
+ planIdentity: identity,
475
+ rootOwned,
476
+ hasActiveL1Writer: (position) => hasActiveL1Writer(activity.snapshot(sessionId, new Date().toISOString()), position),
477
+ });
478
+ if (!valid)
479
+ return false;
480
+ if (!autopilot.checkpointAccepted(sessionId, checkpoint, session.activeTurnId, new Date().toISOString()))
481
+ return false;
482
+ return (runtime?.resolveServerRequest(sessionId, rawInteraction.requestId, toOrgPlanCheckpointToolResponse()) === true);
483
+ }
420
484
  const interaction = {
421
485
  ...rawInteraction,
422
486
  turnId: session.activeTurnId,
@@ -431,6 +495,7 @@ export async function composeRelayApp(options) {
431
495
  ? { attentionReason: interaction.payload.reason }
432
496
  : {}),
433
497
  });
498
+ autopilot.semanticEvent(sessionId, 'interactionChanged');
434
499
  const updated = RelaySession.rehydrate(session).requestInteraction(interaction, new Date().toISOString()).snapshot;
435
500
  sessions.save(updated);
436
501
  events.publish(journal.append(sessionId, 'interaction.requested', interaction, updated.updatedAt));
@@ -959,3 +1024,7 @@ function resolveStateDatabasePath(root, stateHome) {
959
1024
  const legacyPath = legacyRelayStatePath(root, stateHome);
960
1025
  return existsSync(legacyPath) ? legacyPath : currentPath;
961
1026
  }
1027
+ function hasActiveL1Writer(snapshot, position) {
1028
+ return snapshot.subagents.some((child) => child.canonicalPosition === position &&
1029
+ ['working', 'awaitingAgent', 'awaitingHuman'].includes(child.state));
1030
+ }
@@ -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 = 'v5';
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. 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,
@@ -59,12 +66,23 @@ export function decideAutopilot(input) {
59
66
  return { kind: 'disable', reason: 'manualDisabled' };
60
67
  if (!plan)
61
68
  return { kind: 'disable', reason: 'planRequired' };
62
- if (executionComplete(plan))
69
+ if (executionComplete(plan)) {
70
+ // Checkpoint-aware sessions deliberately reserve a later root turn for
71
+ // terminal whole-branch review. The Org projection remains authoritative:
72
+ // this only controls whether a completed L1 may end the current report turn.
73
+ if (state.checkpoints?.protocolVersion === 1 && !state.checkpoints.terminalReviewAccepted)
74
+ return {
75
+ kind: 'scheduleContinuation',
76
+ at: new Date(Date.parse(now) + policy.backoffMs(state.consecutiveNoProgress)).toISOString(),
77
+ };
63
78
  return { kind: 'complete' };
79
+ }
64
80
  // Quiz, approval, and other held requests are ordinary session work. Only a
65
81
  // validated Org attention record may turn an incomplete plan into a human stop.
66
82
  if (hasPendingInteraction)
67
83
  return { kind: 'observe' };
84
+ if ((input.automaticActionCount ?? 0) > policy.actionLimit)
85
+ return { kind: 'safetyPause', reason: 'actionRateExceeded' };
68
86
  if (!activity || activity.confidence !== 'fresh')
69
87
  return { kind: 'reconcile' };
70
88
  if (Date.parse(now) - Date.parse(activity.root.lastActivityAt) > policy.staleAfterMs)
@@ -3,15 +3,19 @@
3
3
  * Designed by Denis Roio <jaromil@dyne.org>
4
4
  * SPDX-License-Identifier: AGPL-3.0-or-later
5
5
  */
6
+ import { createHash } from 'node:crypto';
6
7
  import { autopilotSnapshot, disabledAutopilot, } from '../domain/autopilot-session.js';
7
8
  import { classifyExecutorOutcome, decideSupervisedLifecycle, executorIdentity, validStructuredBlock, } from '../domain/supervised-lifecycle.js';
8
9
  import { classifyAgentActivity, decideAutopilot, executionComplete, } from './policy.js';
10
+ import { consumeObservableWake, recordAutomaticContinuation, recoverSafetyPause, reportProbe, semanticProgressKey, startSupervisionProtocol, } from '../domain/supervision-protocol.js';
9
11
  export class AutopilotCoordinator {
10
12
  deps;
11
13
  timers = new Map();
12
14
  completionTimers = new Map();
13
15
  executorTimers = new Map();
14
16
  publishedSnapshots = new Map();
17
+ planEventKeys = new Map();
18
+ activityEventKeys = new Map();
15
19
  /** Serializes asynchronous watchdog and timer work per relay session. */
16
20
  operations = new Map();
17
21
  constructor(deps) {
@@ -35,7 +39,7 @@ export class AutopilotCoordinator {
35
39
  !session ||
36
40
  !session.threadId ||
37
41
  !state.requestedEnabled ||
38
- ['disabled', 'attentionRequired', 'completed'].includes(state.state))
42
+ ['disabled', 'attentionRequired', 'completed', 'safetyPaused'].includes(state.state))
39
43
  return;
40
44
  // A relay restart deliberately drops its writer before rehydrating the
41
45
  // coordinator. The plan-status watcher is authoritative and asynchronous,
@@ -77,6 +81,88 @@ export class AutopilotCoordinator {
77
81
  }
78
82
  dispose(sessionId) {
79
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
+ });
80
166
  }
81
167
  enable(sessionId) {
82
168
  const session = this.deps.session(sessionId);
@@ -106,6 +192,7 @@ export class AutopilotCoordinator {
106
192
  stopReason: null,
107
193
  executor: undefined,
108
194
  blocking: undefined,
195
+ supervision: recoverSafetyPause(prior.supervision ?? startSupervisionProtocol(this.progressKey(sessionId)), this.progressKey(sessionId)),
109
196
  updatedAt: now,
110
197
  };
111
198
  this.persist(next);
@@ -136,6 +223,7 @@ export class AutopilotCoordinator {
136
223
  ...(cancelled ? { lastControlId: null } : {}),
137
224
  stopReason: 'manualDisabled',
138
225
  blocking: undefined,
226
+ supervision: recoverSafetyPause(prior.supervision ?? startSupervisionProtocol(this.progressKey(sessionId)), this.progressKey(sessionId)),
139
227
  updatedAt: now,
140
228
  };
141
229
  this.persist(next, cancelled);
@@ -259,6 +347,17 @@ export class AutopilotCoordinator {
259
347
  updatedAt: now,
260
348
  };
261
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;
262
361
  case 'disable':
263
362
  next = {
264
363
  ...prior,
@@ -274,12 +373,14 @@ export class AutopilotCoordinator {
274
373
  }
275
374
  if (decision.kind === 'requestAttention' ||
276
375
  decision.kind === 'complete' ||
277
- decision.kind === 'disable')
376
+ decision.kind === 'disable' ||
377
+ decision.kind === 'safetyPause')
278
378
  this.cancelTimer(sessionId);
279
379
  if (next !== prior) {
280
380
  const cancelled = decision.kind === 'requestAttention' ||
281
381
  decision.kind === 'complete' ||
282
- decision.kind === 'disable'
382
+ decision.kind === 'disable' ||
383
+ decision.kind === 'safetyPause'
283
384
  ? this.cancelScheduledControl(prior, now)
284
385
  : undefined;
285
386
  this.persist(cancelled ? { ...next, lastControlId: null } : next, cancelled);
@@ -294,11 +395,31 @@ export class AutopilotCoordinator {
294
395
  state?.stopReason === 'planRemoved' ||
295
396
  state?.stopReason === 'planReplaced' ||
296
397
  state?.stopReason === 'sessionEnded';
398
+ const checkpointBoundary = Boolean(state?.checkpoints?.pendingTurnId &&
399
+ state.checkpoints.planIdentity === this.deps.plan(sessionId)?.identity);
297
400
  const finalAllowed = !state ||
298
401
  !plan ||
299
402
  explicitlyStopped ||
300
- executionComplete(plan) ||
403
+ checkpointBoundary ||
404
+ (!state.checkpoints && executionComplete(plan)) ||
301
405
  validStructuredBlock(state.blocking);
406
+ if (checkpointBoundary && state?.checkpoints) {
407
+ const occurredAt = this.deps.now();
408
+ this.persist({
409
+ ...state,
410
+ checkpoints: { ...state.checkpoints, pendingTurnId: null },
411
+ updatedAt: occurredAt,
412
+ }, undefined, [
413
+ {
414
+ sessionId,
415
+ type: state.checkpoints.terminalReviewAccepted
416
+ ? 'org-plan.terminal-review-reported'
417
+ : 'org-plan.milestone-reported',
418
+ payload: { turnId: state.checkpoints.pendingTurnId },
419
+ occurredAt,
420
+ },
421
+ ]);
422
+ }
302
423
  if (!finalAllowed) {
303
424
  const occurredAt = this.deps.now();
304
425
  this.commit({
@@ -316,6 +437,47 @@ export class AutopilotCoordinator {
316
437
  this.activitySettled(sessionId, 'rootFinalAttempt');
317
438
  return finalAllowed;
318
439
  }
440
+ /** Records an already validated root-only checkpoint; it never changes Org state. */
441
+ checkpointAccepted(sessionId, checkpoint, turnId, occurredAt) {
442
+ const prior = this.deps.store.find(sessionId);
443
+ const retained = this.deps.plan(sessionId);
444
+ if (!prior || !retained || !turnId || checkpoint.planIdentity !== retained.identity)
445
+ return false;
446
+ const previous = prior.checkpoints;
447
+ if (previous && previous.planIdentity !== retained.identity)
448
+ return false;
449
+ const reported = previous?.reportedL1Ids ?? [];
450
+ const key = createHash('sha256').update(JSON.stringify(checkpoint)).digest('hex');
451
+ const acceptedKeys = previous?.acceptedKeys ?? [];
452
+ if (acceptedKeys.includes(key))
453
+ return true;
454
+ if (checkpoint.kind === 'l1Accepted' && reported.includes(checkpoint.l1Id))
455
+ return false;
456
+ if (checkpoint.kind === 'terminalReviewAccepted' && previous?.terminalReviewAccepted)
457
+ return false;
458
+ const checkpoints = {
459
+ protocolVersion: 1,
460
+ planIdentity: retained.identity,
461
+ reportedL1Ids: checkpoint.kind === 'l1Accepted' ? [...reported, checkpoint.l1Id] : reported,
462
+ acceptedKeys: [...acceptedKeys, key],
463
+ pendingTurnId: turnId,
464
+ terminalReviewAccepted: checkpoint.kind === 'terminalReviewAccepted' || previous?.terminalReviewAccepted === true,
465
+ };
466
+ this.persist({ ...prior, checkpoints, updatedAt: occurredAt }, undefined, [
467
+ {
468
+ sessionId,
469
+ type: checkpoint.kind === 'l1Accepted'
470
+ ? 'org-plan.milestone-checkpointed'
471
+ : 'org-plan.terminal-review-checkpointed',
472
+ payload: checkpoint.kind === 'l1Accepted'
473
+ ? { l1Id: checkpoint.l1Id, position: checkpoint.position, turnId }
474
+ : { turnId },
475
+ occurredAt,
476
+ },
477
+ ]);
478
+ this.semanticEvent(sessionId, 'checkpointChanged');
479
+ return true;
480
+ }
319
481
  /** Handles only plan lifecycle safety; ordinary plan mutations are ignored. */
320
482
  planStatusChanged(sessionId) {
321
483
  const prior = this.deps.store.find(sessionId);
@@ -330,8 +492,16 @@ export class AutopilotCoordinator {
330
492
  this.cancel(sessionId, 'planReplaced');
331
493
  return;
332
494
  }
333
- 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);
334
503
  this.evaluate(sessionId);
504
+ }
335
505
  }
336
506
  /**
337
507
  * Records a validated, session-private supervision request. It intentionally
@@ -386,6 +556,25 @@ export class AutopilotCoordinator {
386
556
  const activity = this.deps.activity(sessionId);
387
557
  if (!activity || activity.confidence !== 'fresh')
388
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;
389
578
  const disposition = classifyAgentActivity(activity);
390
579
  if (disposition === 'attention') {
391
580
  this.evaluate(sessionId);
@@ -528,6 +717,39 @@ export class AutopilotCoordinator {
528
717
  await this.enforceSupervisedLifecycle(sessionId, 'stateChanged');
529
718
  return;
530
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
+ }
531
753
  const id = current.lastControlId;
532
754
  if (!id)
533
755
  return;
@@ -585,8 +807,9 @@ export class AutopilotCoordinator {
585
807
  this.deps.pendingInteraction(sessionId))
586
808
  return false;
587
809
  const executor = this.currentExecutor(sessionId, retained.plan, state.consecutiveNoProgress, state.executor);
588
- if (executor)
589
- this.persistExecutor(sessionId, executor);
810
+ const executorChanged = executor ? this.persistExecutor(sessionId, executor) : false;
811
+ if (executorChanged && this.wakeForExecutorChange(sessionId, executor))
812
+ return true;
590
813
  const decision = decideSupervisedLifecycle({
591
814
  plan: retained.plan,
592
815
  event,
@@ -611,7 +834,11 @@ export class AutopilotCoordinator {
611
834
  return true;
612
835
  case 'monitorProcess': {
613
836
  const processId = decision.action.process.processId;
614
- 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);
615
842
  if (executor)
616
843
  this.persistExecutor(sessionId, {
617
844
  ...executor,
@@ -619,10 +846,11 @@ export class AutopilotCoordinator {
619
846
  ? { ...process, ownership: 'supervisor', state: 'detached-active' }
620
847
  : process),
621
848
  });
622
- this.audit(sessionId, 'autopilot.process-monitoring', {
623
- threadId: decision.action.process.ownerThreadId,
624
- processId,
625
- });
849
+ if (!alreadyMonitoring)
850
+ this.audit(sessionId, 'autopilot.process-monitoring', {
851
+ threadId: decision.action.process.ownerThreadId,
852
+ processId,
853
+ });
626
854
  this.armExecutorRefresh(sessionId, decision.action.pollAfterMs);
627
855
  return true;
628
856
  }
@@ -724,8 +952,22 @@ export class AutopilotCoordinator {
724
952
  persistExecutor(sessionId, executor) {
725
953
  const current = this.deps.store.find(sessionId);
726
954
  if (!current || JSON.stringify(current.executor) === JSON.stringify(executor))
727
- return;
955
+ return false;
728
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));
729
971
  }
730
972
  freshExecutorIdentity(sessionId) {
731
973
  const plan = this.deps.plan(sessionId)?.plan;
@@ -900,6 +1142,8 @@ export class AutopilotCoordinator {
900
1142
  return 'unknown';
901
1143
  }
902
1144
  decision(sessionId, state) {
1145
+ if (state.supervision?.outcome === 'parked')
1146
+ return { kind: 'observe' };
903
1147
  const plan = this.deps.plan(sessionId);
904
1148
  const now = this.deps.now();
905
1149
  return decideAutopilot({
@@ -914,6 +1158,48 @@ export class AutopilotCoordinator {
914
1158
  policy: this.deps.policy,
915
1159
  });
916
1160
  }
1161
+ progressKey(sessionId) {
1162
+ const retained = this.deps.plan(sessionId);
1163
+ const activity = this.deps.activity(sessionId);
1164
+ const interactions = this.deps.pendingInteraction(sessionId)
1165
+ ? [{ id: 'pending', kind: 'session', state: 'pending' }]
1166
+ : [];
1167
+ return semanticProgressKey({
1168
+ plan: {
1169
+ identity: retained?.identity ?? 'none',
1170
+ fingerprint: retained ? fingerprint(retained.plan) : 'none',
1171
+ currentPosition: retained?.plan.currentStepId ?? null,
1172
+ },
1173
+ review: { status: null },
1174
+ checkpoint: {
1175
+ pendingTurnId: this.deps.store.find(sessionId)?.checkpoints?.pendingTurnId ?? null,
1176
+ terminalReviewAccepted: this.deps.store.find(sessionId)?.checkpoints?.terminalReviewAccepted ?? false,
1177
+ },
1178
+ pendingInteractions: interactions,
1179
+ executor: {
1180
+ generation: this.deps.store.find(sessionId)?.executor?.continuationGeneration ?? 0,
1181
+ state: this.deps.store.find(sessionId)?.executor?.outcome ?? null,
1182
+ },
1183
+ ownedProcesses: (this.deps.store.find(sessionId)?.executor?.ownedProcesses ?? []).map((process) => ({
1184
+ id: process.processId,
1185
+ state: process.state,
1186
+ ownerGeneration: this.deps.store.find(sessionId)?.executor?.continuationGeneration ?? 0,
1187
+ })),
1188
+ agentActivity: [
1189
+ ...(activity
1190
+ ? [
1191
+ {
1192
+ agentId: 'root',
1193
+ // Activity time is liveness-only; semantic progress uses the
1194
+ // sequenced state projection, never the observation clock.
1195
+ sequence: 0,
1196
+ state: activity.root.state,
1197
+ },
1198
+ ]
1199
+ : []),
1200
+ ],
1201
+ });
1202
+ }
917
1203
  async reconcile(sessionId, generation) {
918
1204
  try {
919
1205
  const result = await this.deps.reconcile(sessionId);