gestalt-mobile 0.41.2 → 0.41.3

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,7 +16,7 @@ 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-9uZwieND.js"></script>
19
+ <script type="module" crossorigin src="/assets/index-BRly-q56.js"></script>
20
20
  <link rel="stylesheet" crossorigin href="/assets/index-D57mr3B1.css">
21
21
  </head>
22
22
  <body>
@@ -12,7 +12,7 @@ import { join } from 'node:path';
12
12
  import { describe, expect, it, onTestFinished, vi } from 'vitest';
13
13
  import WebSocket from 'ws';
14
14
  import { composeRelayApp } from './composition.js';
15
- import { AUTOPILOT_CONTINUATION_PROMPT } from './features/autopilot/application/policy.js';
15
+ import { autopilotExecutorLaunchPrompt } from './features/autopilot/application/policy.js';
16
16
  import { CodexJsonRpcError } from './platform/codex/json-rpc-client.js';
17
17
  import { SqliteAuthorizationStore } from './platform/auth/sqlite-authorization-store.js';
18
18
  import { authorizationSessionId, authorizedDeviceId, localOwnerId, webAuthnCredentialId, } from './features/auth/domain/identifiers.js';
@@ -497,7 +497,12 @@ describe('production composition', () => {
497
497
  input: [
498
498
  {
499
499
  type: 'text',
500
- text: `${AUTOPILOT_CONTINUATION_PROMPT} Launch task_name l1 for canonical L1; retain the canonical label in status and review output.`,
500
+ text: autopilotExecutorLaunchPrompt({
501
+ canonicalTaskName: 'l1',
502
+ canonicalPosition: 'L1',
503
+ generation: 1,
504
+ taskName: 'l1',
505
+ }),
501
506
  text_elements: [],
502
507
  },
503
508
  ],
@@ -1455,7 +1460,7 @@ describe('production composition', () => {
1455
1460
  .filter((request) => request.method === 'turn/start')).toHaveLength(startsAfterFinal);
1456
1461
  await fixture.app.close();
1457
1462
  });
1458
- it('production attention request requires explicit re-enable and a complete plan transitions to completed', async () => {
1463
+ it('production attention approval resumes Autopilot and a complete plan transitions to completed', async () => {
1459
1464
  const fixture = await createProductionAutopilotFixture();
1460
1465
  const handle = fixture.handles.find((candidate) => candidate.request);
1461
1466
  expect((await fixture.app.inject({
@@ -1474,15 +1479,6 @@ describe('production composition', () => {
1474
1479
  url: `/api/sessions/${fixture.sessionId}/attention/810/resolve`,
1475
1480
  payload: { operationKey: 'resume-attention', action: 'resume' },
1476
1481
  })).statusCode).toBe(202);
1477
- await new Promise((resolve) => setTimeout(resolve, 25));
1478
- expect((await fixture.app.inject(`/api/sessions/${fixture.sessionId}`)).json()).toMatchObject({
1479
- autopilot: { state: 'attentionRequired', enabled: false },
1480
- });
1481
- expect((await fixture.app.inject({
1482
- method: 'PUT',
1483
- url: `/api/sessions/${fixture.sessionId}/autopilot`,
1484
- payload: { enabled: true },
1485
- })).statusCode).toBe(200);
1486
1482
  await vi.waitFor(async () => expect((await fixture.app.inject(`/api/sessions/${fixture.sessionId}`)).json()).toMatchObject({
1487
1483
  autopilot: { enabled: true, state: expect.stringMatching(/monitoring|backoff/) },
1488
1484
  }));
@@ -1770,7 +1766,7 @@ describe('production composition', () => {
1770
1766
  // rejected child continuation; it must never target the dead child.
1771
1767
  expect((starts[3]?.params).threadId).not.toBe('child-1');
1772
1768
  expect((starts[3]?.params).input?.[0]?.text).toContain('Launch task_name l1_g2 for canonical L1');
1773
- expect((starts[3]?.params).input?.[0]?.text).toContain('explicit model selected by the supervisor');
1769
+ expect((starts[3]?.params).input?.[0]?.text).toContain('agent_type org-plan-executor and reasoning_effort high');
1774
1770
  // Capacity recovery is allowed only for the active root control. It
1775
1771
  // recycles that writer but must retain the one durable g2 handoff.
1776
1772
  const rootHandle = fixture.handles.find((candidate) => candidate.requests.some((request) => request.method === 'turn/start' &&
@@ -66,7 +66,7 @@ import { AutopilotCoordinator } from './features/autopilot/application/service.j
66
66
  import { deriveSessionStatus } from './features/sessions/session-status.js';
67
67
  import { AUTOPILOT_CONTINUATION_PROMPT, AUTOPILOT_EXECUTOR_CONTINUATION_PROMPT, autopilotExecutorLaunchPrompt, defaultAutopilotPolicy, } from './features/autopilot/application/policy.js';
68
68
  import { createRelyingPartyConfig } from './config.js';
69
- import { parseOrgPlanAttention, toOrgPlanAttentionAcknowledgement, } from '../shared/contracts/org-plan-attention.js';
69
+ import { parseOrgPlanAttention, parseOrgPlanAttentionToolResponse, toOrgPlanAttentionAcknowledgement, } from '../shared/contracts/org-plan-attention.js';
70
70
  import { autopilotWaitLeaseToolResponse, } from '../shared/contracts/autopilot-wait-lease.js';
71
71
  import { agentCapacityRecoveryToolResponse } from '../shared/contracts/agent-capacity-recovery.js';
72
72
  import { pwaIconUrl } from './features/pwa/register-routes.js';
@@ -400,8 +400,6 @@ export async function composeRelayApp(options) {
400
400
  explicit: session.effectiveSkillSelection.skills,
401
401
  }).skillsConfig;
402
402
  const project = await skillProfiles.readWorkspaceDefault(session.workspacePath);
403
- if (!options.explicitSkillProfile && !project)
404
- return undefined;
405
403
  return compileSkillOverride({
406
404
  discovered: catalog.skills,
407
405
  explicit: options.explicitSkillProfile?.skills,
@@ -1433,6 +1431,10 @@ export async function composeRelayApp(options) {
1433
1431
  const interaction = interactions.find(sessionId, requestId);
1434
1432
  if (!interaction || interaction.kind !== 'orgPlanAttention')
1435
1433
  return { kind: 'noActive' };
1434
+ const decision = parseOrgPlanAttentionToolResponse(response);
1435
+ const attention = parseOrgPlanAttention(interaction.payload);
1436
+ if (!decision || !attention)
1437
+ return { kind: 'staleOperation' };
1436
1438
  const claim = interactions.claimOperation(sessionId, requestId, operationKey);
1437
1439
  if (claim === 'resolved') {
1438
1440
  const resolved = interactions.resolved(sessionId, requestId);
@@ -1448,12 +1450,23 @@ export async function composeRelayApp(options) {
1448
1450
  if (acknowledged) {
1449
1451
  if (!interactions.beginDelivery(sessionId, requestId, operationKey))
1450
1452
  return { kind: 'staleOperation' };
1453
+ if (decision.action === 'resume' && attention.executorReplacement) {
1454
+ const authorization = autopilot.authorizeExecutorReplacement(sessionId, attention.executorReplacement.canonicalTaskName);
1455
+ if (!authorization.accepted) {
1456
+ interactions.retryDelivery(sessionId, requestId, operationKey);
1457
+ return { kind: 'replacementRejected' };
1458
+ }
1459
+ }
1451
1460
  const resolvedAt = new Date().toISOString();
1452
1461
  if (!interactions.settleOperation(sessionId, requestId, operationKey, resolvedAt, 'answered'))
1453
1462
  return { kind: 'staleOperation' };
1454
1463
  const accepted = { kind: 'accepted', resolvedAt };
1455
1464
  idempotency.put(scope, operationKey, 202, JSON.stringify({ kind: 'replayed', resolvedAt }));
1456
1465
  publishAttentionSettlement(sessionId, requestId, resolvedAt, 'answered');
1466
+ if (decision.action === 'disableAutopilot')
1467
+ autopilot.disable(sessionId);
1468
+ else
1469
+ autopilot.enable(sessionId);
1457
1470
  return accepted;
1458
1471
  }
1459
1472
  // A durable capability belongs to the session/thread, not the
@@ -1465,6 +1478,13 @@ export async function composeRelayApp(options) {
1465
1478
  return { kind: 'writerUnavailable' };
1466
1479
  if (!interactions.beginDelivery(sessionId, requestId, operationKey))
1467
1480
  return { kind: 'staleOperation' };
1481
+ if (decision.action === 'resume' && attention.executorReplacement) {
1482
+ const authorization = autopilot.authorizeExecutorReplacement(sessionId, attention.executorReplacement.canonicalTaskName);
1483
+ if (!authorization.accepted) {
1484
+ interactions.retryDelivery(sessionId, requestId, operationKey);
1485
+ return { kind: 'replacementRejected' };
1486
+ }
1487
+ }
1468
1488
  const writer = runtime.attentionWriterState(sessionId, requestId);
1469
1489
  if (writer === 'unavailable') {
1470
1490
  interactions.retryDelivery(sessionId, requestId, operationKey);
@@ -1488,6 +1508,10 @@ export async function composeRelayApp(options) {
1488
1508
  const accepted = { kind: 'accepted', resolvedAt };
1489
1509
  idempotency.put(scope, operationKey, 202, JSON.stringify({ kind: 'replayed', resolvedAt }));
1490
1510
  publishAttentionSettlement(sessionId, requestId, resolvedAt, 'answered');
1511
+ if (decision.action === 'disableAutopilot')
1512
+ autopilot.disable(sessionId);
1513
+ else
1514
+ autopilot.enable(sessionId);
1491
1515
  return accepted;
1492
1516
  })();
1493
1517
  attentionResolutionOperations.set(key, operation);
@@ -3,15 +3,15 @@
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 = 'v11';
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. Spawn exactly one executor per L1 and pass the exact literal task_name l<a>: l1 for L1, l2 for L2, and so on. Never create an L2-specific task name or append a title, role, nickname, plan name, or generated label. A validated DONE L2 and an accepted L1 are mandatory answer boundaries. The corresponding gestalt_org_plan_checkpoint call must be the last tool call of that root turn: immediately emit exactly one boundary final and end the turn, without followup_task, review, executor launch, or later milestone work. Roll up commentary, files, verification, and commands since the previous boundary into that one answer; all later milestone activity belongs to a new root turn. On this later Autopilot turn, resume the same executor after an L2 boundary or launch the next executor after an accepted-L1 boundary. Treat a status question as an interruption: answer briefly, then perform the applicable continuation in the same turn. Before sending any blocker response or yielding because progress cannot continue safely, call gestalt_org_plan_attention for the matching decision-table blocker; never rely on blocker prose as the signal. Do not call it for routine progress or a recoverable failure. Before yielding for known long work, call gestalt_autopilot_wait_lease version 2 with relevant observable wake conditions and a bounded maxWaitMs; it is one episode, so reassess before registering another in a later turn. Yield only when its response contains accepted:true; accepted:false means no continuation was registered and requires same-turn supervision. For GitHub PR CI, start an owned gh pr checks --watch process and lease processExited plus processResultAvailable while it runs. When the Autopilot probe is active, register the compatible wait lease, declare genuine attention, or immediately do actionable work; never acknowledge waiting in prose. Do not send a status-only response.';
6
+ export const AUTOPILOT_PROMPT_VERSION = 'v13';
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. Spawn exactly one executor per L1 and pass the exact literal task_name l<a>: l1 for L1, l2 for L2, and so on. Never create an L2-specific task name or append a title, role, nickname, plan name, or generated label. A validated DONE L2 and an accepted L1 are mandatory answer boundaries. The corresponding gestalt_org_plan_checkpoint call must be the last tool call of that root turn: immediately emit exactly one boundary final and end the turn, without followup_task, review, executor launch, or later milestone work. An accepted L1 always ends the root turn with a chat answer: never continue into the next L1 or emit post-acceptance documentation as commentary. Roll up commentary, files, verification, and commands since the previous boundary into that one answer; all later milestone activity belongs to a new root turn. On this later Autopilot turn, resume the same executor after an L2 boundary or launch exactly one fresh canonical executor for the next L1 after an accepted-L1 boundary. Treat a status question as an interruption: answer briefly, then perform the applicable continuation in the same turn. Before sending any blocker response or yielding because progress cannot continue safely, call gestalt_org_plan_attention for the matching decision-table blocker; never rely on blocker prose as the signal. When explicit human permission is required for a physical executor replacement, include executorReplacement with the exact canonicalTaskName; Mobile alone computes and persists the physical generation. Do not call attention for routine progress or a recoverable failure. Before yielding for known long work, call gestalt_autopilot_wait_lease version 2 with relevant observable wake conditions and a bounded maxWaitMs; it is one episode, so reassess before registering another in a later turn. Yield only when its response contains accepted:true; accepted:false means no continuation was registered and requires same-turn supervision. For GitHub PR CI, start an owned gh pr checks --watch process and lease processExited plus processResultAvailable while it runs. When the Autopilot probe is active, register the compatible wait lease, 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 and take the next legal L2 action. Whenever an L2 reaches DONE, return its concise structured evidence and end the executor turn so the root can publish that mandatory answer boundary. Also report at the L1 review boundary or through structured attention.';
9
9
  /** Builds a physical launch instruction while keeping durable L1 identity canonical. */
10
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.`;
11
+ const launch = `Launch task_name ${identity.taskName} for canonical ${identity.canonicalPosition} with agent_type org-plan-executor and reasoning_effort high; the profile selects gpt-5.6-terra by default, so do not override its model. Retain the canonical label in status and review output.`;
12
12
  if (identity.generation === 1)
13
13
  return `${AUTOPILOT_CONTINUATION_PROMPT} ${launch}`;
14
- return `${AUTOPILOT_CONTINUATION_PROMPT} ${launch} This exact replacement generation ${identity.generation} is durably authorized by Mobile; do not infer another generation from this prompt or the roster; 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.`;
14
+ return `${AUTOPILOT_CONTINUATION_PROMPT} ${launch} This exact replacement generation ${identity.generation} is durably authorized by Mobile; do not infer another generation from this prompt or the roster; 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 the authorized ${identity.taskName}. Transfer sole ${identity.canonicalPosition} ownership and continue from its durable Org Plan and worktree state.`;
15
15
  }
16
16
  export const defaultAutopilotPolicy = Object.freeze({
17
17
  quiescenceMs: 1_000,
@@ -321,9 +321,9 @@ export class AutopilotCoordinator {
321
321
  consecutiveNoProgress: 0,
322
322
  nextEvaluationAt: null,
323
323
  stopReason: null,
324
- executor: undefined,
324
+ executor: !replacing && prior.state === 'attentionRequired' ? prior.executor : undefined,
325
325
  blocking: undefined,
326
- supervision: recoverSafetyPause(consumeObsoleteWait(replacing
326
+ supervision: recoverSafetyPause(consumeObsoleteWait(replacing || prior.supervision?.outcome === 'attentionRequired'
327
327
  ? startSupervisionProtocol(this.progressKey(sessionId))
328
328
  : (prior.supervision ?? startSupervisionProtocol(this.progressKey(sessionId))), this.progressKey(sessionId)), this.progressKey(sessionId)),
329
329
  updatedAt: now,
@@ -332,6 +332,64 @@ export class AutopilotCoordinator {
332
332
  this.evaluate(sessionId);
333
333
  return this.snapshot(sessionId);
334
334
  }
335
+ /** Converts one typed human permission into one exact durable physical replacement. */
336
+ authorizeExecutorReplacement(sessionId, canonicalTaskName) {
337
+ const state = this.deps.store.find(sessionId);
338
+ const retained = this.deps.plan(sessionId);
339
+ if (!state ||
340
+ state.state !== 'attentionRequired' ||
341
+ state.blocking?.reason !== 'permissionRequired')
342
+ return { accepted: false, reason: 'attentionRequired' };
343
+ if (!retained ||
344
+ state.planIdentity !== retained.identity ||
345
+ state.planFingerprint !== fingerprint(retained.plan))
346
+ return { accepted: false, reason: 'planMismatch' };
347
+ const executor = state.executor;
348
+ if (!executor || executor.canonicalTaskName !== canonicalTaskName || executor.l1State !== 'WIP')
349
+ return { accepted: false, reason: 'executorNotAssigned' };
350
+ if (executor.replacement && this.validReplacement(sessionId, executor.replacement))
351
+ return { accepted: true, identity: executor.replacement };
352
+ const activity = this.deps.activity(sessionId);
353
+ if (activity?.confidence !== 'fresh' || this.executorTurnActive(executor, activity))
354
+ return { accepted: false, reason: 'executorStillActive' };
355
+ const previous = executor.assignment ?? executorAssignment(executor);
356
+ const identity = executorIdentity(canonicalTaskName, previous.generation + 1);
357
+ const now = this.deps.now();
358
+ this.persist({
359
+ ...state,
360
+ executor: {
361
+ ...executor,
362
+ outcome: 'failed',
363
+ assignment: { ...previous, state: 'unavailable' },
364
+ replacement: {
365
+ ...identity,
366
+ planIdentity: retained.identity,
367
+ planFingerprint: fingerprint(retained.plan),
368
+ previous: { ...previous, state: 'unavailable' },
369
+ reason: 'humanPermissionGranted',
370
+ evidence: 'typed-attention-approval',
371
+ },
372
+ commands: executor.commands?.map((command) => ['scheduled', 'issued'].includes(command.status)
373
+ ? { ...command, status: 'superseded', updatedAt: now }
374
+ : command),
375
+ },
376
+ updatedAt: now,
377
+ }, undefined, [
378
+ {
379
+ sessionId,
380
+ type: 'autopilot.executor-replacement-authorized',
381
+ payload: {
382
+ canonicalTaskName,
383
+ previousTaskName: previous.taskName,
384
+ taskName: identity.taskName,
385
+ generation: identity.generation,
386
+ evidence: 'typed-attention-approval',
387
+ },
388
+ occurredAt: now,
389
+ },
390
+ ]);
391
+ return { accepted: true, identity };
392
+ }
335
393
  disable(sessionId) {
336
394
  const now = this.deps.now();
337
395
  const prior = this.deps.store.find(sessionId) ?? disabledAutopilot(sessionId, now);
@@ -421,6 +479,7 @@ export class AutopilotCoordinator {
421
479
  if (this.deps.executorController &&
422
480
  plan &&
423
481
  !this.requiresRootBoundary(prior, plan) &&
482
+ !this.executorDiagnosisRequired(prior) &&
424
483
  this.currentExecutor(sessionId, plan, prior.consecutiveNoProgress, prior.executor)) {
425
484
  this.enqueue(sessionId, async () => {
426
485
  await this.enforceSupervisedLifecycle(sessionId, 'stateChanged');
@@ -885,15 +944,27 @@ export class AutopilotCoordinator {
885
944
  const cancelled = this.cancelScheduledControl(prior, now);
886
945
  const subagentsWorking = activity.aggregateSubagents === 'working' ||
887
946
  activity.aggregateSubagents === 'awaitingAgent';
947
+ const supervision = subagentsWorking &&
948
+ Boolean(this.deps.session(sessionId)?.activeTurnId) &&
949
+ prior.supervision?.outcome === 'probeRequired'
950
+ ? reportProbe(prior.supervision, {
951
+ id: `executor-actionable:${createHash('sha256')
952
+ .update(this.progressKey(sessionId))
953
+ .digest('hex')}`,
954
+ kind: 'actionable',
955
+ })
956
+ : prior.supervision;
888
957
  if (cancelled ||
889
958
  prior.state !== 'monitoring' ||
890
959
  prior.nextEvaluationAt ||
891
- (subagentsWorking && prior.consecutiveNoProgress > 0))
960
+ (subagentsWorking && prior.consecutiveNoProgress > 0) ||
961
+ supervision !== prior.supervision)
892
962
  this.persist({
893
963
  ...prior,
894
964
  state: 'monitoring',
895
965
  ...(cancelled ? { generation: prior.generation + 1, lastControlId: null } : {}),
896
966
  ...(subagentsWorking ? { consecutiveNoProgress: 0 } : {}),
967
+ ...(supervision ? { supervision } : {}),
897
968
  nextEvaluationAt: null,
898
969
  updatedAt: now,
899
970
  }, cancelled);
@@ -1033,6 +1104,7 @@ export class AutopilotCoordinator {
1033
1104
  retained &&
1034
1105
  !current.executor?.replacement &&
1035
1106
  !this.requiresRootBoundary(current, retained.plan) &&
1107
+ !this.executorDiagnosisRequired(current) &&
1036
1108
  this.currentExecutor(sessionId, retained.plan, current.consecutiveNoProgress, current.executor)) {
1037
1109
  const now = this.deps.now();
1038
1110
  const cancelled = this.cancelScheduledControl(current, now);
@@ -1047,7 +1119,12 @@ export class AutopilotCoordinator {
1047
1119
  await this.enforceSupervisedLifecycle(sessionId, 'stateChanged');
1048
1120
  return;
1049
1121
  }
1050
- const protocol = recordAutomaticContinuation(current.supervision ?? startSupervisionProtocol(this.progressKey(sessionId)), this.progressKey(sessionId));
1122
+ // Direct executor continuations account for their own accepted attempts.
1123
+ // Once that bounded loop requests diagnosis, preserve the probe state for
1124
+ // the root instead of counting the root control as another blind retry.
1125
+ const protocol = this.executorDiagnosisRequired(current)
1126
+ ? current.supervision
1127
+ : recordAutomaticContinuation(current.supervision ?? startSupervisionProtocol(this.progressKey(sessionId)), this.progressKey(sessionId));
1051
1128
  if (protocol.outcome === 'probeRequired') {
1052
1129
  this.persist({ ...current, supervision: protocol, updatedAt: this.deps.now() }, undefined, [
1053
1130
  {
@@ -1224,6 +1301,8 @@ export class AutopilotCoordinator {
1224
1301
  case 'reinspect':
1225
1302
  return false;
1226
1303
  case 'resumeExecutor':
1304
+ if (this.executorDiagnosisRequired(this.deps.store.find(sessionId) ?? state))
1305
+ return false;
1227
1306
  this.armExecutorContinuation(sessionId, decision.action.delayMs, decision.action.threadId, decision.action.generation, { kind: 'partial' });
1228
1307
  return true;
1229
1308
  case 'monitorProcess': {
@@ -1258,7 +1337,7 @@ export class AutopilotCoordinator {
1258
1337
  resultArtifact: processAction.resultArtifact,
1259
1338
  });
1260
1339
  if (consumed && executor)
1261
- this.armExecutorContinuation(sessionId, this.deps.policy.executorContinuationBaseMs, executor.threadId, executor.continuationGeneration + 1, {
1340
+ this.armExecutorContinuation(sessionId, this.deps.policy.executorContinuationBaseMs, executor.threadId, executor.continuationGeneration + executor.continuationCount + 1, {
1262
1341
  kind: 'processExited',
1263
1342
  processId,
1264
1343
  resultArtifact: processAction.resultArtifact,
@@ -1279,7 +1358,7 @@ export class AutopilotCoordinator {
1279
1358
  reason: 'resourceBudget',
1280
1359
  });
1281
1360
  if (terminated && executor)
1282
- this.armExecutorContinuation(sessionId, this.deps.policy.executorContinuationBaseMs, executor.threadId, executor.continuationGeneration + 1, { kind: 'processResourceLimit', processId });
1361
+ this.armExecutorContinuation(sessionId, this.deps.policy.executorContinuationBaseMs, executor.threadId, executor.continuationGeneration + executor.continuationCount + 1, { kind: 'processResourceLimit', processId });
1283
1362
  else
1284
1363
  this.armExecutorRefresh(sessionId, this.deps.policy.processPollMs);
1285
1364
  return true;
@@ -1366,7 +1445,9 @@ export class AutopilotCoordinator {
1366
1445
  ownedProcesses: refreshPersistedProcesses(persisted?.canonicalPosition === canonicalPosition ? persisted.ownedProcesses : [], child.ownedProcesses ?? [], this.deps.now()),
1367
1446
  outcome: outcome.outcome,
1368
1447
  ...(outcome.blocking ? { blocking: outcome.blocking } : {}),
1369
- continuationGeneration: Math.max(child.continuationGeneration ?? 1, persisted?.canonicalPosition === canonicalPosition ? persisted.continuationGeneration : 1),
1448
+ // The roster value and assignment identify the physical l<a>_gN slot.
1449
+ // Logical follow-up attempts are tracked separately by continuationCount.
1450
+ continuationGeneration: replacementChild || !assigned ? (child.continuationGeneration ?? 1) : assigned.generation,
1370
1451
  continuationCount: persisted?.canonicalPosition === canonicalPosition
1371
1452
  ? persisted.continuationCount
1372
1453
  : continuationCount,
@@ -1892,23 +1973,24 @@ export class AutopilotCoordinator {
1892
1973
  const armedChild = this.deps
1893
1974
  .activity(sessionId)
1894
1975
  ?.subagents.find((child) => (child.threadId ?? child.id) === threadId);
1895
- const fence = state && retained
1976
+ const commandExecutor = state?.executor;
1977
+ const fence = state && retained && commandExecutor
1896
1978
  ? {
1897
1979
  sessionGeneration: state.generation,
1898
1980
  planIdentity: retained.identity,
1899
1981
  planFingerprint: fingerprint(retained.plan),
1900
1982
  canonicalPosition: armedChild?.canonicalPosition ?? state.executor?.canonicalPosition ?? null,
1901
1983
  executorThreadId: threadId,
1902
- executorGeneration: generation - 1,
1984
+ // The roster generation identifies the physical l<a>_gN slot.
1985
+ // A canonical executor can accept many logical follow-up turns, so
1986
+ // never derive this ownership fence from the follow-up sequence.
1987
+ executorGeneration: commandExecutor.assignment?.generation ?? commandExecutor.continuationGeneration,
1903
1988
  supervisionOutcome: state.supervision?.outcome ?? null,
1904
1989
  checkpoint: JSON.stringify(state.checkpoints ?? null),
1905
1990
  boundaryState: state.state,
1906
1991
  }
1907
1992
  : null;
1908
- if (!fence || this.deps.session(sessionId)?.activeTurnId)
1909
- return;
1910
- const commandExecutor = state?.executor;
1911
- if (!commandExecutor)
1993
+ if (!commandExecutor || !fence || this.deps.session(sessionId)?.activeTurnId)
1912
1994
  return;
1913
1995
  const requested = this.executorCommand(fence, commandExecutor, generation, trigger);
1914
1996
  const command = this.scheduleExecutorCommand(sessionId, requested);
@@ -1974,17 +2056,19 @@ export class AutopilotCoordinator {
1974
2056
  trigger: trigger.kind,
1975
2057
  });
1976
2058
  const latest = this.deps.store.find(sessionId);
1977
- if (latest?.requestedEnabled)
2059
+ if (latest?.requestedEnabled) {
2060
+ const progressKey = this.progressKey(sessionId);
2061
+ const supervision = recordAutomaticContinuation(latest.supervision ?? startSupervisionProtocol(progressKey), progressKey);
1978
2062
  this.persist({
1979
2063
  ...latest,
1980
2064
  state: 'monitoring',
2065
+ supervision,
1981
2066
  consecutiveNoProgress: latest.consecutiveNoProgress + 1,
1982
2067
  ...(latest.executor
1983
2068
  ? {
1984
2069
  executor: {
1985
2070
  ...latest.executor,
1986
2071
  outcome: 'partial',
1987
- continuationGeneration: generation,
1988
2072
  continuationCount: latest.executor.continuationCount + 1,
1989
2073
  lastActivityAt: this.deps.now(),
1990
2074
  },
@@ -1993,6 +2077,7 @@ export class AutopilotCoordinator {
1993
2077
  nextEvaluationAt: null,
1994
2078
  updatedAt: this.deps.now(),
1995
2079
  });
2080
+ }
1996
2081
  }
1997
2082
  catch (error) {
1998
2083
  // Only an explicit app-server rejection is safe to retry. A lost
@@ -2085,6 +2170,9 @@ export class AutopilotCoordinator {
2085
2170
  child.state === 'awaitingAgent' ||
2086
2171
  child.ownedProcesses?.some((process) => process.state === 'running' || process.state === 'detached-active')));
2087
2172
  }
2173
+ executorDiagnosisRequired(state) {
2174
+ return state.supervision?.outcome === 'probeRequired';
2175
+ }
2088
2176
  executorTurnActive(executor, activity) {
2089
2177
  if (activity?.confidence !== 'fresh')
2090
2178
  return false;
@@ -179,7 +179,8 @@ export function parsePersistedSupervisedLifecycle(value) {
179
179
  const replacementPrevious = replacementPreviousValue
180
180
  ? parseExecutorAssignment(replacementPreviousValue)
181
181
  : undefined;
182
- const replacementReason = replacementValue && stringValue(replacementValue.reason, ['explicitExecutorRejection']);
182
+ const replacementReason = replacementValue &&
183
+ stringValue(replacementValue.reason, ['explicitExecutorRejection', 'humanPermissionGranted']);
183
184
  const replacementEvidence = replacementValue && boundedText(replacementValue.evidence);
184
185
  if (!canonicalPosition ||
185
186
  !canonicalTaskName ||
@@ -200,7 +201,8 @@ export function parsePersistedSupervisedLifecycle(value) {
200
201
  !replacementPlanIdentity ||
201
202
  !replacementPlanFingerprint ||
202
203
  (replacementValue.previous !== undefined && !replacementPrevious) ||
203
- (replacementReason !== undefined && replacementReason !== 'explicitExecutorRejection') ||
204
+ (replacementReason !== undefined &&
205
+ !['explicitExecutorRejection', 'humanPermissionGranted'].includes(replacementReason)) ||
204
206
  (replacementValue.evidence !== undefined && !replacementEvidence))))
205
207
  return undefined;
206
208
  if (replacementValue) {
@@ -304,7 +306,10 @@ export function parsePersistedSupervisedLifecycle(value) {
304
306
  ownedProcesses,
305
307
  outcome,
306
308
  ...(executorBlocking ? { blocking: executorBlocking } : {}),
307
- continuationGeneration,
309
+ // Earlier releases incremented this value after every follow-up, even
310
+ // though the roster derives it from the immutable physical task name. A
311
+ // durable assignment is authoritative during reconciliation.
312
+ continuationGeneration: assignment?.generation ?? continuationGeneration,
308
313
  continuationCount,
309
314
  ...(resumeFailures ? { resumeFailures } : {}),
310
315
  ...(assignment ? { assignment } : {}),
@@ -537,7 +542,7 @@ export function decideSupervisedLifecycle(input) {
537
542
  action: {
538
543
  kind: 'resumeExecutor',
539
544
  threadId: input.executor.threadId,
540
- generation: input.executor.continuationGeneration + 1,
545
+ generation: input.executor.continuationGeneration + input.executor.continuationCount + 1,
541
546
  delayMs,
542
547
  },
543
548
  };
@@ -34,9 +34,11 @@ export function registerResolveOrgPlanAttention(app, deps) {
34
34
  ? 'ATTENTION_OPERATION_STALE'
35
35
  : outcome.kind === 'legacyUnsupported'
36
36
  ? 'ATTENTION_LEGACY_UNSUPPORTED'
37
- : outcome.kind === 'writerCleared'
38
- ? 'ATTENTION_WRITER_CLEARED'
39
- : 'ATTENTION_WRITER_UNAVAILABLE';
37
+ : outcome.kind === 'replacementRejected'
38
+ ? 'EXECUTOR_REPLACEMENT_NOT_AUTHORIZED'
39
+ : outcome.kind === 'writerCleared'
40
+ ? 'ATTENTION_WRITER_CLEARED'
41
+ : 'ATTENTION_WRITER_UNAVAILABLE';
40
42
  return reply.code(outcome.kind === 'noActive' ? 404 : 409).send({ code });
41
43
  });
42
44
  }
@@ -40,7 +40,8 @@ export function registerListAvailableSkills(app, deps) {
40
40
  return {
41
41
  source: project ? 'project' : 'native',
42
42
  errors: discovered.errors.map(({ message }) => ({ message })),
43
- skills: [...skills]
43
+ skills: skills
44
+ .filter((skill) => !isAlwaysAdvertisedSkill(skill))
44
45
  .sort((left, right) => left.path.localeCompare(right.path))
45
46
  .map((skill) => ({
46
47
  name: skill.name,
@@ -56,7 +56,10 @@ export const availableSkillSchema = z
56
56
  .strict();
57
57
  /** Gestalt workflow skills are session infrastructure, not optional profile entries. */
58
58
  export function isAlwaysAdvertisedSkill(skill) {
59
- return skill.name.startsWith('gestalt:');
59
+ return isGestaltSkillName(skill.name);
60
+ }
61
+ export function isGestaltSkillName(name) {
62
+ return name.startsWith('gestalt:');
60
63
  }
61
64
  const profileDocumentSchema = z
62
65
  .object({
@@ -195,8 +198,6 @@ export function reconcileSkillSelectionSnapshot(discovered, selection) {
195
198
  */
196
199
  export function compileSkillOverride(input) {
197
200
  const effective = selectEffectiveSkillSelection(input);
198
- if (effective.selection === undefined)
199
- return { source: 'native', skillsConfig: undefined, warnings: [] };
200
201
  const reconciled = reconcileSkillSelectionSnapshot(input.discovered, effective.selection);
201
202
  return {
202
203
  source: effective.source,
@@ -211,7 +212,10 @@ export function createSkillProfile(input) {
211
212
  return {
212
213
  version: 1,
213
214
  name: normalizeSkillProfileName(input.name),
214
- skills: createSkillSelection(input.skills),
215
+ // Gestalt skills are mandatory thread infrastructure. Legacy profiles are
216
+ // normalized on read so they cannot pin a stale plugin-cache version or
217
+ // expose a meaningless enable/disable control in the editor.
218
+ skills: createSkillSelection(input.skills.filter((skill) => !isGestaltSkillName(skill.name))),
215
219
  };
216
220
  }
217
221
  /** Parse the version-1 YAML document without performing filesystem access. */
@@ -3,16 +3,6 @@
3
3
  * Designed by Denis Roio <jaromil@dyne.org>
4
4
  * SPDX-License-Identifier: AGPL-3.0-or-later
5
5
  */
6
- export function profileAppServerCommand(skillsConfig) {
7
- const base = { command: 'codex', args: ['app-server', '--stdio'] };
8
- return skillsConfig === undefined
9
- ? base
10
- : {
11
- ...base,
12
- args: [
13
- ...base.args,
14
- '--config',
15
- `skills.config = [${skillsConfig.map((entry) => `{ path = ${JSON.stringify(entry.path)}, enabled = ${entry.enabled} }`).join(', ')}]`,
16
- ],
17
- };
6
+ export function profileAppServerCommand() {
7
+ return { command: 'codex', args: ['app-server', '--stdio'] };
18
8
  }
@@ -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.skillsConfig);
10
+ const launch = profileAppServerCommand();
11
11
  const child = spawn(launch.command, launch.args, {
12
12
  cwd: input.cwd,
13
13
  env: codexChildEnvironment(input.environment),
@@ -17,10 +17,15 @@ import { threadPlanName } from './thread-plan-name.js';
17
17
  import { WriterAcquisitionError, } from '../../features/sessions/application/writer-acquisition.js';
18
18
  import { isCodexThreadWriterBusy, isMissingCodexThreadRollout } from './json-rpc-client.js';
19
19
  import { resolvedServerRequestId } from './server-request.js';
20
+ /** Sticky thread config keeps skill paths out of the app-server process argv. */
21
+ export function threadSkillConfig(skillsConfig) {
22
+ return skillsConfig === undefined ? {} : { config: { skills: { config: skillsConfig } } };
23
+ }
20
24
  /** One private owner for every resource acquired for a live Codex child. */
21
25
  class SessionResource {
22
26
  sessionId;
23
27
  process;
28
+ skillsConfig;
24
29
  planStatusLease;
25
30
  planMeasurementToken;
26
31
  unregister;
@@ -39,9 +44,10 @@ class SessionResource {
39
44
  completedProcessActions = new Map();
40
45
  turnThreads = new Map();
41
46
  childThreads = new Set();
42
- constructor(sessionId, process, planStatusLease, planMeasurementToken, unregister, onDisposed) {
47
+ constructor(sessionId, process, skillsConfig, planStatusLease, planMeasurementToken, unregister, onDisposed) {
43
48
  this.sessionId = sessionId;
44
49
  this.process = process;
50
+ this.skillsConfig = skillsConfig;
45
51
  this.planStatusLease = planStatusLease;
46
52
  this.planMeasurementToken = planMeasurementToken;
47
53
  this.unregister = unregister;
@@ -117,7 +123,7 @@ export class CodexSessionRuntime {
117
123
  clientInfo: { name: 'gestalt-mobile', version: '0.1.0' },
118
124
  capabilities: { experimentalApi: true },
119
125
  });
120
- const startedThreadId = await this.startThread(resource.process, session);
126
+ const startedThreadId = await this.startThread(resource.process, session, resource.skillsConfig);
121
127
  resource.threadId = startedThreadId;
122
128
  this.sessions.set(session.id, resource);
123
129
  await this.writePendingThreadName(session.id);
@@ -671,6 +677,7 @@ export class CodexSessionRuntime {
671
677
  await resource.process.rpc.request('thread/resume', {
672
678
  threadId: session.threadId,
673
679
  cwd: session.workspacePath,
680
+ ...threadSkillConfig(resource.skillsConfig),
674
681
  ...(session.executionPolicy?.approvalPolicy
675
682
  ? { approvalPolicy: session.executionPolicy.approvalPolicy }
676
683
  : {}),
@@ -692,7 +699,7 @@ export class CodexSessionRuntime {
692
699
  catch (error) {
693
700
  if (!canRebindMissingRollout(session, error))
694
701
  throw error;
695
- const replacementThreadId = await this.startThread(resource.process, session);
702
+ const replacementThreadId = await this.startThread(resource.process, session, resource.skillsConfig);
696
703
  result = rebindMissingRollout(session, error, replacementThreadId, now);
697
704
  }
698
705
  resource.threadId = result.session.threadId;
@@ -760,12 +767,13 @@ export class CodexSessionRuntime {
760
767
  reject(new Error('CODEX_SERVER_REQUEST_UNSUPPORTED'));
761
768
  });
762
769
  }
763
- async startThread(process, session) {
764
- return decodeThreadStart(await process.rpc.request('thread/start', this.threadStartParams(session)));
770
+ async startThread(process, session, skillsConfig) {
771
+ return decodeThreadStart(await process.rpc.request('thread/start', this.threadStartParams(session, skillsConfig)));
765
772
  }
766
- threadStartParams(session) {
773
+ threadStartParams(session, skillsConfig) {
767
774
  return {
768
775
  cwd: session.workspacePath,
776
+ ...threadSkillConfig(skillsConfig),
769
777
  approvalPolicy: session.executionPolicy?.approvalPolicy ?? 'on-request',
770
778
  dynamicTools: [
771
779
  gestaltQuizDynamicTool,
@@ -792,10 +800,10 @@ export class CodexSessionRuntime {
792
800
  : undefined;
793
801
  try {
794
802
  const token = randomUUID();
803
+ const skillsConfig = await this.resolveSkills?.(session);
795
804
  const process = this.launch({
796
805
  profile: session.profile,
797
806
  cwd: session.workspacePath,
798
- skillsConfig: await this.resolveSkills?.(session),
799
807
  ...(lease || this.planMeasurementBaseUrl
800
808
  ? {
801
809
  environment: {
@@ -812,7 +820,7 @@ export class CodexSessionRuntime {
812
820
  }
813
821
  : {}),
814
822
  });
815
- const resource = new SessionResource(session.id, process, lease, token, [], () => {
823
+ const resource = new SessionResource(session.id, process, skillsConfig, lease, token, [], () => {
816
824
  if (this.sessions.get(session.id) === resource)
817
825
  this.sessions.delete(session.id);
818
826
  });