gestalt-mobile 0.17.2 → 0.18.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.
Files changed (44) hide show
  1. package/README.md +5 -1
  2. package/dist/client/assets/index-B0Uq4OB0.css +1 -0
  3. package/dist/client/assets/index-orfZBEaD.js +27 -0
  4. package/dist/client/index.html +2 -2
  5. package/dist/server/server/app.js +6 -0
  6. package/dist/server/server/composition.js +413 -21
  7. package/dist/server/server/features/agent-activity/model.js +197 -0
  8. package/dist/server/server/features/agent-activity/registry.js +155 -0
  9. package/dist/server/server/features/autopilot/application/policy.js +55 -0
  10. package/dist/server/server/features/autopilot/application/ports.js +6 -0
  11. package/dist/server/server/features/autopilot/application/service.js +542 -0
  12. package/dist/server/server/features/autopilot/domain/autopilot-session.js +40 -0
  13. package/dist/server/server/features/autopilot/register-routes.js +9 -0
  14. package/dist/server/server/features/autopilot/toggle/endpoint.js +32 -0
  15. package/dist/server/server/features/org-plan-attention/application/ports.js +6 -0
  16. package/dist/server/server/features/org-plan-attention/get-active/endpoint.js +22 -0
  17. package/dist/server/server/features/org-plan-attention/register-routes.js +11 -0
  18. package/dist/server/server/features/org-plan-attention/resolve/endpoint.js +42 -0
  19. package/dist/server/server/features/plans/application/parse-supervised-plan.js +4 -0
  20. package/dist/server/server/features/sessions/get-history/endpoint.js +62 -2
  21. package/dist/server/server/features/sessions/get-history/history-mapper.js +17 -5
  22. package/dist/server/server/features/sessions/get-session/endpoint.js +3 -1
  23. package/dist/server/server/features/sessions/interaction/kind.js +1 -0
  24. package/dist/server/server/features/sessions/interaction/response-validator.js +5 -1
  25. package/dist/server/server/features/sessions/list-sessions/endpoint.js +2 -0
  26. package/dist/server/server/features/sessions/model/relay-session.js +3 -0
  27. package/dist/server/server/features/sessions/refresh-activity/endpoint.js +14 -0
  28. package/dist/server/server/features/sessions/register-routes.js +15 -2
  29. package/dist/server/server/features/sessions/respond-interaction/endpoint.js +15 -2
  30. package/dist/server/server/features/sessions/restore-session/endpoint.js +21 -5
  31. package/dist/server/server/features/skills/list-available/endpoint.js +3 -4
  32. package/dist/server/server/features/skills/model/skill-profile.js +13 -5
  33. package/dist/server/server/platform/codex/activity-facts.js +88 -0
  34. package/dist/server/server/platform/codex/server-request.js +21 -4
  35. package/dist/server/server/platform/codex/session-runtime.js +114 -22
  36. package/dist/server/server/platform/persistence/migrate.js +16 -1
  37. package/dist/server/server/platform/persistence/sqlite-autopilot-store.js +151 -0
  38. package/dist/server/server/platform/persistence/sqlite-event-journal.js +92 -4
  39. package/dist/server/server/platform/persistence/sqlite-pending-interaction-store.js +62 -3
  40. package/dist/server/server/platform/persistence/sqlite-session-repository.js +5 -2
  41. package/dist/server/shared/contracts/org-plan-attention.js +101 -0
  42. package/package.json +1 -1
  43. package/dist/client/assets/index-DgD-0I3M.js +0 -25
  44. package/dist/client/assets/index-e2lJ5XqL.css +0 -1
@@ -0,0 +1,42 @@
1
+ import { parseOrgPlanAttentionResponse, toOrgPlanAttentionToolResponse, } from '../../../../shared/contracts/org-plan-attention.js';
2
+ export function registerResolveOrgPlanAttention(app, deps) {
3
+ app.post('/api/sessions/:id/attention/:requestId/resolve', async (request, reply) => {
4
+ const { id: sessionId, requestId } = request.params;
5
+ const body = request.body;
6
+ const validFields = body &&
7
+ Object.keys(body).every((key) => ['operationKey', 'action', 'guidance'].includes(key));
8
+ const response = validFields
9
+ ? parseOrgPlanAttentionResponse({
10
+ action: body.action,
11
+ ...(body.guidance === undefined ? {} : { guidance: body.guidance }),
12
+ })
13
+ : null;
14
+ const operationKey = typeof body?.operationKey === 'string' && /^[A-Za-z0-9_-]{1,64}$/.test(body.operationKey)
15
+ ? body.operationKey
16
+ : null;
17
+ if (!response || !operationKey)
18
+ return reply.code(400).send({ code: 'ATTENTION_RESPONSE_INVALID' });
19
+ const outcome = await deps.resolver.resolve({
20
+ sessionId,
21
+ requestId,
22
+ operationKey,
23
+ response: toOrgPlanAttentionToolResponse(response),
24
+ });
25
+ if (outcome.kind === 'accepted' || outcome.kind === 'replayed')
26
+ return reply.code(202).send({
27
+ accepted: true,
28
+ replayed: outcome.kind === 'replayed',
29
+ resolvedAt: outcome.resolvedAt,
30
+ });
31
+ const code = outcome.kind === 'noActive'
32
+ ? 'ORG_PLAN_ATTENTION_NOT_ACTIVE'
33
+ : outcome.kind === 'staleOperation'
34
+ ? 'ATTENTION_OPERATION_STALE'
35
+ : outcome.kind === 'legacyUnsupported'
36
+ ? 'ATTENTION_LEGACY_UNSUPPORTED'
37
+ : outcome.kind === 'writerCleared'
38
+ ? 'ATTENTION_WRITER_CLEARED'
39
+ : 'ATTENTION_WRITER_UNAVAILABLE';
40
+ return reply.code(outcome.kind === 'noActive' ? 404 : 409).send({ code });
41
+ });
42
+ }
@@ -94,6 +94,9 @@ export function parseSupervisedPlan(input) {
94
94
  return unavailable('MISSING_REQUIRED_FIELD');
95
95
  const allSteps = headings;
96
96
  const allDone = allSteps.every((step) => step.state === 'DONE');
97
+ const executionComplete = built.every((step) => step.state === 'DONE' &&
98
+ step.reviewStatus === 'REVIEWED' &&
99
+ step.children.every((child) => child.state === 'DONE'));
97
100
  const currentStepId = findCurrentStepId(allSteps, allDone);
98
101
  if (!currentStepId)
99
102
  return unavailable('MALFORMED_ORG');
@@ -108,6 +111,7 @@ export function parseSupervisedPlan(input) {
108
111
  totalSteps: allSteps.length,
109
112
  doneSteps: allSteps.filter((step) => step.state === 'DONE').length,
110
113
  allDone,
114
+ executionComplete,
111
115
  currentStepId,
112
116
  }),
113
117
  };
@@ -38,12 +38,21 @@ export function registerGetHistory(app, deps) {
38
38
  retryable: true,
39
39
  });
40
40
  }
41
- const items = toChatItems(history.turns);
41
+ const controls = deps.autopilotControlTurns?.(session.id) ?? new Map();
42
+ const items = toChatItems(history.turns, controls);
43
+ const auditSource = deps.autopilotAudit?.(session.id, 100) ?? [];
44
+ const boundedAudit = isBoundedAudit(auditSource) ? auditSource : null;
45
+ const auditEvents = boundedAudit
46
+ ? boundedAudit.events
47
+ : auditSource;
48
+ const autopilotAudit = toAutopilotAudit(auditEvents);
42
49
  const snapshot = {
43
50
  items,
44
- turns: toChatTurns(history.turns),
51
+ turns: toChatTurns(history.turns, controls),
45
52
  activeTurnId: history.activeTurnId,
46
53
  interactions: deps.interactions?.(session.id) ?? [],
54
+ ...(autopilotAudit.length ? { autopilotAudit } : {}),
55
+ ...(boundedAudit?.truncated ? { autopilotAuditTruncated: true } : {}),
47
56
  baseSequence,
48
57
  // Temporary compatibility for clients which have not adopted ChatSnapshot.
49
58
  currentSequence: baseSequence,
@@ -51,3 +60,54 @@ export function registerGetHistory(app, deps) {
51
60
  return reply.send(snapshot);
52
61
  });
53
62
  }
63
+ function isBoundedAudit(source) {
64
+ return !Array.isArray(source) && 'events' in source;
65
+ }
66
+ const auditLabels = {
67
+ 'autopilot.continuation-scheduled': 'Autopilot scheduled a continuation',
68
+ 'autopilot.control-issued': 'Autopilot issued an automatic continuation.',
69
+ 'autopilot.turn-started': 'Autopilot continuation started',
70
+ 'autopilot.turn-failed': 'Autopilot continuation failed',
71
+ 'autopilot.progress-reset': 'Autopilot reset retry progress after the plan changed',
72
+ 'org-plan.attention-required': 'Autopilot needs attention',
73
+ 'org-plan.attention-resolved': 'Attention resolved',
74
+ };
75
+ function snapshotAuditLabel(payload) {
76
+ if (!payload || typeof payload !== 'object')
77
+ return null;
78
+ const { state, reason } = payload;
79
+ if (state === 'backoff')
80
+ return 'Autopilot is backing off';
81
+ if (state === 'attentionRequired')
82
+ return 'Autopilot needs attention';
83
+ if (state === 'completed')
84
+ return 'Autopilot completed the plan';
85
+ if (state === 'disabled' && reason === 'planRequired')
86
+ return 'Autopilot requires an incomplete supervised plan';
87
+ return null;
88
+ }
89
+ /** Maps journal vocabulary to a deliberately redacted, durable timeline projection. */
90
+ function toAutopilotAudit(events) {
91
+ return events.flatMap((event) => {
92
+ const occurredAt = Date.parse(event.occurredAt);
93
+ if (!Number.isFinite(occurredAt))
94
+ return [];
95
+ const label = event.type === 'org-plan.attention-resolved' &&
96
+ event.payload &&
97
+ typeof event.payload === 'object' &&
98
+ event.payload.outcome === 'failed'
99
+ ? 'Attention resolution failed'
100
+ : (auditLabels[event.type] ??
101
+ (event.type === 'autopilot.updated' ? snapshotAuditLabel(event.payload) : null));
102
+ if (!label)
103
+ return [];
104
+ const controlId = event.payload &&
105
+ typeof event.payload === 'object' &&
106
+ typeof event.payload.controlId === 'string'
107
+ ? event.payload.controlId
108
+ : undefined;
109
+ return [
110
+ { id: `audit:${event.sequence}`, label, occurredAt, ...(controlId ? { controlId } : {}) },
111
+ ];
112
+ });
113
+ }
@@ -4,21 +4,21 @@
4
4
  * SPDX-License-Identifier: AGPL-3.0-or-later
5
5
  */
6
6
  /** Canonical grouping preserves the upstream Codex turn identity across reloads. */
7
- export function toChatTurns(turns) {
7
+ export function toChatTurns(turns, autopilotControlTurns = new Map()) {
8
8
  return turns.map((turn, index) => {
9
9
  const turnId = turn.id ?? `history-turn-${index}`;
10
10
  return {
11
11
  id: turnId,
12
- items: turn.items.flatMap((item) => toChatItem(item, occurredAt(item, turn), turnId)),
12
+ items: turn.items.flatMap((item) => toChatItem(item, occurredAt(item, turn), turnId, autopilotControlTurns)),
13
13
  startedAt: turn.startedAt,
14
14
  completedAt: turn.completedAt,
15
15
  };
16
16
  });
17
17
  }
18
- export function toChatItems(turns) {
19
- return turns.flatMap((turn, index) => turn.items.flatMap((item) => toChatItem(item, occurredAt(item, turn), turn.id ?? `history-turn-${index}`)));
18
+ export function toChatItems(turns, autopilotControlTurns = new Map()) {
19
+ return turns.flatMap((turn, index) => turn.items.flatMap((item) => toChatItem(item, occurredAt(item, turn), turn.id ?? `history-turn-${index}`, autopilotControlTurns)));
20
20
  }
21
- function toChatItem(item, timestamp, turnId) {
21
+ function toChatItem(item, timestamp, turnId, autopilotControlTurns) {
22
22
  const owner = turnId ? { turnId } : {};
23
23
  return [item].flatMap((item) => {
24
24
  const id = typeof item.id === 'string' ? item.id : null;
@@ -26,6 +26,18 @@ function toChatItem(item, timestamp, turnId) {
26
26
  return [];
27
27
  switch (item.type) {
28
28
  case 'userMessage': {
29
+ if (typeof item.clientId === 'string' &&
30
+ turnId !== undefined &&
31
+ autopilotControlTurns.get(turnId) === item.clientId)
32
+ return [
33
+ {
34
+ id,
35
+ kind: 'autopilot',
36
+ controlId: item.clientId,
37
+ ...owner,
38
+ ...(timestamp !== undefined ? { occurredAt: timestamp } : {}),
39
+ },
40
+ ];
29
41
  const text = Array.isArray(item.content)
30
42
  ? item.content
31
43
  .filter(isRecord)
@@ -4,12 +4,14 @@
4
4
  * SPDX-License-Identifier: AGPL-3.0-or-later
5
5
  */
6
6
  import { buildResumeCommand } from '../application/resume-command.js';
7
- export function registerGetSession(app, find) {
7
+ export function registerGetSession(app, find, activity, autopilot) {
8
8
  app.get('/api/sessions/:id', async (request, reply) => {
9
9
  const session = find(request.params.id);
10
10
  return session
11
11
  ? reply.send({
12
12
  ...session,
13
+ ...(activity ? { agentActivity: activity(session.id) } : {}),
14
+ ...(autopilot ? { autopilot: autopilot(session.id) } : {}),
13
15
  resumeCommand: session.threadId ? buildResumeCommand(session) : null,
14
16
  })
15
17
  : reply.code(404).send({ code: 'SESSION_NOT_FOUND' });
@@ -10,5 +10,6 @@ export function isInteractionKind(value) {
10
10
  'permissionsApproval',
11
11
  'userInput',
12
12
  'quiz',
13
+ 'orgPlanAttention',
13
14
  ].includes(value);
14
15
  }
@@ -4,13 +4,14 @@
4
4
  * SPDX-License-Identifier: AGPL-3.0-or-later
5
5
  */
6
6
  import { isQuizToolResponseForQuiz, parseQuiz } from '../../../../shared/contracts/quiz.js';
7
+ import { isOrgPlanAttentionToolResponse, parseOrgPlanAttention, } from '../../../../shared/contracts/org-plan-attention.js';
7
8
  /** Validates the relay-safe subset of Codex's generated server-request responses. */
8
9
  export function isValidInteractionResponse(kind, value) {
9
10
  if (!isRecord(value))
10
11
  return false;
11
12
  if (kind === 'userInput')
12
13
  return isValidUserInputResponse(value);
13
- if (kind === 'quiz')
14
+ if (kind === 'quiz' || kind === 'orgPlanAttention')
14
15
  return false;
15
16
  if (kind === 'permissionsApproval')
16
17
  return isRecord(value.permissions) && (value.scope === 'turn' || value.scope === 'session');
@@ -21,6 +22,9 @@ export function isValidQuizInteractionResponse(payload, value) {
21
22
  const quiz = parseQuiz(payload);
22
23
  return quiz !== null && isQuizToolResponseForQuiz(quiz, value);
23
24
  }
25
+ export function isValidOrgPlanAttentionInteractionResponse(payload, value) {
26
+ return parseOrgPlanAttention(payload) !== null && isOrgPlanAttentionToolResponse(value);
27
+ }
24
28
  function isValidUserInputResponse(value) {
25
29
  if (!isRecord(value.answers))
26
30
  return false;
@@ -7,6 +7,8 @@ import { buildResumeCommand } from '../application/resume-command.js';
7
7
  export function registerListSessions(app, deps) {
8
8
  app.get('/api/sessions', async () => deps.list().map((session) => ({
9
9
  ...session,
10
+ ...(deps.activity ? { agentActivity: deps.activity(session.id) } : {}),
11
+ ...(deps.autopilot ? { autopilot: deps.autopilot(session.id) } : {}),
10
12
  resumeCommand: session.threadId ? buildResumeCommand(session) : null,
11
13
  })));
12
14
  }
@@ -75,6 +75,9 @@ export class RelaySession {
75
75
  bindThread(value, now) {
76
76
  return this.transition({ threadId: threadId(value), state: 'ready' }, 'ThreadBound', now);
77
77
  }
78
+ supportsAttentionTool(now) {
79
+ return this.transition({ attentionToolCapability: 'supported' }, 'AttentionToolRegistered', now);
80
+ }
78
81
  selectModel(value, now) {
79
82
  if (this.value.state !== 'ready')
80
83
  throw new DomainError('SESSION_NOT_READY');
@@ -0,0 +1,14 @@
1
+ /**
2
+ * The client session controller owns foreground and sequence-gap detection.
3
+ * On either signal it calls this idempotent boundary; replaying journal events
4
+ * alone is deliberately not treated as an authoritative activity reconciliation.
5
+ */
6
+ export function registerRefreshActivity(app, deps) {
7
+ app.post('/api/sessions/:id/activity/refresh', async (request, reply) => {
8
+ const id = request.params.id;
9
+ if (!deps.exists(id))
10
+ return reply.code(404).send({ code: 'SESSION_NOT_FOUND' });
11
+ await deps.refresh(id);
12
+ return reply.code(202).send({ accepted: true });
13
+ });
14
+ }
@@ -20,6 +20,7 @@ import { registerSessionEvents } from './session-events/endpoint.js';
20
20
  import { registerStartSession } from './start-session/endpoint.js';
21
21
  import { registerStartTurn } from './start-turn/endpoint.js';
22
22
  import { registerStopSession } from './stop-session/endpoint.js';
23
+ import { registerRefreshActivity } from './refresh-activity/endpoint.js';
23
24
  export function registerSessionRoutes(app, deps) {
24
25
  const sessions = deps.sessionRoutes;
25
26
  if (deps.recentThreads) {
@@ -46,15 +47,26 @@ export function registerSessionRoutes(app, deps) {
46
47
  ...sessions,
47
48
  reportFailure: (operation, error) => deps.logger.error(`${operation} failed: ${safeErrorLabel(error)}`),
48
49
  });
49
- registerGetSession(app, sessions.find);
50
+ registerGetSession(app, sessions.find, sessions.agentActivity, sessions.autopilotSnapshot);
51
+ if (sessions.refreshActivity)
52
+ registerRefreshActivity(app, {
53
+ exists: (id) => sessions.find(id) !== null,
54
+ refresh: sessions.refreshActivity,
55
+ });
50
56
  if (sessions.list)
51
- registerListSessions(app, { list: sessions.list });
57
+ registerListSessions(app, {
58
+ list: sessions.list,
59
+ activity: sessions.agentActivity,
60
+ autopilot: sessions.autopilotSnapshot,
61
+ });
52
62
  if (sessions.readHistory)
53
63
  registerGetHistory(app, {
54
64
  find: sessions.find,
55
65
  read: sessions.readHistory,
56
66
  currentSequence: sessions.currentSequence ?? (() => 0),
57
67
  interactions: deps.interactions?.snapshot,
68
+ autopilotControlTurns: sessions.autopilotControlTurns,
69
+ autopilotAudit: sessions.autopilotAudit,
58
70
  });
59
71
  if (sessions.startTurn)
60
72
  registerStartTurn(app, {
@@ -80,6 +92,7 @@ export function registerSessionRoutes(app, deps) {
80
92
  find: sessions.find,
81
93
  restore: sessions.restore,
82
94
  save: sessions.save,
95
+ ownsWriter: sessions.ownsWriter,
83
96
  idempotency: sessions.idempotency,
84
97
  });
85
98
  if (deps.recentThreads && sessions.promoteRecent)
@@ -18,8 +18,21 @@ export function registerRespondInteraction(app, deps) {
18
18
  return reply.code(409).send({ code: 'INTERACTION_NOT_PENDING' });
19
19
  if (deps.validate && !deps.validate(id, requestId, value))
20
20
  return reply.code(400).send({ code: 'INTERACTION_RESPONSE_INVALID' });
21
- if (!deps.reply(id, requestId, value))
22
- return reply.code(409).send({ code: 'INTERACTION_NOT_PENDING' });
21
+ const delivery = deps.reply(id, requestId, value);
22
+ if (delivery === false || delivery === 'unavailable')
23
+ return reply.code(409).send({ code: 'INTERACTION_DELIVERY_UNAVAILABLE' });
24
+ if (delivery === 'cleared') {
25
+ const resolvedAt = deps.now();
26
+ const outcome = 'dismissed';
27
+ if (deps.resolve(id, requestId, resolvedAt, outcome)) {
28
+ deps.resolved?.(id, requestId, resolvedAt, outcome);
29
+ return reply.code(202).send({ accepted: true, resolvedAt, outcome });
30
+ }
31
+ const raced = deps.alreadyResolved?.(id, requestId);
32
+ return raced
33
+ ? reply.code(202).send({ accepted: true, ...raced })
34
+ : reply.code(409).send({ code: 'INTERACTION_NOT_PENDING' });
35
+ }
23
36
  const resolvedAt = deps.now();
24
37
  const outcome = safeOutcome(value);
25
38
  if (!deps.resolve(id, requestId, resolvedAt, outcome))
@@ -4,7 +4,7 @@
4
4
  * SPDX-License-Identifier: AGPL-3.0-or-later
5
5
  */
6
6
  import { idempotencyKey } from '../../../platform/http/idempotency.js';
7
- import { RelaySession } from '../model/relay-session.js';
7
+ import { RelaySession, relayOwnsWriter } from '../model/relay-session.js';
8
8
  import { canRestore } from './use-case.js';
9
9
  export function registerRestoreSession(app, deps) {
10
10
  app.post('/api/sessions/:id/restore', async (request, reply) => {
@@ -17,8 +17,15 @@ export function registerRestoreSession(app, deps) {
17
17
  const session = deps.find(id);
18
18
  if (!session)
19
19
  return reply.code(404).send({ code: 'SESSION_NOT_FOUND' });
20
- if (!canRestore(session))
20
+ // Opening an already live session is a safe no-op. It must not tear down or
21
+ // replace this relay's writer merely because a client retried Open while the
22
+ // session was becoming ready.
23
+ const staleWriter = relayOwnsWriter(session) && deps.ownsWriter?.(id) === false;
24
+ if (!canRestore(session) && !staleWriter) {
25
+ if (relayOwnsWriter(session))
26
+ return reply.send(session);
21
27
  return reply.code(409).send({ code: 'SESSION_CANNOT_RESTORE' });
28
+ }
22
29
  const recovering = RelaySession.rehydrate(session).beginRecovery(new Date().toISOString()).snapshot;
23
30
  deps.save(recovering);
24
31
  let restored;
@@ -28,7 +35,9 @@ export function registerRestoreSession(app, deps) {
28
35
  catch {
29
36
  // The pre-I/O recovery marker must never strand a saved session: keep
30
37
  // its original thread and inactive state available for a later Open.
31
- deps.save(session);
38
+ deps.save(staleWriter
39
+ ? RelaySession.rehydrate(session).stop(new Date().toISOString()).snapshot
40
+ : session);
32
41
  return reply.code(502).send({ code: 'RESTORE_FAILED' });
33
42
  }
34
43
  const response = 'session' in restored
@@ -40,8 +49,15 @@ export function registerRestoreSession(app, deps) {
40
49
  }
41
50
  : restored;
42
51
  deps.save(response);
52
+ const settled = (response.state === 'ready' || response.state === 'turnActive') &&
53
+ deps.ownsWriter &&
54
+ !deps.ownsWriter(id)
55
+ ? RelaySession.rehydrate(response).stop(new Date().toISOString()).snapshot
56
+ : response;
57
+ if (settled !== response)
58
+ deps.save(settled);
43
59
  if (key)
44
- deps.idempotency?.put(scope, key, 200, JSON.stringify(response));
45
- return reply.send(response);
60
+ deps.idempotency?.put(scope, key, 200, JSON.stringify(settled));
61
+ return reply.send(settled);
46
62
  });
47
63
  }
@@ -4,7 +4,7 @@
4
4
  * SPDX-License-Identifier: AGPL-3.0-or-later
5
5
  */
6
6
  import { z } from 'zod';
7
- import { applySkillSelectionSnapshot } from '../model/skill-profile.js';
7
+ import { applySkillSelectionSnapshot, isAlwaysAdvertisedSkill } from '../model/skill-profile.js';
8
8
  import { SkillProfileError } from '../model/errors.js';
9
9
  import { problem } from '../../../platform/http/problem.js';
10
10
  const querySchema = z
@@ -34,9 +34,7 @@ export function registerListAvailableSkills(app, deps) {
34
34
  : deps.catalog.list(profile.name, workspace.realPath),
35
35
  deps.selections.readWorkspaceDefault(workspace.realPath),
36
36
  ]);
37
- const skills = project
38
- ? applySkillSelectionSnapshot(discovered.skills, project.skills)
39
- : discovered.skills;
37
+ const skills = applySkillSelectionSnapshot(discovered.skills, project?.skills);
40
38
  return {
41
39
  source: project ? 'project' : 'native',
42
40
  errors: discovered.errors.map(({ message }) => ({ message })),
@@ -57,6 +55,7 @@ export function registerListAvailableSkills(app, deps) {
57
55
  scope: skill.scope,
58
56
  nativeEnabled: discovered.skills.find((native) => native.path === skill.path)?.enabled ?? false,
59
57
  effectiveEnabled: skill.enabled,
58
+ alwaysAdvertised: isAlwaysAdvertisedSkill(skill),
60
59
  })),
61
60
  };
62
61
  }
@@ -54,6 +54,10 @@ export const availableSkillSchema = z
54
54
  scope: z.string().optional(),
55
55
  })
56
56
  .strict();
57
+ /** Gestalt workflow skills are session infrastructure, not optional profile entries. */
58
+ export function isAlwaysAdvertisedSkill(skill) {
59
+ return skill.name.startsWith('gestalt:');
60
+ }
57
61
  const profileDocumentSchema = z
58
62
  .object({
59
63
  version: z.literal(1),
@@ -135,7 +139,9 @@ export function createSkillSelection(entries) {
135
139
  /**
136
140
  * Apply an optional complete selection snapshot to a fresh catalog. Selection
137
141
  * entry names are never used for matching: canonical paths are the identity.
138
- * Without a snapshot, the catalog retains Codex-native enabled states.
142
+ * Gestalt-provider skills are always enabled so update-added infrastructure
143
+ * cannot disappear from a session catalog. Other skills retain native state
144
+ * when no snapshot exists.
139
145
  */
140
146
  export function applySkillSelectionSnapshot(discovered, selection) {
141
147
  const catalog = discovered.map((skill) => {
@@ -145,11 +151,13 @@ export function applySkillSelectionSnapshot(discovered, selection) {
145
151
  }
146
152
  return { ...parsed.data, path: canonicalSkillPath(parsed.data.path) };
147
153
  });
148
- if (selection === undefined) {
149
- return catalog;
150
- }
154
+ if (selection === undefined)
155
+ return catalog.map((skill) => isAlwaysAdvertisedSkill(skill) ? { ...skill, enabled: true } : skill);
151
156
  const enabledByPath = new Map(createSkillSelection(selection).map((entry) => [entry.path, entry.enabled]));
152
- return catalog.map((skill) => ({ ...skill, enabled: enabledByPath.get(skill.path) ?? false }));
157
+ return catalog.map((skill) => ({
158
+ ...skill,
159
+ enabled: isAlwaysAdvertisedSkill(skill) ? true : (enabledByPath.get(skill.path) ?? false),
160
+ }));
153
161
  }
154
162
  /** Explicit session selection takes precedence over a workspace default. */
155
163
  export function selectEffectiveSkillSelection(input) {
@@ -0,0 +1,88 @@
1
+ /*
2
+ * Copyright (C) 2026 Dyne.org foundation
3
+ * Designed by Denis Roio <jaromil@dyne.org>
4
+ * SPDX-License-Identifier: AGPL-3.0-or-later
5
+ */
6
+ const id = (value) => typeof value === 'string' && value.length > 0 && value.length <= 256 ? value : undefined;
7
+ const record = (value) => value && typeof value === 'object' && !Array.isArray(value)
8
+ ? value
9
+ : undefined;
10
+ /** Decodes only bounded app-server evidence; unsupported shapes deliberately become no fact. */
11
+ export function decodeAgentActivityFact(sessionId, occurredAt, input) {
12
+ const params = record(input.params);
13
+ if (!params)
14
+ return null;
15
+ if (input.method === 'thread/started') {
16
+ const thread = record(params.thread);
17
+ const status = record(thread?.status);
18
+ return id(thread?.id)
19
+ ? {
20
+ sessionId,
21
+ occurredAt,
22
+ kind: 'threadStarted',
23
+ threadId: id(thread?.id),
24
+ ...(typeof status?.type === 'string' ? { status: status.type } : {}),
25
+ }
26
+ : null;
27
+ }
28
+ if (input.method === 'thread/status/changed') {
29
+ const status = record(params.status);
30
+ return id(params.threadId) && typeof status?.type === 'string' && status.type.length <= 64
31
+ ? {
32
+ sessionId,
33
+ occurredAt,
34
+ kind: 'threadStatus',
35
+ threadId: id(params.threadId),
36
+ status: status.type,
37
+ }
38
+ : null;
39
+ }
40
+ if (input.method === 'turn/started')
41
+ return id(params.threadId)
42
+ ? {
43
+ sessionId,
44
+ occurredAt,
45
+ kind: 'turnStarted',
46
+ threadId: id(params.threadId),
47
+ turnId: id(record(params.turn)?.id),
48
+ }
49
+ : null;
50
+ if (input.method === 'turn/completed')
51
+ return id(record(params.turn)?.id)
52
+ ? {
53
+ sessionId,
54
+ occurredAt,
55
+ kind: 'turnCompleted',
56
+ threadId: id(params.threadId),
57
+ turnId: id(record(params.turn)?.id),
58
+ status: typeof record(params.turn)?.status === 'string'
59
+ ? record(params.turn)?.status
60
+ : undefined,
61
+ }
62
+ : null;
63
+ if (input.method === 'item/started' || input.method === 'item/completed') {
64
+ const item = record(params.item);
65
+ if (item?.type !== 'collabToolCall')
66
+ return null;
67
+ const action = typeof item.tool === 'string' &&
68
+ ['spawn_agent', 'send_input', 'resume_agent', 'wait', 'close_agent'].includes(item.tool)
69
+ ? item.tool
70
+ : undefined;
71
+ const child = id(item.receiverThreadId) ?? id(item.newThreadId);
72
+ const itemStatus = typeof item.status === 'string' && item.status.length <= 64 ? item.status : undefined;
73
+ return {
74
+ sessionId,
75
+ occurredAt,
76
+ kind: 'collaboration',
77
+ ...(child ? { childId: child, childThreadId: child } : {}),
78
+ ...(id(item.senderThreadId) ? { threadId: id(item.senderThreadId) } : {}),
79
+ ...(typeof item.agentStatus === 'string' && item.agentStatus.length <= 64
80
+ ? { childStatus: item.agentStatus }
81
+ : itemStatus
82
+ ? { childStatus: itemStatus }
83
+ : {}),
84
+ ...(action ? { collaborationAction: action } : {}),
85
+ };
86
+ }
87
+ return null;
88
+ }
@@ -4,14 +4,22 @@
4
4
  * SPDX-License-Identifier: AGPL-3.0-or-later
5
5
  */
6
6
  import { GESTALT_QUIZ_TOOL_NAME, parseQuiz } from '../../../shared/contracts/quiz.js';
7
+ import { GESTALT_ORG_PLAN_ATTENTION_TOOL_NAME, parseOrgPlanAttention, } from '../../../shared/contracts/org-plan-attention.js';
7
8
  export function toPendingInteraction(input) {
8
9
  if (!Number.isSafeInteger(input.id) || !isRecord(input.params))
9
10
  return null;
10
11
  if (input.method === 'item/tool/call') {
11
- if (!isRecord(input.params) || input.params.tool !== GESTALT_QUIZ_TOOL_NAME)
12
- return null;
13
- const quiz = parseQuiz(input.params.arguments);
14
- return quiz ? { requestId: String(input.id), kind: 'quiz', payload: quiz } : null;
12
+ if (input.params.tool === GESTALT_QUIZ_TOOL_NAME) {
13
+ const quiz = parseQuiz(input.params.arguments);
14
+ return quiz ? { requestId: String(input.id), kind: 'quiz', payload: quiz } : null;
15
+ }
16
+ if (input.params.tool === GESTALT_ORG_PLAN_ATTENTION_TOOL_NAME) {
17
+ const attention = parseOrgPlanAttention(input.params.arguments);
18
+ return attention
19
+ ? { requestId: String(input.id), kind: 'orgPlanAttention', payload: attention }
20
+ : null;
21
+ }
22
+ return null;
15
23
  }
16
24
  const kind = {
17
25
  'item/commandExecution/requestApproval': 'commandApproval',
@@ -21,6 +29,15 @@ export function toPendingInteraction(input) {
21
29
  }[input.method];
22
30
  return kind ? { requestId: String(input.id), kind, payload: input.params } : null;
23
31
  }
32
+ export function resolvedServerRequestId(input) {
33
+ if (input.method !== 'serverRequest/resolved' || !isRecord(input.params))
34
+ return null;
35
+ const requestId = input.params.requestId;
36
+ if (!((typeof requestId === 'string' && requestId.length > 0 && requestId.length <= 256) ||
37
+ (typeof requestId === 'number' && Number.isSafeInteger(requestId))))
38
+ return null;
39
+ return String(requestId);
40
+ }
24
41
  function isRecord(value) {
25
42
  return typeof value === 'object' && value !== null && !Array.isArray(value);
26
43
  }