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