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
@@ -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-DgD-0I3M.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,7 +32,9 @@ 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 { toPendingInteraction } from './platform/codex/server-request.js';
35
+ import { AgentActivityRegistry } from './features/agent-activity/registry.js';
36
+ import { decodeAgentActivityFact } from './platform/codex/activity-facts.js';
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';
38
40
  import { FilesystemSkillProfileStore } from './platform/skills/filesystem-skill-profile-store.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());
@@ -113,9 +246,51 @@ export async function composeRelayApp(options) {
113
246
  const gitSummaries = new GitSummaryCache(inspectGit);
114
247
  let recoverExitedSession = () => { };
115
248
  let planMeasurementRefresh;
116
- const runtime = options.startAppServers
249
+ const publishInteractionResolved = (sessionId, requestId, occurredAt, outcome) => {
250
+ const interaction = interactions
251
+ .snapshot(sessionId)
252
+ .find((item) => item.requestId === requestId);
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);
268
+ };
269
+ const dismissPendingInteractions = (sessionId, occurredAt, outcome = 'dismissed') => {
270
+ for (const interaction of interactions.list(sessionId)) {
271
+ if (interactions.resolve(sessionId, interaction.requestId, occurredAt, outcome))
272
+ publishInteractionResolved(sessionId, interaction.requestId, occurredAt, outcome);
273
+ }
274
+ };
275
+ let closing = false;
276
+ let runtime = null;
277
+ runtime = options.startAppServers
117
278
  ? new CodexSessionRuntime(options.launchAppServer ?? launchCodexAppServer, undefined, (sessionId, notification) => {
118
279
  const occurredAt = new Date().toISOString();
280
+ const activityFact = decodeAgentActivityFact(sessionId, occurredAt, notification);
281
+ if (activityFact)
282
+ activity.observe(activityFact);
283
+ const resolvedRequestId = resolvedServerRequestId(notification);
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
+ }
119
294
  const currentSession = sessions.find(sessionId);
120
295
  const normalized = normalizeCodexNotification(sessionId, 0, occurredAt, notification, currentSession?.workspacePath, currentSession?.activeTurnId);
121
296
  if (!normalized)
@@ -127,6 +302,7 @@ export async function composeRelayApp(options) {
127
302
  if (session && turnId && session.activeTurnId === turnId) {
128
303
  completedSession = RelaySession.rehydrate(session).completeTurn(turnId, occurredAt).snapshot;
129
304
  sessions.save(completedSession);
305
+ autopilot.turnCompleted(sessionId);
130
306
  }
131
307
  planMeasurementRefresh?.refreshNow(sessionId);
132
308
  }
@@ -144,11 +320,28 @@ export async function composeRelayApp(options) {
144
320
  requestedAt: new Date().toISOString(),
145
321
  };
146
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
+ });
147
331
  const updated = RelaySession.rehydrate(session).requestInteraction(interaction, new Date().toISOString()).snapshot;
148
332
  sessions.save(updated);
149
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);
150
337
  return true;
151
- }, (sessionId) => recoverExitedSession(sessionId), resolveSkills, planStatusSource, (sessionId, update) => {
338
+ }, (sessionId) => {
339
+ activity.disconnected(sessionId, new Date().toISOString());
340
+ dismissPendingInteractions(sessionId, new Date().toISOString(), 'failed');
341
+ recoverExitedSession(sessionId);
342
+ }, resolveSkills, planStatusSource, (sessionId, update) => {
343
+ if (closing)
344
+ return;
152
345
  supervisedPlans.accept(sessionId, update);
153
346
  planMeasurementRefresh?.accept(sessionId, update);
154
347
  if (update.kind === 'updated') {
@@ -166,6 +359,7 @@ export async function composeRelayApp(options) {
166
359
  }
167
360
  events.publish(journal.append(sessionId, 'plan.updated', { plan: update.plan, reason: update.reason }, occurredAt));
168
361
  }
362
+ autopilot.planUpdated(sessionId);
169
363
  }, options.planMeasurementBaseUrl, 30_000, 64, root)
170
364
  : null;
171
365
  if (runtime && planMeasurementHelperPath) {
@@ -177,8 +371,19 @@ export async function composeRelayApp(options) {
177
371
  }, (planPath, stepId, snapshot) => checkpointPlanMeasurement(planMeasurementHelperPath, planPath, stepId, snapshot));
178
372
  }
179
373
  const saveSession = (session) => {
374
+ const prior = sessions.find(session.id);
180
375
  sessions.save(session);
181
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);
182
387
  };
183
388
  if (runtime) {
184
389
  const supervisor = new SessionSupervisor(async (sessionId) => {
@@ -187,7 +392,8 @@ export async function composeRelayApp(options) {
187
392
  return;
188
393
  const recovering = RelaySession.rehydrate(session).beginRecovery(new Date().toISOString()).snapshot;
189
394
  saveSession(recovering);
190
- saveSession(await runtime.restore(recovering, new Date().toISOString()));
395
+ const restored = await runtime.restore(recovering, new Date().toISOString());
396
+ saveSession(restored);
191
397
  }, (sessionId) => {
192
398
  const session = sessions.find(sessionId);
193
399
  if (session)
@@ -198,8 +404,10 @@ export async function composeRelayApp(options) {
198
404
  recoverExitedSession = (sessionId) => {
199
405
  supervisor.cancel(sessionId);
200
406
  const session = sessions.find(sessionId);
201
- if (session)
407
+ if (session) {
408
+ autopilot.cancel(sessionId, 'sessionEnded');
202
409
  saveSession(RelaySession.rehydrate(session).stop(new Date().toISOString()).snapshot);
410
+ }
203
411
  };
204
412
  }
205
413
  let authorization;
@@ -291,16 +499,46 @@ export async function composeRelayApp(options) {
291
499
  skillCatalog,
292
500
  defaultSkillProfile: options.explicitSkillProfile,
293
501
  activate: runtime
294
- ? async (session, settings) => runtime.start(session, new Date().toISOString(), settings)
502
+ ? async (session, settings) => {
503
+ const now = new Date().toISOString();
504
+ dismissPendingInteractions(session.id, now);
505
+ const started = await runtime.start(session, now, settings);
506
+ return started;
507
+ }
295
508
  : undefined,
296
509
  startTurn: runtime
297
- ? 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
+ }
298
514
  : undefined,
299
515
  ensureWriter: runtime
300
- ? (session) => runtime.ensureWriter(session, new Date().toISOString())
516
+ ? (session) => {
517
+ dismissPendingInteractions(session.id, new Date().toISOString());
518
+ return runtime.ensureWriter(session, new Date().toISOString());
519
+ }
301
520
  : undefined,
302
- releaseWriter: runtime ? (id) => runtime.release(id) : undefined,
303
- onTurnStarted: (session) => planMeasurementRefresh?.refreshNow(session.id),
521
+ releaseWriter: runtime
522
+ ? (id) => {
523
+ dismissPendingInteractions(id, new Date().toISOString());
524
+ return runtime.release(id);
525
+ }
526
+ : undefined,
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),
304
542
  models,
305
543
  readHistory: runtime ? (session) => runtime.readHistory(session) : undefined,
306
544
  currentSequence: (sessionId) => journal.since(sessionId, 0).at(-1)?.sequence ?? 0,
@@ -308,8 +546,20 @@ export async function composeRelayApp(options) {
308
546
  ? (session, turnId) => runtime.interruptTurn(session, turnId)
309
547
  : undefined,
310
548
  restore: runtime
311
- ? (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
+ }
312
561
  : undefined,
562
+ ownsWriter: runtime ? (id) => runtime.ownsWriter(id) : undefined,
313
563
  promoteRecent: runtime
314
564
  ? (thread) => promoteRecentThread(thread, {
315
565
  createId: randomUUID,
@@ -319,23 +569,41 @@ export async function composeRelayApp(options) {
319
569
  read: (session) => runtime.readHistory(session),
320
570
  })
321
571
  : undefined,
322
- release: (session) => RelaySession.rehydrate(session).release(new Date().toISOString()).snapshot,
323
- 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
+ },
324
581
  idempotency,
325
582
  close: runtime
326
583
  ? (id) => {
584
+ autopilot.cancel(id, 'sessionEnded');
327
585
  planMeasurementRefresh?.stop(id);
586
+ dismissPendingInteractions(id, new Date().toISOString());
587
+ activity.dispose(id);
328
588
  return runtime.release(id);
329
589
  }
330
590
  : undefined,
331
591
  replyInteraction: runtime
332
- ? (sessionId, requestId, value) => runtime.resolveServerRequest(sessionId, requestId, value)
592
+ ? (sessionId, requestId, value) => runtime.resolveServerRequest(sessionId, requestId, value) ? 'accepted' : 'cleared'
333
593
  : undefined,
334
594
  interactionResolved: (sessionId, requestId, occurredAt, outcome) => {
335
- const interaction = interactions
336
- .snapshot(sessionId)
337
- .find((item) => item.requestId === requestId);
338
- events.publish(journal.append(sessionId, 'interaction.resolved', { requestId, turnId: interaction?.turnId ?? null, resolvedAt: occurredAt, outcome }, occurredAt));
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
+ });
606
+ publishInteractionResolved(sessionId, requestId, occurredAt, outcome);
339
607
  },
340
608
  },
341
609
  sessionEvents: {
@@ -355,12 +623,14 @@ export async function composeRelayApp(options) {
355
623
  removeStatus: (id) => planStatusSource.remove(id, supervisedPlans.identity(id) ?? undefined),
356
624
  clear: (id) => supervisedPlans.clear(id),
357
625
  closed: (id) => {
626
+ autopilot.cancel(id, 'planRemoved');
358
627
  planMeasurementRefresh?.stop(id);
359
628
  const occurredAt = new Date().toISOString();
360
629
  events.publish(journal.append(id, 'plan.closed', {}, occurredAt));
361
630
  },
362
631
  },
363
632
  workspacePlanRoutes: { workspaces, plans: workspacePlanCatalog },
633
+ autopilot,
364
634
  ...(runtime
365
635
  ? {
366
636
  planMeasurementRoutes: {
@@ -386,9 +656,108 @@ export async function composeRelayApp(options) {
386
656
  return false;
387
657
  if (interaction.kind === 'quiz')
388
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;
389
663
  return isValidInteractionResponse(interaction.kind, value);
390
664
  },
391
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
+ },
392
761
  gitSummary: {
393
762
  workspaces,
394
763
  inspect: async (path) => {
@@ -424,10 +793,18 @@ export async function composeRelayApp(options) {
424
793
  throw error;
425
794
  }
426
795
  const detachActiveSessions = async () => {
427
- await mapWithConcurrency(sessions.list().filter((session) => session.threadId !== null), 2, async (session) => {
428
- 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') {
429
803
  saveSession(RelaySession.rehydrate(session).stop(new Date().toISOString()).snapshot);
804
+ await runtime?.release(session.id);
805
+ }
430
806
  await runtime?.watchPlanStatus(session);
807
+ autopilot.restore(session.id);
431
808
  });
432
809
  };
433
810
  app.addHook('onListen', async () => {
@@ -437,7 +814,22 @@ export async function composeRelayApp(options) {
437
814
  await detachActiveSessions();
438
815
  });
439
816
  app.addHook('onClose', async () => {
817
+ closing = true;
440
818
  planMeasurementRefresh?.stopAll();
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
+ }
441
833
  runtime?.stopAll();
442
834
  planStatusSource.closeAll();
443
835
  database.close();