gestalt-mobile 0.41.1 → 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.
@@ -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
  }
@@ -3,8 +3,13 @@
3
3
  * Designed by Denis Roio <jaromil@dyne.org>
4
4
  * SPDX-License-Identifier: AGPL-3.0-or-later
5
5
  */
6
+ /** Terminal roster entries remain visible and are not active implementation writers. */
7
+ export function hasActiveL1Writer(snapshot, position) {
8
+ return snapshot.subagents.some((child) => child.canonicalPosition === position &&
9
+ ['working', 'awaitingAgent', 'awaitingHuman'].includes(child.state));
10
+ }
6
11
  /** Expands a host-owned compact boundary signal from authoritative plan state. */
7
- export function resolveOrgPlanCheckpointSignal(kind, plan, planIdentity) {
12
+ export function resolveOrgPlanCheckpointSignal(kind, plan, planIdentity, publicationReason = null) {
8
13
  if (kind === 'terminalReviewAccepted')
9
14
  return {
10
15
  version: 1,
@@ -12,8 +17,16 @@ export function resolveOrgPlanCheckpointSignal(kind, plan, planIdentity) {
12
17
  planIdentity,
13
18
  verdict: 'ACCEPT',
14
19
  };
15
- const selected = plan.steps.find((step) => step.id === plan.currentStepId) ??
16
- [...plan.steps].reverse().find((step) => step.state === 'DONE' || step.state === 'WIP');
20
+ const reviewedId = publicationReason?.match(/^review:([^:]{1,128}):REVIEWED$/)?.[1];
21
+ const selected = kind === 'l1Accepted'
22
+ ? ((reviewedId
23
+ ? plan.steps.find((step) => step.id === reviewedId && step.state === 'DONE' && step.reviewStatus === 'REVIEWED')
24
+ : undefined) ??
25
+ [...plan.steps]
26
+ .reverse()
27
+ .find((step) => step.state === 'DONE' && step.reviewStatus === 'REVIEWED'))
28
+ : (plan.steps.find((step) => step.id === plan.currentStepId) ??
29
+ [...plan.steps].reverse().find((step) => step.state === 'DONE' || step.state === 'WIP'));
17
30
  if (!selected)
18
31
  return null;
19
32
  const l1Position = `L${plan.steps.indexOf(selected) + 1}`;
@@ -44,25 +57,80 @@ export function resolveOrgPlanCheckpointSignal(kind, plan, planIdentity) {
44
57
  };
45
58
  }
46
59
  export function validOrgPlanCheckpoint(input) {
60
+ return validateOrgPlanCheckpoint(input).valid;
61
+ }
62
+ export function validateOrgPlanCheckpoint(input) {
47
63
  const { checkpoint, plan, planIdentity, rootOwned } = input;
48
- if (!rootOwned || checkpoint.planIdentity !== planIdentity)
49
- return false;
64
+ if (!rootOwned)
65
+ return { valid: false, reasonCode: 'rootNotOwner' };
66
+ if (checkpoint.planIdentity !== planIdentity)
67
+ return { valid: false, reasonCode: 'planIdentityMismatch' };
50
68
  if (checkpoint.kind === 'terminalReviewAccepted')
51
- return plan.steps.every((step) => step.state === 'DONE' && step.reviewStatus === 'REVIEWED');
69
+ return plan.steps.every((step) => step.state === 'DONE' && step.reviewStatus === 'REVIEWED')
70
+ ? { valid: true }
71
+ : { valid: false, reasonCode: 'terminalReviewIncomplete' };
52
72
  const l1 = plan.steps.find((step) => step.id === checkpoint.l1Id);
53
73
  if (!l1)
54
- return false;
74
+ return { valid: false, reasonCode: 'l1NotFound' };
55
75
  const position = `L${plan.steps.indexOf(l1) + 1}`;
56
76
  if (checkpoint.kind === 'l2Completed') {
57
77
  const l2 = l1.children.find((step) => step.id === checkpoint.l2Id);
58
78
  if (!l2)
59
- return false;
60
- return (checkpoint.position === `${position}.${l1.children.indexOf(l2) + 1}` &&
61
- l2.state === 'DONE' &&
62
- l1.reviewStatus === 'UNREVIEWED');
79
+ return { valid: false, reasonCode: 'l2NotFound' };
80
+ const expectedPosition = `${position}.${l1.children.indexOf(l2) + 1}`;
81
+ if (checkpoint.position !== expectedPosition)
82
+ return {
83
+ valid: false,
84
+ reasonCode: 'checkpointPositionMismatch',
85
+ expected: { position: expectedPosition },
86
+ observed: { position: checkpoint.position },
87
+ };
88
+ if (l2.state !== 'DONE')
89
+ return {
90
+ valid: false,
91
+ reasonCode: 'l2NotDone',
92
+ expected: { l2State: 'DONE' },
93
+ observed: { l2State: l2.state },
94
+ };
95
+ return l1.reviewStatus === 'UNREVIEWED'
96
+ ? { valid: true }
97
+ : { valid: false, reasonCode: 'l1AlreadyReviewed' };
63
98
  }
64
- return (checkpoint.position === position &&
65
- l1.state === 'DONE' &&
66
- l1.reviewStatus === 'REVIEWED' &&
67
- !input.hasActiveL1Writer(position));
99
+ if (checkpoint.position !== position)
100
+ return {
101
+ valid: false,
102
+ reasonCode: 'checkpointPositionMismatch',
103
+ expected: { position },
104
+ observed: { position: checkpoint.position },
105
+ };
106
+ if (l1.state !== 'DONE')
107
+ return {
108
+ valid: false,
109
+ reasonCode: 'l1NotDone',
110
+ expected: { l1State: 'DONE' },
111
+ observed: { l1State: l1.state },
112
+ };
113
+ if (l1.reviewStatus !== 'REVIEWED')
114
+ return {
115
+ valid: false,
116
+ reasonCode: 'l1NotReviewed',
117
+ expected: { reviewStatus: 'REVIEWED' },
118
+ observed: { reviewStatus: l1.reviewStatus ?? null },
119
+ };
120
+ const incompleteChild = l1.children.find((child) => child.state !== 'DONE');
121
+ if (incompleteChild)
122
+ return {
123
+ valid: false,
124
+ reasonCode: 'l1ChildrenIncomplete',
125
+ expected: { childrenDone: true },
126
+ observed: { childrenDone: false, childState: incompleteChild.state },
127
+ };
128
+ if (input.hasActiveL1Writer(position))
129
+ return {
130
+ valid: false,
131
+ reasonCode: 'executorStillActive',
132
+ expected: { executorTerminal: true },
133
+ observed: { executorTerminal: false },
134
+ };
135
+ return { valid: true };
68
136
  }
@@ -8,7 +8,11 @@ export class SupervisedPlanRegistry {
8
8
  plans = new Map();
9
9
  accept(sessionId, update) {
10
10
  if (update.kind === 'updated')
11
- this.plans.set(sessionId, { plan: update.plan, identity: update.identity });
11
+ this.plans.set(sessionId, {
12
+ plan: update.plan,
13
+ identity: update.identity,
14
+ publicationReason: update.reason,
15
+ });
12
16
  }
13
17
  find(sessionId) {
14
18
  return this.plans.get(sessionId)?.plan ?? null;
@@ -16,6 +20,9 @@ export class SupervisedPlanRegistry {
16
20
  identity(sessionId) {
17
21
  return this.plans.get(sessionId)?.identity ?? null;
18
22
  }
23
+ publicationReason(sessionId) {
24
+ return this.plans.get(sessionId)?.publicationReason ?? null;
25
+ }
19
26
  clear(sessionId) {
20
27
  this.plans.delete(sessionId);
21
28
  }
@@ -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
  });
@@ -17,16 +17,22 @@ export class SqlitePendingInteractionStore {
17
17
  constructor(db) {
18
18
  this.db = db;
19
19
  }
20
- add(sessionId, interaction) {
20
+ add(sessionId, interaction, initialResolutionState = 'active') {
21
21
  this.db
22
- .prepare(`INSERT INTO pending_interactions (session_id,request_id,kind,payload_json,turn_id,requested_at)
23
- VALUES (?,?,?,?,?,?)
22
+ .prepare(`INSERT INTO pending_interactions (session_id,request_id,kind,payload_json,turn_id,requested_at,resolution_state)
23
+ VALUES (?,?,?,?,?,?,?)
24
24
  ON CONFLICT(session_id,request_id) DO UPDATE SET
25
25
  kind = excluded.kind, payload_json = excluded.payload_json, turn_id = excluded.turn_id,
26
26
  requested_at = excluded.requested_at, resolved_at = NULL, outcome = NULL, operation_key = NULL,
27
- resolution_state = 'active'
27
+ resolution_state = excluded.resolution_state
28
28
  WHERE pending_interactions.kind != 'orgPlanAttention'`)
29
- .run(sessionId, interaction.requestId, interaction.kind, JSON.stringify(interaction.payload), interaction.turnId ?? null, interaction.requestedAt ?? null);
29
+ .run(sessionId, interaction.requestId, interaction.kind, JSON.stringify(interaction.payload), interaction.turnId ?? null, interaction.requestedAt ?? null, initialResolutionState);
30
+ }
31
+ resolutionState(sessionId, requestId) {
32
+ const row = this.db
33
+ .prepare('SELECT resolution_state FROM pending_interactions WHERE session_id = ? AND request_id = ? AND resolved_at IS NULL')
34
+ .get(sessionId, requestId);
35
+ return row?.resolution_state ?? null;
30
36
  }
31
37
  resolve(sessionId, requestId, resolvedAt, outcome = 'answered') {
32
38
  return (this.db
@@ -56,7 +62,7 @@ export class SqlitePendingInteractionStore {
56
62
  /** Persist the in-flight delivery boundary before the app-server is notified. */
57
63
  beginDelivery(sessionId, requestId, operationKey) {
58
64
  return (this.db
59
- .prepare("UPDATE pending_interactions SET resolution_state = 'delivering' WHERE session_id = ? AND request_id = ? AND operation_key = ? AND resolved_at IS NULL AND resolution_state IN ('active','delivering')")
65
+ .prepare("UPDATE pending_interactions SET resolution_state = 'delivering' WHERE session_id = ? AND request_id = ? AND operation_key = ? AND resolved_at IS NULL AND resolution_state IN ('active','acknowledged','delivering')")
60
66
  .run(sessionId, requestId, operationKey).changes === 1);
61
67
  }
62
68
  /** A local writer outage is retryable only by the already-claimed key. */