gestalt-mobile 0.17.2 → 0.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/README.md +5 -1
  2. package/dist/client/assets/index-B0Uq4OB0.css +1 -0
  3. package/dist/client/assets/index-orfZBEaD.js +27 -0
  4. package/dist/client/index.html +2 -2
  5. package/dist/server/server/app.js +6 -0
  6. package/dist/server/server/composition.js +413 -21
  7. package/dist/server/server/features/agent-activity/model.js +197 -0
  8. package/dist/server/server/features/agent-activity/registry.js +155 -0
  9. package/dist/server/server/features/autopilot/application/policy.js +55 -0
  10. package/dist/server/server/features/autopilot/application/ports.js +6 -0
  11. package/dist/server/server/features/autopilot/application/service.js +542 -0
  12. package/dist/server/server/features/autopilot/domain/autopilot-session.js +40 -0
  13. package/dist/server/server/features/autopilot/register-routes.js +9 -0
  14. package/dist/server/server/features/autopilot/toggle/endpoint.js +32 -0
  15. package/dist/server/server/features/org-plan-attention/application/ports.js +6 -0
  16. package/dist/server/server/features/org-plan-attention/get-active/endpoint.js +22 -0
  17. package/dist/server/server/features/org-plan-attention/register-routes.js +11 -0
  18. package/dist/server/server/features/org-plan-attention/resolve/endpoint.js +42 -0
  19. package/dist/server/server/features/plans/application/parse-supervised-plan.js +4 -0
  20. package/dist/server/server/features/sessions/get-history/endpoint.js +62 -2
  21. package/dist/server/server/features/sessions/get-history/history-mapper.js +17 -5
  22. package/dist/server/server/features/sessions/get-session/endpoint.js +3 -1
  23. package/dist/server/server/features/sessions/interaction/kind.js +1 -0
  24. package/dist/server/server/features/sessions/interaction/response-validator.js +5 -1
  25. package/dist/server/server/features/sessions/list-sessions/endpoint.js +2 -0
  26. package/dist/server/server/features/sessions/model/relay-session.js +3 -0
  27. package/dist/server/server/features/sessions/refresh-activity/endpoint.js +14 -0
  28. package/dist/server/server/features/sessions/register-routes.js +15 -2
  29. package/dist/server/server/features/sessions/respond-interaction/endpoint.js +15 -2
  30. package/dist/server/server/features/sessions/restore-session/endpoint.js +21 -5
  31. package/dist/server/server/features/skills/list-available/endpoint.js +3 -4
  32. package/dist/server/server/features/skills/model/skill-profile.js +13 -5
  33. package/dist/server/server/platform/codex/activity-facts.js +88 -0
  34. package/dist/server/server/platform/codex/server-request.js +21 -4
  35. package/dist/server/server/platform/codex/session-runtime.js +114 -22
  36. package/dist/server/server/platform/persistence/migrate.js +16 -1
  37. package/dist/server/server/platform/persistence/sqlite-autopilot-store.js +151 -0
  38. package/dist/server/server/platform/persistence/sqlite-event-journal.js +92 -4
  39. package/dist/server/server/platform/persistence/sqlite-pending-interaction-store.js +62 -3
  40. package/dist/server/server/platform/persistence/sqlite-session-repository.js +5 -2
  41. package/dist/server/shared/contracts/org-plan-attention.js +101 -0
  42. package/package.json +1 -1
  43. package/dist/client/assets/index-DgD-0I3M.js +0 -25
  44. package/dist/client/assets/index-e2lJ5XqL.css +0 -1
@@ -0,0 +1,542 @@
1
+ /*
2
+ * Copyright (C) 2026 Dyne.org foundation
3
+ * Designed by Denis Roio <jaromil@dyne.org>
4
+ * SPDX-License-Identifier: AGPL-3.0-or-later
5
+ */
6
+ import { autopilotSnapshot, disabledAutopilot, } from '../domain/autopilot-session.js';
7
+ import { decideAutopilot, executionComplete } from './policy.js';
8
+ export class AutopilotCoordinator {
9
+ deps;
10
+ timers = new Map();
11
+ completionTimers = new Map();
12
+ publishedSnapshots = new Map();
13
+ /** Serializes asynchronous watchdog and timer work per relay session. */
14
+ operations = new Map();
15
+ constructor(deps) {
16
+ this.deps = deps;
17
+ }
18
+ snapshot(sessionId) {
19
+ return autopilotSnapshot(this.deps.store.find(sessionId) ?? disabledAutopilot(sessionId, this.deps.now()), this.deps.policy.retryLimit);
20
+ }
21
+ controlIds(sessionId) {
22
+ return this.deps.store.controlIds(sessionId);
23
+ }
24
+ acceptedControlTurns(sessionId) {
25
+ return this.deps.store.acceptedControlTurns?.(sessionId) ?? new Map();
26
+ }
27
+ /** Rehydrates only actionable durable state; terminal rows intentionally create no work. */
28
+ restore(sessionId) {
29
+ this.flushOutbox(sessionId);
30
+ const state = this.deps.store.find(sessionId);
31
+ const session = this.deps.session(sessionId);
32
+ if (!state ||
33
+ !session ||
34
+ !session.threadId ||
35
+ !state.requestedEnabled ||
36
+ ['disabled', 'attentionRequired', 'completed'].includes(state.state))
37
+ return;
38
+ // A relay restart deliberately drops its writer before rehydrating the
39
+ // coordinator. The plan-status watcher is authoritative and asynchronous,
40
+ // so retaining enabled durable state until it supplies the projection is
41
+ // safer than interpreting this short bootstrap gap as plan removal.
42
+ if (!this.deps.plan(sessionId))
43
+ return;
44
+ const control = state.lastControlId
45
+ ? this.deps.store.findControl(sessionId, state.lastControlId)
46
+ : null;
47
+ // An issued command may have crossed the app-server acceptance boundary
48
+ // immediately before process loss. Replaying it risks a second logical turn.
49
+ if (control?.status === 'issued') {
50
+ // The runtime persists the accepted active turn before returning from its
51
+ // start capability. Recover that durable acceptance as a single audited
52
+ // control result; the outbox/journal key makes a crash before ack replay
53
+ // the same event rather than manufacture a second turn or audit record.
54
+ if (session.activeTurnId) {
55
+ this.updateControl(sessionId, control.controlId, 'started', null, session.activeTurnId, 'autopilot.turn-started');
56
+ return;
57
+ }
58
+ this.persist({
59
+ ...state,
60
+ state: 'attentionRequired',
61
+ requestedEnabled: false,
62
+ generation: state.generation + 1,
63
+ nextEvaluationAt: null,
64
+ stopReason: 'reconcileFailed',
65
+ updatedAt: this.deps.now(),
66
+ });
67
+ return;
68
+ }
69
+ if (state.state === 'backoff' && state.nextEvaluationAt) {
70
+ this.arm(sessionId, state.generation, state.nextEvaluationAt);
71
+ return;
72
+ }
73
+ this.evaluate(sessionId);
74
+ }
75
+ dispose(sessionId) {
76
+ this.cancelTimer(sessionId);
77
+ }
78
+ enable(sessionId) {
79
+ const session = this.deps.session(sessionId);
80
+ if (!session || !session.threadId || !['ready', 'turnActive'].includes(session.state))
81
+ return { code: 'AUTOPILOT_SESSION_UNAVAILABLE' };
82
+ const currentPlan = this.deps.plan(sessionId);
83
+ if (!currentPlan)
84
+ return { code: 'AUTOPILOT_PLAN_REQUIRED' };
85
+ if (executionComplete(currentPlan.plan))
86
+ return { code: 'AUTOPILOT_PLAN_COMPLETE' };
87
+ const now = this.deps.now();
88
+ const prior = this.deps.store.find(sessionId) ?? disabledAutopilot(sessionId, now);
89
+ const nextFingerprint = fingerprint(currentPlan.plan);
90
+ if (prior.requestedEnabled &&
91
+ prior.planIdentity === currentPlan.identity &&
92
+ prior.planFingerprint === nextFingerprint &&
93
+ prior.state !== 'attentionRequired')
94
+ return this.snapshot(sessionId);
95
+ const next = {
96
+ ...prior,
97
+ state: 'monitoring',
98
+ requestedEnabled: true,
99
+ planIdentity: currentPlan.identity,
100
+ planFingerprint: nextFingerprint,
101
+ generation: prior.generation + 1,
102
+ consecutiveNoProgress: 0,
103
+ nextEvaluationAt: null,
104
+ stopReason: null,
105
+ updatedAt: now,
106
+ };
107
+ this.persist(next);
108
+ this.evaluate(sessionId);
109
+ return this.snapshot(sessionId);
110
+ }
111
+ disable(sessionId) {
112
+ const now = this.deps.now();
113
+ const prior = this.deps.store.find(sessionId) ?? disabledAutopilot(sessionId, now);
114
+ if (!prior.requestedEnabled && prior.state === 'disabled')
115
+ return this.snapshot(sessionId);
116
+ const next = {
117
+ ...prior,
118
+ state: 'disabled',
119
+ requestedEnabled: false,
120
+ generation: prior.generation + 1,
121
+ nextEvaluationAt: null,
122
+ stopReason: 'manualDisabled',
123
+ updatedAt: now,
124
+ };
125
+ this.persist(next);
126
+ this.cancelTimer(sessionId);
127
+ return this.snapshot(sessionId);
128
+ }
129
+ cancel(sessionId, reason) {
130
+ const prior = this.deps.store.find(sessionId);
131
+ if (!prior)
132
+ return;
133
+ this.persist({
134
+ ...prior,
135
+ state: 'disabled',
136
+ requestedEnabled: false,
137
+ generation: prior.generation + 1,
138
+ nextEvaluationAt: null,
139
+ stopReason: reason,
140
+ updatedAt: this.deps.now(),
141
+ });
142
+ this.cancelTimer(sessionId);
143
+ }
144
+ evaluate(sessionId) {
145
+ const prior = this.deps.store.find(sessionId);
146
+ if (!prior)
147
+ return this.snapshot(sessionId);
148
+ const plan = this.deps.plan(sessionId);
149
+ const session = this.deps.session(sessionId);
150
+ const decision = decideAutopilot({
151
+ state: prior,
152
+ plan: plan?.plan ?? null,
153
+ planIdentity: plan?.identity,
154
+ planFingerprint: plan ? fingerprint(plan.plan) : null,
155
+ activity: this.deps.activity(sessionId),
156
+ hasPendingInteraction: this.deps.pendingInteraction(sessionId),
157
+ hasActiveAttention: prior.state === 'attentionRequired',
158
+ lastTurnOutcome: this.lastTurnOutcome(sessionId, prior.lastControlId),
159
+ now: this.deps.now(),
160
+ policy: this.deps.policy,
161
+ });
162
+ const now = this.deps.now();
163
+ this.deps.diagnostic?.(sessionId, decision.kind);
164
+ let next = prior;
165
+ switch (decision.kind) {
166
+ case 'observe':
167
+ break;
168
+ case 'reconcile':
169
+ next = { ...prior, state: 'monitoring', nextEvaluationAt: null, updatedAt: now };
170
+ this.enqueue(sessionId, () => this.reconcile(sessionId, prior.generation));
171
+ break;
172
+ case 'scheduleContinuation':
173
+ if (session?.activeTurnId || this.deps.pendingInteraction(sessionId))
174
+ break;
175
+ const scheduledControlId = this.deps.nextControlId(sessionId, prior.generation);
176
+ next = {
177
+ ...prior,
178
+ state: 'backoff',
179
+ nextEvaluationAt: decision.at,
180
+ lastControlId: scheduledControlId,
181
+ updatedAt: now,
182
+ };
183
+ const control = {
184
+ sessionId,
185
+ controlId: scheduledControlId,
186
+ status: 'scheduled',
187
+ createdAt: now,
188
+ updatedAt: now,
189
+ failureCode: null,
190
+ turnId: null,
191
+ };
192
+ this.persist(next, control, [
193
+ {
194
+ sessionId,
195
+ type: 'autopilot.continuation-scheduled',
196
+ payload: { controlId: scheduledControlId },
197
+ occurredAt: now,
198
+ },
199
+ ]);
200
+ next = prior;
201
+ this.arm(sessionId, prior.generation, decision.at);
202
+ break;
203
+ case 'requestAttention':
204
+ next = {
205
+ ...prior,
206
+ state: 'attentionRequired',
207
+ requestedEnabled: false,
208
+ generation: prior.generation + 1,
209
+ nextEvaluationAt: null,
210
+ stopReason: decision.reason,
211
+ updatedAt: now,
212
+ };
213
+ break;
214
+ case 'complete':
215
+ next = {
216
+ ...prior,
217
+ state: 'completed',
218
+ requestedEnabled: false,
219
+ generation: prior.generation + 1,
220
+ nextEvaluationAt: null,
221
+ stopReason: 'planComplete',
222
+ updatedAt: now,
223
+ };
224
+ break;
225
+ case 'disable':
226
+ next = {
227
+ ...prior,
228
+ state: 'disabled',
229
+ requestedEnabled: false,
230
+ generation: prior.generation + 1,
231
+ nextEvaluationAt: null,
232
+ stopReason: decision.reason,
233
+ updatedAt: now,
234
+ };
235
+ break;
236
+ }
237
+ if (decision.kind === 'requestAttention' ||
238
+ decision.kind === 'complete' ||
239
+ decision.kind === 'disable')
240
+ this.cancelTimer(sessionId);
241
+ if (next !== prior)
242
+ this.persist(next);
243
+ return this.snapshot(sessionId);
244
+ }
245
+ planUpdated(sessionId) {
246
+ const prior = this.deps.store.find(sessionId);
247
+ const plan = this.deps.plan(sessionId);
248
+ if (!prior || !plan)
249
+ return;
250
+ if (prior.planIdentity && prior.planIdentity !== plan.identity) {
251
+ this.cancel(sessionId, 'planReplaced');
252
+ return;
253
+ }
254
+ const nextFingerprint = fingerprint(plan.plan);
255
+ if (prior.planFingerprint !== nextFingerprint) {
256
+ const now = this.deps.now();
257
+ this.persist({
258
+ ...prior,
259
+ planIdentity: plan.identity,
260
+ planFingerprint: nextFingerprint,
261
+ consecutiveNoProgress: 0,
262
+ updatedAt: now,
263
+ }, undefined, [
264
+ {
265
+ sessionId,
266
+ type: 'autopilot.progress-reset',
267
+ payload: { reason: 'planUpdated' },
268
+ occurredAt: now,
269
+ },
270
+ ]);
271
+ }
272
+ this.evaluate(sessionId);
273
+ }
274
+ turnCompleted(sessionId) {
275
+ this.completionTimers.get(sessionId)?.();
276
+ this.completionTimers.set(sessionId, this.deps.schedule(() => {
277
+ this.completionTimers.delete(sessionId);
278
+ this.evaluate(sessionId);
279
+ }, this.deps.policy.quiescenceMs));
280
+ }
281
+ manualSend(sessionId) {
282
+ this.cancelTimer(sessionId);
283
+ const prior = this.deps.store.find(sessionId);
284
+ if (!prior || !prior.requestedEnabled)
285
+ return;
286
+ this.persist({
287
+ ...prior,
288
+ state: 'monitoring',
289
+ generation: prior.generation + 1,
290
+ nextEvaluationAt: null,
291
+ lastControlId: null,
292
+ updatedAt: this.deps.now(),
293
+ });
294
+ }
295
+ recordControlIssued(sessionId, controlId) {
296
+ const prior = this.deps.store.find(sessionId);
297
+ if (!prior ||
298
+ !prior.requestedEnabled ||
299
+ prior.lastControlId !== controlId ||
300
+ prior.state !== 'backoff')
301
+ return false;
302
+ const now = this.deps.now();
303
+ const next = {
304
+ ...prior,
305
+ lastControlId: controlId,
306
+ state: 'monitoring',
307
+ nextEvaluationAt: null,
308
+ consecutiveNoProgress: prior.consecutiveNoProgress + 1,
309
+ updatedAt: now,
310
+ };
311
+ const events = [
312
+ ...this.snapshotEvents(next),
313
+ { sessionId, type: 'autopilot.control-issued', payload: { controlId }, occurredAt: now },
314
+ ];
315
+ const control = this.deps.store.claimControlIssued
316
+ ? this.deps.store.claimControlIssued(sessionId, controlId, now, next, events)
317
+ : this.legacyClaim(sessionId, controlId, next, events);
318
+ if (!control)
319
+ return false;
320
+ this.publishedSnapshots.set(sessionId, this.semanticSnapshot(next));
321
+ this.flushOutbox(sessionId);
322
+ return true;
323
+ }
324
+ persist(next, control, events = []) {
325
+ const snapshotEvents = this.snapshotEvents(next);
326
+ this.commit({
327
+ state: next,
328
+ ...(control ? { control } : {}),
329
+ events: [...snapshotEvents, ...events],
330
+ });
331
+ if (snapshotEvents.length)
332
+ this.publishedSnapshots.set(next.sessionId, this.semanticSnapshot(next));
333
+ this.flushOutbox(next.sessionId);
334
+ }
335
+ arm(sessionId, generation, at) {
336
+ this.cancelTimer(sessionId);
337
+ this.timers.set(sessionId, this.deps.schedule(() => this.enqueue(sessionId, () => this.fire(sessionId, generation)), Math.max(0, Date.parse(at) - Date.parse(this.deps.now()))));
338
+ }
339
+ async fire(sessionId, generation) {
340
+ this.timers.delete(sessionId);
341
+ const current = this.deps.store.find(sessionId);
342
+ const session = this.deps.session(sessionId);
343
+ if (!current ||
344
+ current.generation !== generation ||
345
+ !current.requestedEnabled ||
346
+ session?.activeTurnId ||
347
+ this.deps.pendingInteraction(sessionId))
348
+ return;
349
+ const id = current.lastControlId;
350
+ if (!id)
351
+ return;
352
+ if (!this.recordControlIssued(sessionId, id))
353
+ return;
354
+ try {
355
+ await this.deps.turnStarter.start(sessionId, id, generation);
356
+ this.updateControl(sessionId, id, 'started', null, this.deps.session(sessionId)?.activeTurnId ?? null, 'autopilot.turn-started');
357
+ }
358
+ catch (error) {
359
+ // The runtime persists an accepted turn before returning to this coordinator.
360
+ // A process-loss/fault seam after that durability point is not a failed start:
361
+ // promote the existing control and let its outbox audit replay exactly once.
362
+ const acceptedTurnId = this.deps.session(sessionId)?.activeTurnId;
363
+ if (acceptedTurnId) {
364
+ this.updateControl(sessionId, id, 'started', null, acceptedTurnId, 'autopilot.turn-started');
365
+ return;
366
+ }
367
+ const failureCode = startFailureCode(error);
368
+ this.updateControl(sessionId, id, 'failed', failureCode, null, 'autopilot.turn-failed');
369
+ // Availability, permission, and dependency failures cannot be repaired
370
+ // by another identical synthetic turn. They remain a durable, explicit
371
+ // human stop; unknown transport failures still consume the retry budget.
372
+ if (failureCode === 'START_UNAVAILABLE') {
373
+ const latest = this.deps.store.find(sessionId);
374
+ if (latest?.requestedEnabled)
375
+ this.persist({
376
+ ...latest,
377
+ state: 'attentionRequired',
378
+ requestedEnabled: false,
379
+ generation: latest.generation + 1,
380
+ nextEvaluationAt: null,
381
+ stopReason: 'attentionRequired',
382
+ updatedAt: this.deps.now(),
383
+ });
384
+ return;
385
+ }
386
+ this.evaluate(sessionId);
387
+ }
388
+ }
389
+ cancelTimer(sessionId) {
390
+ this.timers.get(sessionId)?.();
391
+ this.timers.delete(sessionId);
392
+ this.completionTimers.get(sessionId)?.();
393
+ this.completionTimers.delete(sessionId);
394
+ }
395
+ updateControl(sessionId, controlId, status, failureCode, turnId = null, eventType) {
396
+ const control = this.deps.store.findControl(sessionId, controlId);
397
+ if (!control)
398
+ return;
399
+ const updated = {
400
+ ...control,
401
+ status,
402
+ failureCode,
403
+ turnId,
404
+ updatedAt: this.deps.now(),
405
+ };
406
+ const occurredAt = this.deps.now();
407
+ this.commit({
408
+ control: updated,
409
+ events: eventType
410
+ ? [
411
+ {
412
+ sessionId,
413
+ type: eventType,
414
+ payload: eventType === 'autopilot.turn-failed'
415
+ ? { controlId, code: failureCode }
416
+ : { controlId },
417
+ occurredAt,
418
+ },
419
+ ]
420
+ : [],
421
+ });
422
+ this.flushOutbox(sessionId);
423
+ }
424
+ semanticSnapshot(next) {
425
+ return JSON.stringify({
426
+ ...autopilotSnapshot(next, this.deps.policy.retryLimit),
427
+ updatedAt: '',
428
+ });
429
+ }
430
+ snapshotEvents(next) {
431
+ const semantic = this.semanticSnapshot(next);
432
+ if (this.publishedSnapshots.get(next.sessionId) === semantic)
433
+ return [];
434
+ return [
435
+ {
436
+ sessionId: next.sessionId,
437
+ type: 'autopilot.updated',
438
+ payload: autopilotSnapshot(next, this.deps.policy.retryLimit),
439
+ occurredAt: next.updatedAt,
440
+ },
441
+ ];
442
+ }
443
+ flushOutbox(sessionId) {
444
+ for (const event of this.deps.store.drainOutbox?.(sessionId) ?? []) {
445
+ this.deps.publish(event.sessionId, event.type, event.payload, event.occurredAt, event.id);
446
+ this.deps.store.acknowledgeOutbox?.(event.id);
447
+ }
448
+ }
449
+ commit(input) {
450
+ if (this.deps.store.commit) {
451
+ this.deps.store.commit(input);
452
+ return;
453
+ }
454
+ // Narrow in-memory test-double adapter. Real composition always supplies
455
+ // the transactional implementation above; this path cannot be reached by SQLite.
456
+ if (input.state)
457
+ this.deps.store.save(input.state);
458
+ if (input.control)
459
+ this.deps.store.saveControl?.(input.control);
460
+ for (const event of input.events)
461
+ this.deps.publish(event.sessionId, event.type, event.payload, event.occurredAt);
462
+ }
463
+ legacyClaim(sessionId, controlId, state, events) {
464
+ const control = this.deps.store.findControl(sessionId, controlId);
465
+ if (!control || control.status !== 'scheduled')
466
+ return null;
467
+ this.commit({
468
+ state,
469
+ control: { ...control, status: 'issued', updatedAt: state.updatedAt },
470
+ events,
471
+ });
472
+ return { ...control, status: 'issued', updatedAt: state.updatedAt };
473
+ }
474
+ lastTurnOutcome(sessionId, controlId) {
475
+ if (!controlId)
476
+ return undefined;
477
+ const control = this.deps.store.findControl(sessionId, controlId);
478
+ if (control?.status === 'failed')
479
+ return 'failed';
480
+ if (control?.status === 'started')
481
+ return 'completed';
482
+ return 'unknown';
483
+ }
484
+ async reconcile(sessionId, generation) {
485
+ try {
486
+ const result = await this.deps.reconcile(sessionId);
487
+ const current = this.deps.store.find(sessionId);
488
+ if (!result.compatible || !current || current.generation !== generation)
489
+ throw new Error('INCOMPATIBLE');
490
+ // A compatible reconciliation normally refreshes the activity projection.
491
+ // Do not feed a still-stale projection straight back into reconciliation:
492
+ // that would create an unbounded microtask loop with no timer or event to
493
+ // yield to. A later activity/watchdog event will evaluate it again.
494
+ if (this.deps.activity(sessionId)?.confidence === 'fresh')
495
+ this.evaluate(sessionId);
496
+ }
497
+ catch {
498
+ const current = this.deps.store.find(sessionId);
499
+ if (!current || current.generation !== generation)
500
+ return;
501
+ this.persist({
502
+ ...current,
503
+ state: 'attentionRequired',
504
+ requestedEnabled: false,
505
+ generation: current.generation + 1,
506
+ nextEvaluationAt: null,
507
+ stopReason: 'reconcileFailed',
508
+ updatedAt: this.deps.now(),
509
+ });
510
+ }
511
+ }
512
+ enqueue(sessionId, operation) {
513
+ const previous = this.operations.get(sessionId);
514
+ // Start the first operation synchronously through its pre-await safety
515
+ // checks; later operations serialize behind it for this session.
516
+ const next = previous ? previous.catch(() => undefined).then(operation) : operation();
517
+ this.operations.set(sessionId, next);
518
+ void next.finally(() => {
519
+ if (this.operations.get(sessionId) === next)
520
+ this.operations.delete(sessionId);
521
+ });
522
+ return next;
523
+ }
524
+ }
525
+ function startFailureCode(error) {
526
+ const code = error && typeof error === 'object' && 'code' in error && typeof error.code === 'string'
527
+ ? error.code
528
+ : error instanceof Error
529
+ ? error.message
530
+ : '';
531
+ return /(?:UNAVAILABLE|PERMISSION|DEPENDENCY|CODEX_SESSION_NOT_RUNNING|WRITER_)/.test(code)
532
+ ? 'START_UNAVAILABLE'
533
+ : 'START_FAILED';
534
+ }
535
+ function fingerprint(plan) {
536
+ return JSON.stringify(plan.steps.map((step) => [
537
+ step.id,
538
+ step.state,
539
+ step.reviewStatus,
540
+ step.children.map((child) => [child.id, child.state]),
541
+ ]));
542
+ }
@@ -0,0 +1,40 @@
1
+ /*
2
+ * Copyright (C) 2026 Dyne.org foundation
3
+ * Designed by Denis Roio <jaromil@dyne.org>
4
+ * SPDX-License-Identifier: AGPL-3.0-or-later
5
+ */
6
+ export function disabledAutopilot(sessionId, now) {
7
+ return {
8
+ sessionId,
9
+ state: 'disabled',
10
+ requestedEnabled: false,
11
+ planIdentity: null,
12
+ planFingerprint: null,
13
+ generation: 0,
14
+ consecutiveNoProgress: 0,
15
+ nextEvaluationAt: null,
16
+ lastControlId: null,
17
+ stopReason: 'manualDisabled',
18
+ updatedAt: now,
19
+ };
20
+ }
21
+ export function autopilotSnapshot(state, retryLimit) {
22
+ return {
23
+ state: state.state,
24
+ enabled: state.requestedEnabled,
25
+ ...(state.stopReason ? { reason: state.stopReason } : {}),
26
+ retry: { position: state.consecutiveNoProgress, limit: retryLimit },
27
+ ...(state.lastControlId
28
+ ? {
29
+ lastAutomaticAction: {
30
+ controlId: state.lastControlId,
31
+ summary: state.state === 'backoff'
32
+ ? 'Automatic continuation scheduled.'
33
+ : 'Automatic continuation issued.',
34
+ },
35
+ }
36
+ : {}),
37
+ ...(state.nextEvaluationAt ? { nextEvaluationAt: state.nextEvaluationAt } : {}),
38
+ updatedAt: state.updatedAt,
39
+ };
40
+ }
@@ -0,0 +1,9 @@
1
+ /*
2
+ * Copyright (C) 2026 Dyne.org foundation
3
+ * Designed by Denis Roio <jaromil@dyne.org>
4
+ * SPDX-License-Identifier: AGPL-3.0-or-later
5
+ */
6
+ import { registerAutopilotToggle } from './toggle/endpoint.js';
7
+ export function registerAutopilotRoutes(app, coordinator, idempotency) {
8
+ registerAutopilotToggle(app, coordinator, idempotency);
9
+ }
@@ -0,0 +1,32 @@
1
+ /*
2
+ * Copyright (C) 2026 Dyne.org foundation
3
+ * Designed by Denis Roio <jaromil@dyne.org>
4
+ * SPDX-License-Identifier: AGPL-3.0-or-later
5
+ */
6
+ import { createHash } from 'node:crypto';
7
+ export function registerAutopilotToggle(app, coordinator, idempotency) {
8
+ app.put('/api/sessions/:id/autopilot', async (request, reply) => {
9
+ const enabled = request.body?.enabled;
10
+ if (typeof enabled !== 'boolean')
11
+ return reply.code(400).send({ code: 'AUTOPILOT_ENABLED_REQUIRED' });
12
+ const key = request.headers['idempotency-key'];
13
+ const sessionId = request.params.id;
14
+ const scope = `autopilot-toggle:${sessionId}`;
15
+ const fingerprint = createHash('sha256').update(String(enabled)).digest('hex');
16
+ if (typeof key === 'string' && key && idempotency) {
17
+ const prior = idempotency.get(scope, key);
18
+ if (prior) {
19
+ const stored = JSON.parse(prior.body);
20
+ if (stored.fingerprint !== fingerprint)
21
+ return reply.code(409).send({ code: 'IDEMPOTENCY_KEY_REUSED' });
22
+ return reply.code(prior.statusCode).send(stored.response);
23
+ }
24
+ }
25
+ const result = enabled ? coordinator.enable(sessionId) : coordinator.disable(sessionId);
26
+ const statusCode = 'code' in result ? 409 : 200;
27
+ const response = 'code' in result ? result : { autopilot: result };
28
+ if (typeof key === 'string' && key && idempotency)
29
+ idempotency.put(scope, key, statusCode, JSON.stringify({ fingerprint, response }));
30
+ return reply.code(statusCode).send(response);
31
+ });
32
+ }
@@ -0,0 +1,6 @@
1
+ /*
2
+ * Copyright (C) 2026 Dyne.org foundation
3
+ * Designed by Denis Roio <jaromil@dyne.org>
4
+ * SPDX-License-Identifier: AGPL-3.0-or-later
5
+ */
6
+ export {};
@@ -0,0 +1,22 @@
1
+ /*
2
+ * Copyright (C) 2026 Dyne.org foundation
3
+ * Designed by Denis Roio <jaromil@dyne.org>
4
+ * SPDX-License-Identifier: AGPL-3.0-or-later
5
+ */
6
+ /** Feature entry point for consumers that need only the active typed alert. */
7
+ export function registerGetActiveOrgPlanAttention(app, deps) {
8
+ app.get('/api/sessions/:id/attention', async (request, reply) => {
9
+ const sessionId = request.params.id;
10
+ if (!deps.exists(sessionId))
11
+ return reply.code(404).send({ code: 'SESSION_NOT_FOUND' });
12
+ const attention = deps.reader.active(sessionId);
13
+ if (!attention)
14
+ return reply.code(404).type('application/problem+json').send({
15
+ type: 'urn:gestalt-mobile:error:org-plan-attention-not-active',
16
+ title: 'No active Org Plan attention request',
17
+ status: 404,
18
+ code: 'ORG_PLAN_ATTENTION_NOT_ACTIVE',
19
+ });
20
+ return reply.send(attention);
21
+ });
22
+ }
@@ -0,0 +1,11 @@
1
+ /*
2
+ * Copyright (C) 2026 Dyne.org foundation
3
+ * Designed by Denis Roio <jaromil@dyne.org>
4
+ * SPDX-License-Identifier: AGPL-3.0-or-later
5
+ */
6
+ import { registerGetActiveOrgPlanAttention } from './get-active/endpoint.js';
7
+ import { registerResolveOrgPlanAttention } from './resolve/endpoint.js';
8
+ export function registerOrgPlanAttentionRoutes(app, deps) {
9
+ registerGetActiveOrgPlanAttention(app, deps);
10
+ registerResolveOrgPlanAttention(app, deps);
11
+ }