gestalt-mobile 0.17.3 → 0.18.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/README.md +5 -1
  2. package/dist/client/assets/index-CJBtrNWw.css +1 -0
  3. package/dist/client/assets/index-D4ZAKUN3.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 +383 -26
  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/restore-session/endpoint.js +21 -5
  30. package/dist/server/server/features/skills/list-available/endpoint.js +3 -4
  31. package/dist/server/server/features/skills/model/skill-profile.js +13 -5
  32. package/dist/server/server/platform/codex/activity-facts.js +88 -0
  33. package/dist/server/server/platform/codex/server-request.js +12 -4
  34. package/dist/server/server/platform/codex/session-runtime.js +91 -8
  35. package/dist/server/server/platform/persistence/migrate.js +16 -1
  36. package/dist/server/server/platform/persistence/sqlite-autopilot-store.js +151 -0
  37. package/dist/server/server/platform/persistence/sqlite-event-journal.js +92 -4
  38. package/dist/server/server/platform/persistence/sqlite-pending-interaction-store.js +57 -8
  39. package/dist/server/server/platform/persistence/sqlite-session-repository.js +5 -2
  40. package/dist/server/server/platform/plans/filesystem-workspace-plan-catalog.js +43 -12
  41. package/dist/server/shared/contracts/org-plan-attention.js +101 -0
  42. package/package.json +1 -1
  43. package/dist/client/assets/index-Ba_4zoY1.js +0 -25
  44. package/dist/client/assets/index-e2lJ5XqL.css +0 -1
  45. package/dist/server/server/platform/plans/org-plan-command-validator.js +0 -62
@@ -16,8 +16,8 @@ SPDX-License-Identifier: AGPL-3.0-or-later
16
16
  <link rel="icon" href="/icons/gestalt-mobile-192.png" />
17
17
  <link rel="apple-touch-icon" href="/icons/gestalt-mobile-180.png" />
18
18
  <title>Gestalt Mobile</title>
19
- <script type="module" crossorigin src="/assets/index-Ba_4zoY1.js"></script>
20
- <link rel="stylesheet" crossorigin href="/assets/index-e2lJ5XqL.css">
19
+ <script type="module" crossorigin src="/assets/index-D4ZAKUN3.js"></script>
20
+ <link rel="stylesheet" crossorigin href="/assets/index-CJBtrNWw.css">
21
21
  </head>
22
22
  <body>
23
23
  <div id="app"></div>
@@ -15,6 +15,8 @@ import { registerSessionRoutes } from './features/sessions/register-routes.js';
15
15
  import { registerProblemHandler } from './platform/http/problem-handler.js';
16
16
  import { registerAuthorizationBoundary } from './platform/http/authorization-boundary.js';
17
17
  import { registerSkillRoutes } from './features/skills/register-routes.js';
18
+ import { registerOrgPlanAttentionRoutes } from './features/org-plan-attention/register-routes.js';
19
+ import { registerAutopilotRoutes } from './features/autopilot/register-routes.js';
18
20
  export async function buildApp(deps) {
19
21
  // Keep the default boundary finite even when an endpoint forgot a narrower schema.
20
22
  const app = fastify({ logger: false, bodyLimit: 1024 * 1024 });
@@ -41,6 +43,10 @@ export async function buildApp(deps) {
41
43
  if (deps.bootstrap)
42
44
  registerGetBootstrap(app, deps.bootstrap);
43
45
  registerSessionRoutes(app, deps);
46
+ if (deps.autopilot)
47
+ registerAutopilotRoutes(app, deps.autopilot, deps.sessionRoutes?.idempotency);
48
+ if (deps.orgPlanAttention)
49
+ registerOrgPlanAttentionRoutes(app, deps.orgPlanAttention);
44
50
  registerPlanRoutes(app, deps);
45
51
  registerGitRoutes(app, deps);
46
52
  registerSkillRoutes(app, deps);
@@ -3,7 +3,7 @@
3
3
  * Designed by Denis Roio <jaromil@dyne.org>
4
4
  * SPDX-License-Identifier: AGPL-3.0-or-later
5
5
  */
6
- import { randomBytes, randomUUID } from 'node:crypto';
6
+ import { createHash, randomBytes, randomUUID } from 'node:crypto';
7
7
  import { existsSync } from 'node:fs';
8
8
  import { homedir } from 'node:os';
9
9
  import { basename, dirname, join, resolve } from 'node:path';
@@ -32,6 +32,8 @@ import { GitSummaryCache } from './platform/git/git-summary-cache.js';
32
32
  import { SessionSupervisor } from './platform/runtime/session-supervisor.js';
33
33
  import { mapWithConcurrency } from './platform/runtime/concurrency.js';
34
34
  import { RelaySession } from './features/sessions/model/relay-session.js';
35
+ import { AgentActivityRegistry } from './features/agent-activity/registry.js';
36
+ import { decodeAgentActivityFact } from './platform/codex/activity-facts.js';
35
37
  import { resolvedServerRequestId, toPendingInteraction } from './platform/codex/server-request.js';
36
38
  import { isValidInteractionResponse, isValidQuizInteractionResponse, } from './features/sessions/interaction/response-validator.js';
37
39
  import { promoteRecentThread } from './features/sessions/promote-recent-thread/use-case.js';
@@ -42,10 +44,13 @@ import { compileSkillOverride } from './features/skills/model/skill-profile.js';
42
44
  import { SupervisedPlanRegistry } from './features/plans/application/supervised-plan-registry.js';
43
45
  import { FilesystemPlanStatusSource } from './platform/plans/filesystem-plan-status-source.js';
44
46
  import { FilesystemWorkspacePlanCatalog } from './platform/plans/filesystem-workspace-plan-catalog.js';
45
- import { OrgPlanCommandValidator } from './platform/plans/org-plan-command-validator.js';
46
47
  import { checkpointPlanMeasurement } from './platform/plans/plan-measurement-command.js';
47
48
  import { PlanMeasurementRefresh } from './platform/plans/plan-measurement-refresh.js';
49
+ import { SqliteAutopilotStore } from './platform/persistence/sqlite-autopilot-store.js';
50
+ import { AutopilotCoordinator } from './features/autopilot/application/service.js';
51
+ import { AUTOPILOT_CONTINUATION_PROMPT, defaultAutopilotPolicy, } from './features/autopilot/application/policy.js';
48
52
  import { createRelyingPartyConfig } from './config.js';
53
+ import { parseOrgPlanAttention } from '../shared/contracts/org-plan-attention.js';
49
54
  const generatedProtocolVersion = 'codex-cli 0.144.3';
50
55
  export async function composeRelayApp(options) {
51
56
  const passkeyAuthEnabled = options.passkeyAuthEnabled ?? true;
@@ -70,23 +75,147 @@ export async function composeRelayApp(options) {
70
75
  throw error;
71
76
  }
72
77
  const sessions = new SqliteSessionRepository(database);
78
+ // Only sessions found while opening the relay database belong to a previous
79
+ // process. A listen hook can run after a new session has already started in
80
+ // this process; detaching that live writer would make restore and activity
81
+ // reconciliation race their own owner.
82
+ const persistedSessionIds = new Set(sessions.list().map((session) => session.id));
73
83
  const journal = new SqliteEventJournal(database);
74
84
  const interactions = new SqlitePendingInteractionStore(database);
75
85
  const idempotency = new SqliteIdempotencyStore(database);
86
+ const autopilotStore = new SqliteAutopilotStore(database);
87
+ const attentionResolutionOperations = new Map();
76
88
  const supervisedPlans = new SupervisedPlanRegistry();
77
89
  const planStatusSource = new FilesystemPlanStatusSource(join(dirname(databasePath), 'plans'));
78
90
  const planMeasurementHelperPath = options.planMeasurementHelperPath ?? process.env.GESTALT_MOBILE_ORG_PLAN_HELPER;
79
91
  const withPendingInteractions = (session) => (session ? { ...session, pendingInteractions: interactions.list(session.id) } : null);
80
92
  const events = new SessionEventBus();
93
+ const attentionTransitions = {
94
+ subscribe: (sessionId, listener) => events.subscribe(sessionId, (event) => {
95
+ if (event.type !== 'org-plan.attention-required' &&
96
+ event.type !== 'org-plan.attention-resolved')
97
+ return;
98
+ const payload = event.payload;
99
+ if (typeof payload.requestId !== 'string')
100
+ return;
101
+ listener({
102
+ kind: event.type === 'org-plan.attention-required'
103
+ ? 'required'
104
+ : payload.outcome === 'failed'
105
+ ? 'failed'
106
+ : 'resolved',
107
+ requestId: payload.requestId,
108
+ occurredAt: event.occurredAt,
109
+ });
110
+ }),
111
+ };
112
+ options.onAttentionTransitions?.(attentionTransitions);
113
+ const activity = new AgentActivityRegistry((snapshot, occurredAt) => events.publish(journal.append(snapshot.sessionId, 'agent.activity.updated', snapshot, occurredAt)), {
114
+ // Evidence arms one bounded reconciliation; healthy sessions are never polled.
115
+ schedule: options.activitySchedule ??
116
+ ((callback, delayMs) => {
117
+ const timer = setTimeout(callback, delayMs);
118
+ return () => clearTimeout(timer);
119
+ }),
120
+ now: () => new Date().toISOString(),
121
+ diagnostic: options.activityDiagnostic ??
122
+ ((sessionId, code) => console.warn(`agent activity ${code} session=${sessionId}`)),
123
+ reconcile: async (sessionId) => {
124
+ const session = sessions.find(sessionId);
125
+ if (!session || !runtime)
126
+ return;
127
+ const history = await runtime.readHistory(session);
128
+ const occurredAt = new Date().toISOString();
129
+ activity.observe({
130
+ sessionId,
131
+ occurredAt,
132
+ kind: history.activeTurnId ? 'turnStarted' : 'turnCompleted',
133
+ ...(session.threadId ? { threadId: session.threadId } : {}),
134
+ ...(history.activeTurnId ? { turnId: history.activeTurnId } : {}),
135
+ });
136
+ activity.childrenReconciled(sessionId, occurredAt, await runtime.listDirectChildren(session));
137
+ },
138
+ });
139
+ const autopilot = new AutopilotCoordinator({
140
+ store: autopilotStore,
141
+ now: () => new Date().toISOString(),
142
+ policy: defaultAutopilotPolicy,
143
+ plan: (sessionId) => {
144
+ const plan = supervisedPlans.find(sessionId);
145
+ const identity = supervisedPlans.identity(sessionId);
146
+ return plan && identity ? { plan, identity } : null;
147
+ },
148
+ session: (sessionId) => sessions.find(sessionId),
149
+ activity: (sessionId) => options.autopilotActivity
150
+ ? options.autopilotActivity(sessionId)
151
+ : activity.snapshot(sessionId, new Date().toISOString()),
152
+ pendingInteraction: (sessionId) => interactions.list(sessionId).length > 0,
153
+ reconcile: async (sessionId) => {
154
+ if (options.autopilotReconcile)
155
+ return options.autopilotReconcile(sessionId);
156
+ await activity.refresh(sessionId);
157
+ return {
158
+ compatible: activity.snapshot(sessionId, new Date().toISOString()).confidence === 'fresh',
159
+ };
160
+ },
161
+ schedule: options.autopilotSchedule ??
162
+ ((callback, delayMs) => {
163
+ const timer = setTimeout(callback, delayMs);
164
+ return () => clearTimeout(timer);
165
+ }),
166
+ nextControlId: (sessionId, generation) => `autopilot-${generation}-${createHash('sha256').update(`${sessionId}:${randomUUID()}`).digest('hex').slice(0, 16)}`,
167
+ turnStarter: {
168
+ start: async (sessionId, controlId, generation) => {
169
+ const current = () => {
170
+ const state = autopilotStore.find(sessionId);
171
+ return Boolean(state &&
172
+ state.requestedEnabled &&
173
+ state.generation === generation &&
174
+ state.lastControlId === controlId);
175
+ };
176
+ if (!current())
177
+ throw new Error('AUTOPILOT_START_UNAVAILABLE');
178
+ const session = sessions.find(sessionId);
179
+ if (!session || !runtime || session.activeTurnId || interactions.list(sessionId).length)
180
+ throw new Error('AUTOPILOT_START_UNAVAILABLE');
181
+ // A relay restart can leave a durable, otherwise eligible session without this
182
+ // process's writer. Reacquire through the normal ownership boundary before a
183
+ // synthetic start; never bypass its single-writer and replacement semantics.
184
+ const writer = await runtime.ensureWriter(session, new Date().toISOString());
185
+ if (!current() || writer.session.activeTurnId || interactions.list(sessionId).length)
186
+ throw new Error('AUTOPILOT_START_UNAVAILABLE');
187
+ await options.autopilotBeforeTurnAccepted?.({ sessionId, controlId });
188
+ const started = await runtime.startTurn(writer.session, AUTOPILOT_CONTINUATION_PROMPT, controlId, new Date().toISOString());
189
+ if (!started.activeTurnId)
190
+ throw new Error('AUTOPILOT_START_UNAVAILABLE');
191
+ sessions.save(started);
192
+ await options.autopilotAfterTurnAccepted?.({
193
+ sessionId,
194
+ controlId,
195
+ turnId: started.activeTurnId,
196
+ });
197
+ activity.observe({
198
+ sessionId,
199
+ occurredAt: started.updatedAt,
200
+ kind: 'turnStarted',
201
+ ...(started.threadId ? { threadId: started.threadId } : {}),
202
+ ...(started.activeTurnId ? { turnId: started.activeTurnId } : {}),
203
+ });
204
+ },
205
+ },
206
+ publish: (sessionId, type, payload, occurredAt, outboxId) => {
207
+ if (!sessions.find(sessionId))
208
+ return;
209
+ events.publish(journal.append(sessionId, type, payload, occurredAt, outboxId));
210
+ },
211
+ });
212
+ options.onAutopilotCoordinator?.(autopilot);
81
213
  const workspaces = new FilesystemWorkspaceCatalog(root);
82
214
  const models = new CodexModelCatalog(root, options.launchAppServer ?? launchCodexAppServer);
83
215
  const skillProfiles = new FilesystemSkillProfileStore(options.homeDirectory ?? homedir());
84
216
  const skillCatalog = (profile) => new CodexSkillCatalog(profile, options.launchAppServer ?? launchCodexAppServer);
85
217
  const editorSkillCatalog = new CachedSkillCatalog((profile, workspace) => skillCatalog(profile).list(workspace));
86
- const workspacePlanCatalog = new FilesystemWorkspacePlanCatalog(new OrgPlanCommandValidator({
87
- ...(planMeasurementHelperPath ? { helperPath: planMeasurementHelperPath } : {}),
88
- discoverSkills: async (workspace) => (await skillCatalog('default').list(workspace)).skills,
89
- }));
218
+ const workspacePlanCatalog = new FilesystemWorkspacePlanCatalog();
90
219
  const resolveSkills = async (session) => {
91
220
  const catalog = await skillCatalog(session.profile).list(session.workspacePath);
92
221
  if (session.effectiveSkillSelection)
@@ -118,20 +247,46 @@ export async function composeRelayApp(options) {
118
247
  .snapshot(sessionId)
119
248
  .find((item) => item.requestId === requestId);
120
249
  events.publish(journal.append(sessionId, 'interaction.resolved', { requestId, turnId: interaction?.turnId ?? null, resolvedAt: occurredAt, outcome }, occurredAt));
250
+ if (interaction?.kind === 'orgPlanAttention')
251
+ events.publish(journal.append(sessionId, 'org-plan.attention-resolved', { requestId, turnId: interaction.turnId ?? null, resolvedAt: occurredAt, outcome }, occurredAt));
252
+ };
253
+ const publishAttentionSettlement = (sessionId, requestId, occurredAt, outcome) => {
254
+ const remaining = interactions.list(sessionId);
255
+ const attention = remaining.find((item) => item.kind === 'orgPlanAttention');
256
+ activity.observe({
257
+ sessionId,
258
+ occurredAt,
259
+ kind: 'interactionResolved',
260
+ hasPendingInteraction: remaining.length > 0,
261
+ ...(attention ? { attentionReason: attention.payload.reason } : {}),
262
+ });
263
+ publishInteractionResolved(sessionId, requestId, occurredAt, outcome);
121
264
  };
122
- const dismissPendingInteractions = (sessionId, occurredAt) => {
265
+ const dismissPendingInteractions = (sessionId, occurredAt, outcome = 'dismissed') => {
123
266
  for (const interaction of interactions.list(sessionId)) {
124
- if (interactions.resolve(sessionId, interaction.requestId, occurredAt, 'dismissed'))
125
- publishInteractionResolved(sessionId, interaction.requestId, occurredAt, 'dismissed');
267
+ if (interactions.resolve(sessionId, interaction.requestId, occurredAt, outcome))
268
+ publishInteractionResolved(sessionId, interaction.requestId, occurredAt, outcome);
126
269
  }
127
270
  };
128
- const runtime = options.startAppServers
271
+ let closing = false;
272
+ let runtime = null;
273
+ runtime = options.startAppServers
129
274
  ? new CodexSessionRuntime(options.launchAppServer ?? launchCodexAppServer, undefined, (sessionId, notification) => {
130
275
  const occurredAt = new Date().toISOString();
276
+ const activityFact = decodeAgentActivityFact(sessionId, occurredAt, notification);
277
+ if (activityFact)
278
+ activity.observe(activityFact);
131
279
  const resolvedRequestId = resolvedServerRequestId(notification);
132
- if (resolvedRequestId &&
133
- interactions.resolve(sessionId, resolvedRequestId, occurredAt, 'dismissed'))
134
- publishInteractionResolved(sessionId, resolvedRequestId, occurredAt, 'dismissed');
280
+ if (resolvedRequestId) {
281
+ const interaction = interactions.find(sessionId, resolvedRequestId);
282
+ const outcome = interaction?.kind === 'orgPlanAttention' ? 'failed' : 'dismissed';
283
+ if (interactions.resolve(sessionId, resolvedRequestId, occurredAt, outcome)) {
284
+ if (outcome === 'failed')
285
+ publishAttentionSettlement(sessionId, resolvedRequestId, occurredAt, outcome);
286
+ else
287
+ publishInteractionResolved(sessionId, resolvedRequestId, occurredAt, outcome);
288
+ }
289
+ }
135
290
  const currentSession = sessions.find(sessionId);
136
291
  const normalized = normalizeCodexNotification(sessionId, 0, occurredAt, notification, currentSession?.workspacePath, currentSession?.activeTurnId);
137
292
  if (!normalized)
@@ -143,6 +298,7 @@ export async function composeRelayApp(options) {
143
298
  if (session && turnId && session.activeTurnId === turnId) {
144
299
  completedSession = RelaySession.rehydrate(session).completeTurn(turnId, occurredAt).snapshot;
145
300
  sessions.save(completedSession);
301
+ autopilot.turnCompleted(sessionId);
146
302
  }
147
303
  planMeasurementRefresh?.refreshNow(sessionId);
148
304
  }
@@ -160,14 +316,28 @@ export async function composeRelayApp(options) {
160
316
  requestedAt: new Date().toISOString(),
161
317
  };
162
318
  interactions.add(sessionId, interaction);
319
+ activity.observe({
320
+ sessionId,
321
+ occurredAt: interaction.requestedAt,
322
+ kind: 'interactionPending',
323
+ ...(interaction.kind === 'orgPlanAttention'
324
+ ? { attentionReason: interaction.payload.reason }
325
+ : {}),
326
+ });
163
327
  const updated = RelaySession.rehydrate(session).requestInteraction(interaction, new Date().toISOString()).snapshot;
164
328
  sessions.save(updated);
165
329
  events.publish(journal.append(sessionId, 'interaction.requested', interaction, updated.updatedAt));
330
+ if (interaction.kind === 'orgPlanAttention')
331
+ events.publish(journal.append(sessionId, 'org-plan.attention-required', interaction, updated.updatedAt));
332
+ autopilot.evaluate(sessionId);
166
333
  return true;
167
334
  }, (sessionId) => {
168
- dismissPendingInteractions(sessionId, new Date().toISOString());
335
+ activity.disconnected(sessionId, new Date().toISOString());
336
+ dismissPendingInteractions(sessionId, new Date().toISOString(), 'failed');
169
337
  recoverExitedSession(sessionId);
170
338
  }, resolveSkills, planStatusSource, (sessionId, update) => {
339
+ if (closing)
340
+ return;
171
341
  supervisedPlans.accept(sessionId, update);
172
342
  planMeasurementRefresh?.accept(sessionId, update);
173
343
  if (update.kind === 'updated') {
@@ -185,6 +355,7 @@ export async function composeRelayApp(options) {
185
355
  }
186
356
  events.publish(journal.append(sessionId, 'plan.updated', { plan: update.plan, reason: update.reason }, occurredAt));
187
357
  }
358
+ autopilot.planUpdated(sessionId);
188
359
  }, options.planMeasurementBaseUrl, 30_000, 64, root)
189
360
  : null;
190
361
  if (runtime && planMeasurementHelperPath) {
@@ -196,8 +367,19 @@ export async function composeRelayApp(options) {
196
367
  }, (planPath, stepId, snapshot) => checkpointPlanMeasurement(planMeasurementHelperPath, planPath, stepId, snapshot));
197
368
  }
198
369
  const saveSession = (session) => {
370
+ const prior = sessions.find(session.id);
199
371
  sessions.save(session);
200
372
  events.publish(journal.append(session.id, 'session.updated', session, session.updatedAt));
373
+ const becameRuntimeReady = prior &&
374
+ prior.state !== 'ready' &&
375
+ prior.state !== 'turnActive' &&
376
+ (session.state === 'ready' || session.state === 'turnActive');
377
+ if (runtime &&
378
+ session.threadId &&
379
+ (!prior || prior.threadId !== session.threadId || becameRuntimeReady))
380
+ void activity.refresh(session.id);
381
+ if (becameRuntimeReady)
382
+ autopilot.restore(session.id);
201
383
  };
202
384
  if (runtime) {
203
385
  const supervisor = new SessionSupervisor(async (sessionId) => {
@@ -206,7 +388,8 @@ export async function composeRelayApp(options) {
206
388
  return;
207
389
  const recovering = RelaySession.rehydrate(session).beginRecovery(new Date().toISOString()).snapshot;
208
390
  saveSession(recovering);
209
- saveSession(await runtime.restore(recovering, new Date().toISOString()));
391
+ const restored = await runtime.restore(recovering, new Date().toISOString());
392
+ saveSession(restored);
210
393
  }, (sessionId) => {
211
394
  const session = sessions.find(sessionId);
212
395
  if (session)
@@ -217,8 +400,10 @@ export async function composeRelayApp(options) {
217
400
  recoverExitedSession = (sessionId) => {
218
401
  supervisor.cancel(sessionId);
219
402
  const session = sessions.find(sessionId);
220
- if (session)
403
+ if (session) {
404
+ autopilot.cancel(sessionId, 'sessionEnded');
221
405
  saveSession(RelaySession.rehydrate(session).stop(new Date().toISOString()).snapshot);
406
+ }
222
407
  };
223
408
  }
224
409
  let authorization;
@@ -313,11 +498,15 @@ export async function composeRelayApp(options) {
313
498
  ? async (session, settings) => {
314
499
  const now = new Date().toISOString();
315
500
  dismissPendingInteractions(session.id, now);
316
- return runtime.start(session, now, settings);
501
+ const started = await runtime.start(session, now, settings);
502
+ return started;
317
503
  }
318
504
  : undefined,
319
505
  startTurn: runtime
320
- ? async (session, text, clientUserMessageId) => runtime.startTurn(session, text, clientUserMessageId, new Date().toISOString())
506
+ ? async (session, text, clientUserMessageId) => {
507
+ autopilot.manualSend(session.id);
508
+ return runtime.startTurn(session, text, clientUserMessageId, new Date().toISOString());
509
+ }
321
510
  : undefined,
322
511
  ensureWriter: runtime
323
512
  ? (session) => {
@@ -331,7 +520,21 @@ export async function composeRelayApp(options) {
331
520
  return runtime.release(id);
332
521
  }
333
522
  : undefined,
334
- onTurnStarted: (session) => planMeasurementRefresh?.refreshNow(session.id),
523
+ onTurnStarted: (session) => {
524
+ activity.observe({
525
+ sessionId: session.id,
526
+ occurredAt: session.updatedAt,
527
+ kind: 'turnStarted',
528
+ ...(session.threadId ? { threadId: session.threadId } : {}),
529
+ ...(session.activeTurnId ? { turnId: session.activeTurnId } : {}),
530
+ });
531
+ planMeasurementRefresh?.refreshNow(session.id);
532
+ },
533
+ agentActivity: (id) => activity.snapshot(id, new Date().toISOString()),
534
+ autopilotSnapshot: (id) => autopilot.snapshot(id),
535
+ autopilotControlTurns: (id) => autopilot.acceptedControlTurns(id),
536
+ autopilotAudit: (id, limit) => journal.autopilotAuditTail(id, limit),
537
+ refreshActivity: (id) => activity.refresh(id),
335
538
  models,
336
539
  readHistory: runtime ? (session) => runtime.readHistory(session) : undefined,
337
540
  currentSequence: (sessionId) => journal.since(sessionId, 0).at(-1)?.sequence ?? 0,
@@ -339,8 +542,20 @@ export async function composeRelayApp(options) {
339
542
  ? (session, turnId) => runtime.interruptTurn(session, turnId)
340
543
  : undefined,
341
544
  restore: runtime
342
- ? (session) => runtime.restoreWithOutcome(session, new Date().toISOString())
545
+ ? async (session) => {
546
+ const restored = await runtime.restoreWithOutcome(session, new Date().toISOString());
547
+ // An exit can be reported while resume is resolving. Do not let the
548
+ // route persist a stale ready snapshot over that recovered exit.
549
+ if (!runtime.ownsWriter(session.id))
550
+ return {
551
+ ...restored,
552
+ session: RelaySession.rehydrate(restored.session).stop(new Date().toISOString())
553
+ .snapshot,
554
+ };
555
+ return restored;
556
+ }
343
557
  : undefined,
558
+ ownsWriter: runtime ? (id) => runtime.ownsWriter(id) : undefined,
344
559
  promoteRecent: runtime
345
560
  ? (thread) => promoteRecentThread(thread, {
346
561
  createId: randomUUID,
@@ -350,13 +565,22 @@ export async function composeRelayApp(options) {
350
565
  read: (session) => runtime.readHistory(session),
351
566
  })
352
567
  : undefined,
353
- release: (session) => RelaySession.rehydrate(session).release(new Date().toISOString()).snapshot,
354
- remove: (id) => sessions.remove(id),
568
+ release: (session) => {
569
+ autopilot.cancel(session.id, 'sessionEnded');
570
+ return RelaySession.rehydrate(session).release(new Date().toISOString()).snapshot;
571
+ },
572
+ remove: (id) => {
573
+ autopilot.cancel(id, 'sessionEnded');
574
+ activity.dispose(id);
575
+ sessions.remove(id);
576
+ },
355
577
  idempotency,
356
578
  close: runtime
357
579
  ? (id) => {
580
+ autopilot.cancel(id, 'sessionEnded');
358
581
  planMeasurementRefresh?.stop(id);
359
582
  dismissPendingInteractions(id, new Date().toISOString());
583
+ activity.dispose(id);
360
584
  return runtime.release(id);
361
585
  }
362
586
  : undefined,
@@ -364,6 +588,17 @@ export async function composeRelayApp(options) {
364
588
  ? (sessionId, requestId, value) => runtime.resolveServerRequest(sessionId, requestId, value) ? 'accepted' : 'cleared'
365
589
  : undefined,
366
590
  interactionResolved: (sessionId, requestId, occurredAt, outcome) => {
591
+ const remaining = interactions.list(sessionId);
592
+ const attention = remaining.find((item) => item.kind === 'orgPlanAttention');
593
+ activity.observe({
594
+ sessionId,
595
+ occurredAt,
596
+ kind: 'interactionResolved',
597
+ hasPendingInteraction: remaining.length > 0,
598
+ ...(attention
599
+ ? { attentionReason: attention.payload.reason }
600
+ : {}),
601
+ });
367
602
  publishInteractionResolved(sessionId, requestId, occurredAt, outcome);
368
603
  },
369
604
  },
@@ -384,12 +619,14 @@ export async function composeRelayApp(options) {
384
619
  removeStatus: (id) => planStatusSource.remove(id, supervisedPlans.identity(id) ?? undefined),
385
620
  clear: (id) => supervisedPlans.clear(id),
386
621
  closed: (id) => {
622
+ autopilot.cancel(id, 'planRemoved');
387
623
  planMeasurementRefresh?.stop(id);
388
624
  const occurredAt = new Date().toISOString();
389
625
  events.publish(journal.append(id, 'plan.closed', {}, occurredAt));
390
626
  },
391
627
  },
392
628
  workspacePlanRoutes: { workspaces, plans: workspacePlanCatalog },
629
+ autopilot,
393
630
  ...(runtime
394
631
  ? {
395
632
  planMeasurementRoutes: {
@@ -415,9 +652,108 @@ export async function composeRelayApp(options) {
415
652
  return false;
416
653
  if (interaction.kind === 'quiz')
417
654
  return isValidQuizInteractionResponse(interaction.payload, value);
655
+ // Attention must use its operation-keyed boundary; generic interaction
656
+ // responses deliberately cannot bypass durable operation identity.
657
+ if (interaction.kind === 'orgPlanAttention')
658
+ return false;
418
659
  return isValidInteractionResponse(interaction.kind, value);
419
660
  },
420
661
  },
662
+ orgPlanAttention: {
663
+ exists: (id) => sessions.find(id) !== null,
664
+ reader: {
665
+ active: (sessionId) => {
666
+ const interaction = interactions
667
+ .list(sessionId)
668
+ .find((item) => item.kind === 'orgPlanAttention');
669
+ const attention = interaction ? parseOrgPlanAttention(interaction.payload) : null;
670
+ return interaction && attention
671
+ ? {
672
+ requestId: interaction.requestId,
673
+ turnId: interaction.turnId ?? null,
674
+ requestedAt: interaction.requestedAt ?? null,
675
+ attention,
676
+ }
677
+ : null;
678
+ },
679
+ },
680
+ resolver: {
681
+ resolve: async ({ sessionId, requestId, operationKey, response }) => {
682
+ const scope = `org-plan-attention:${sessionId}:${requestId}`;
683
+ const stored = idempotency.get(scope, operationKey);
684
+ if (stored)
685
+ return JSON.parse(stored.body);
686
+ const terminal = interactions.terminalOperation(sessionId, requestId);
687
+ if (terminal)
688
+ return terminal.operationKey === operationKey
689
+ ? terminal.outcome === 'failed'
690
+ ? { kind: 'writerCleared', resolvedAt: terminal.resolvedAt }
691
+ : { kind: 'replayed', resolvedAt: terminal.resolvedAt }
692
+ : { kind: 'staleOperation' };
693
+ const key = `${scope}:${operationKey}`;
694
+ const inFlight = attentionResolutionOperations.get(key);
695
+ if (inFlight)
696
+ return inFlight;
697
+ const operation = (async () => {
698
+ const interaction = interactions.find(sessionId, requestId);
699
+ if (!interaction || interaction.kind !== 'orgPlanAttention')
700
+ return { kind: 'noActive' };
701
+ const claim = interactions.claimOperation(sessionId, requestId, operationKey);
702
+ if (claim === 'resolved') {
703
+ const resolved = interactions.resolved(sessionId, requestId);
704
+ return resolved
705
+ ? { kind: 'replayed', resolvedAt: resolved.resolvedAt }
706
+ : { kind: 'staleOperation' };
707
+ }
708
+ if (claim === 'stale')
709
+ return { kind: 'staleOperation' };
710
+ if (claim === 'missing')
711
+ return { kind: 'noActive' };
712
+ // A durable capability belongs to the session/thread, not the
713
+ // relay process. A supported stopped writer is retryable;
714
+ // missing capability identifies a pre-rollout legacy thread.
715
+ if (sessions.find(sessionId)?.attentionToolCapability !== 'supported')
716
+ return { kind: 'legacyUnsupported' };
717
+ if (!runtime)
718
+ return { kind: 'writerUnavailable' };
719
+ if (!interactions.beginDelivery(sessionId, requestId, operationKey))
720
+ return { kind: 'staleOperation' };
721
+ const writer = runtime.attentionWriterState(sessionId, requestId);
722
+ if (writer === 'unavailable') {
723
+ interactions.retryDelivery(sessionId, requestId, operationKey);
724
+ return { kind: 'writerUnavailable' };
725
+ }
726
+ const resolvedAt = new Date().toISOString();
727
+ if (writer === 'cleared') {
728
+ if (!interactions.settleOperation(sessionId, requestId, operationKey, resolvedAt, 'failed'))
729
+ return { kind: 'staleOperation' };
730
+ publishAttentionSettlement(sessionId, requestId, resolvedAt, 'failed');
731
+ return { kind: 'writerCleared', resolvedAt };
732
+ }
733
+ if (!runtime.resolveServerRequest(sessionId, requestId, response)) {
734
+ // The state check and delivery are synchronous, but retain a
735
+ // defensive retry path for a future runtime implementation.
736
+ interactions.retryDelivery(sessionId, requestId, operationKey);
737
+ return { kind: 'writerUnavailable' };
738
+ }
739
+ if (!interactions.settleOperation(sessionId, requestId, operationKey, resolvedAt, 'answered'))
740
+ return { kind: 'staleOperation' };
741
+ const accepted = { kind: 'accepted', resolvedAt };
742
+ idempotency.put(scope, operationKey, 202, JSON.stringify({ kind: 'replayed', resolvedAt }));
743
+ publishAttentionSettlement(sessionId, requestId, resolvedAt, 'answered');
744
+ return accepted;
745
+ })();
746
+ attentionResolutionOperations.set(key, operation);
747
+ try {
748
+ return await operation;
749
+ }
750
+ finally {
751
+ attentionResolutionOperations.delete(key);
752
+ }
753
+ },
754
+ },
755
+ transitions: attentionTransitions,
756
+ },
421
757
  gitSummary: {
422
758
  workspaces,
423
759
  inspect: async (path) => {
@@ -453,10 +789,18 @@ export async function composeRelayApp(options) {
453
789
  throw error;
454
790
  }
455
791
  const detachActiveSessions = async () => {
456
- await mapWithConcurrency(sessions.list().filter((session) => session.threadId !== null), 2, async (session) => {
457
- if (session.desiredState === 'active')
792
+ await mapWithConcurrency(sessions
793
+ .list()
794
+ .filter((session) => persistedSessionIds.has(session.id) && session.threadId !== null), 2, async (session) => {
795
+ // The status lease must repopulate the authoritative plan projection before
796
+ // a durable coordinator is restored. Writer detachment is a process concern,
797
+ // not a human disable: a later fenced continuation can reacquire it safely.
798
+ if (session.desiredState === 'active') {
458
799
  saveSession(RelaySession.rehydrate(session).stop(new Date().toISOString()).snapshot);
800
+ await runtime?.release(session.id);
801
+ }
459
802
  await runtime?.watchPlanStatus(session);
803
+ autopilot.restore(session.id);
460
804
  });
461
805
  };
462
806
  app.addHook('onListen', async () => {
@@ -466,9 +810,22 @@ export async function composeRelayApp(options) {
466
810
  await detachActiveSessions();
467
811
  });
468
812
  app.addHook('onClose', async () => {
813
+ closing = true;
469
814
  planMeasurementRefresh?.stopAll();
470
- for (const session of sessions.list())
471
- dismissPendingInteractions(session.id, new Date().toISOString());
815
+ for (const session of sessions.list()) {
816
+ autopilot.dispose(session.id);
817
+ activity.dispose(session.id);
818
+ // Relay shutdown only releases this process's writer. A typed attention
819
+ // request remains a durable human-visible blocker for the next relay
820
+ // instance; only an app-server-cleared request is a failed audit outcome.
821
+ for (const interaction of interactions.list(session.id)) {
822
+ if (interaction.kind === 'orgPlanAttention')
823
+ continue;
824
+ const occurredAt = new Date().toISOString();
825
+ if (interactions.resolve(session.id, interaction.requestId, occurredAt, 'dismissed'))
826
+ publishInteractionResolved(session.id, interaction.requestId, occurredAt, 'dismissed');
827
+ }
828
+ }
472
829
  runtime?.stopAll();
473
830
  planStatusSource.closeAll();
474
831
  database.close();