gestalt-mobile 0.21.0 → 0.22.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 (23) hide show
  1. package/dist/client/assets/index-Db9p-xm2.css +1 -0
  2. package/dist/client/assets/{index-ufTniVfO.js → index-NHzJXg1w.js} +12 -12
  3. package/dist/client/index.html +2 -2
  4. package/dist/server/server/composition.contracts.js +106 -6
  5. package/dist/server/server/composition.js +36 -8
  6. package/dist/server/server/features/agent-activity/model.js +5 -0
  7. package/dist/server/server/features/agent-activity/registry.js +12 -1
  8. package/dist/server/server/features/autopilot/application/service.js +53 -1
  9. package/dist/server/server/features/sessions/list-sessions/endpoint.js +10 -6
  10. package/dist/server/server/features/sessions/model/relay-session.js +19 -0
  11. package/dist/server/server/features/sessions/promote-recent-thread/use-case.js +13 -3
  12. package/dist/server/server/features/sessions/register-routes.js +1 -0
  13. package/dist/server/server/features/sessions/start-session/use-case.js +3 -5
  14. package/dist/server/server/platform/auth/sqlite-authorization-store.js +98 -205
  15. package/dist/server/server/platform/auth/sqlite.js +54 -0
  16. package/dist/server/server/platform/codex/activity-facts.js +4 -0
  17. package/dist/server/server/platform/codex/recent-thread-lister.js +37 -11
  18. package/dist/server/server/platform/codex/rollout-execution-policy.js +68 -0
  19. package/dist/server/server/platform/codex/session-runtime.js +76 -17
  20. package/dist/server/server/platform/persistence/migrate.js +5 -1
  21. package/dist/server/server/platform/persistence/sqlite-session-repository.js +11 -3
  22. package/package.json +1 -1
  23. package/dist/client/assets/index-D9-XA9jj.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-ufTniVfO.js"></script>
20
- <link rel="stylesheet" crossorigin href="/assets/index-D9-XA9jj.css">
19
+ <script type="module" crossorigin src="/assets/index-NHzJXg1w.js"></script>
20
+ <link rel="stylesheet" crossorigin href="/assets/index-Db9p-xm2.css">
21
21
  </head>
22
22
  <body>
23
23
  <div id="app"></div>
@@ -565,6 +565,9 @@ describe('production composition', () => {
565
565
  });
566
566
  const database = new DatabaseSync(join(fixture.dataDir, 'relay.sqlite'));
567
567
  const timestamp = new Date().toISOString();
568
+ database
569
+ .prepare('DELETE FROM autopilot_sessions WHERE session_id = ?')
570
+ .run(fixture.sessionId);
568
571
  database
569
572
  .prepare("INSERT INTO autopilot_sessions (session_id,state,requested_enabled,plan_identity,plan_fingerprint,generation,no_progress_count,next_evaluation_at,last_control_id,stop_reason,updated_at) VALUES (?, 'backoff', 1, 'fixture', 'fixture', 1, 0, ?, 'issued-without-turn', NULL, ?)")
570
573
  .run(fixture.sessionId, timestamp, timestamp);
@@ -595,6 +598,9 @@ describe('production composition', () => {
595
598
  },
596
599
  });
597
600
  const acceptedDatabase = new DatabaseSync(join(accepted.dataDir, 'relay.sqlite'));
601
+ acceptedDatabase
602
+ .prepare('DELETE FROM autopilot_sessions WHERE session_id = ?')
603
+ .run(accepted.sessionId);
598
604
  acceptedDatabase
599
605
  .prepare("INSERT INTO autopilot_sessions (session_id,state,requested_enabled,plan_identity,plan_fingerprint,generation,no_progress_count,next_evaluation_at,last_control_id,stop_reason,updated_at) VALUES (?, 'backoff', 1, 'fixture', 'fixture', 1, 0, ?, 'issued-with-turn', NULL, ?)")
600
606
  .run(accepted.sessionId, timestamp, timestamp);
@@ -715,20 +721,22 @@ describe('production composition', () => {
715
721
  // absence is the durable proof that no queued control can be reopened.
716
722
  expect(row).toBeUndefined();
717
723
  }
724
+ else if (action === 'replace') {
725
+ await vi.waitFor(() => expect(staleTimer.cancelled).toBe(true));
726
+ await vi.waitFor(async () => expect((await fixture.app.inject(`/api/sessions/${fixture.sessionId}`)).json()).toMatchObject({
727
+ autopilot: { enabled: true, state: expect.stringMatching(/monitoring|backoff/) },
728
+ }));
729
+ }
718
730
  else {
719
731
  await vi.waitFor(async () => expect((await fixture.app.inject(`/api/sessions/${fixture.sessionId}`)).json()).toMatchObject({
720
732
  autopilot: {
721
733
  enabled: false,
722
734
  state: 'disabled',
723
- reason: action === 'replace'
724
- ? 'planReplaced'
725
- : action === 'close'
726
- ? 'planRemoved'
727
- : 'sessionEnded',
735
+ reason: action === 'close' ? 'planRemoved' : 'sessionEnded',
728
736
  },
729
737
  }));
730
738
  }
731
- expect(staleTimer.cancelled).toBe(true);
739
+ expect(staleTimer.cancelled, action).toBe(true);
732
740
  staleTimer.callback();
733
741
  await Promise.resolve();
734
742
  expect(fixture.handles.flatMap((candidate) => candidate.calls)).not.toContain('turn/start');
@@ -804,6 +812,12 @@ describe('production composition', () => {
804
812
  };
805
813
  },
806
814
  });
815
+ expect((await fixture.app.inject({
816
+ method: 'PUT',
817
+ url: `/api/sessions/${fixture.sessionId}/autopilot`,
818
+ payload: { enabled: false },
819
+ })).statusCode).toBe(200);
820
+ timers.length = 0;
807
821
  const handle = fixture.handles.find((candidate) => candidate.notify);
808
822
  handle.notify({
809
823
  method: 'thread/started',
@@ -868,6 +882,12 @@ describe('production composition', () => {
868
882
  };
869
883
  },
870
884
  });
885
+ expect((await fixture.app.inject({
886
+ method: 'PUT',
887
+ url: `/api/sessions/${fixture.sessionId}/autopilot`,
888
+ payload: { enabled: false },
889
+ })).statusCode).toBe(200);
890
+ timers.length = 0;
871
891
  const handle = fixture.handles.find((candidate) => candidate.notify);
872
892
  handle.notify({
873
893
  method: 'thread/started',
@@ -1356,6 +1376,86 @@ describe('production composition', () => {
1356
1376
  });
1357
1377
  });
1358
1378
  describeCompositionConcern('lifecycle', () => {
1379
+ it('forgets a session while activity reconciliation is pending without publishing late activity', async () => {
1380
+ const root = await mkdtemp(join(tmpdir(), 'gestalt-mobile-root-'));
1381
+ const dataDir = await mkdtemp(join(tmpdir(), 'gestalt-mobile-state-'));
1382
+ ownTemporaryPaths(root, dataDir);
1383
+ await mkdir(join(root, 'workspace'));
1384
+ const writers = [];
1385
+ const unhandled = [];
1386
+ const onUnhandled = (reason) => unhandled.push(reason);
1387
+ process.on('unhandledRejection', onUnhandled);
1388
+ onTestFinished(() => {
1389
+ process.off('unhandledRejection', onUnhandled);
1390
+ });
1391
+ const app = await composeAuthorizedApp({
1392
+ root,
1393
+ dataDir,
1394
+ relyingParty,
1395
+ installedCodexVersion: 'codex-cli 0.144.3',
1396
+ startAppServers: true,
1397
+ profiles: {
1398
+ list: async () => [{ name: 'default', state: 'ok', status: 'ready' }],
1399
+ require: async () => ({
1400
+ name: 'default',
1401
+ state: 'ok',
1402
+ status: 'ready',
1403
+ }),
1404
+ },
1405
+ launchAppServer: () => {
1406
+ const writer = {};
1407
+ return {
1408
+ rpc: {
1409
+ request: async (method, params) => {
1410
+ if (method === 'thread/start') {
1411
+ writers.push(writer);
1412
+ return { thread: { id: `thread-${writers.length}` } };
1413
+ }
1414
+ if (method === 'thread/read')
1415
+ return await new Promise((resolve, reject) => {
1416
+ writer.releaseRead = () => resolve({ thread: { turns: [] } });
1417
+ writer.rejectRead = () => reject(new Error('LATE_READ_FAILURE'));
1418
+ });
1419
+ if (method === 'thread/list')
1420
+ return { data: [] };
1421
+ if (method === 'model/list')
1422
+ return { data: [{ id: 'gpt-5.6-terra' }] };
1423
+ if (method === 'skills/list')
1424
+ return {
1425
+ data: [{ cwd: params.cwds[0], skills: [], errors: [] }],
1426
+ };
1427
+ return {};
1428
+ },
1429
+ onNotification: (listener) => {
1430
+ writer.notify = listener;
1431
+ return () => { };
1432
+ },
1433
+ onServerRequest: () => () => { },
1434
+ },
1435
+ close: () => { },
1436
+ onExit: () => () => { },
1437
+ };
1438
+ },
1439
+ });
1440
+ const forgottenId = await createComposedSession(app);
1441
+ await vi.waitFor(() => expect(writers[0]?.releaseRead).toBeTypeOf('function'));
1442
+ expect((await app.inject({ method: 'DELETE', url: `/api/sessions/${forgottenId}` })).statusCode).toBe(204);
1443
+ writers[0].rejectRead();
1444
+ await Promise.resolve();
1445
+ await Promise.resolve();
1446
+ expect(unhandled).toEqual([]);
1447
+ expect((await app.inject('/api/bootstrap')).statusCode).toBe(200);
1448
+ const liveId = await createComposedSession(app);
1449
+ await vi.waitFor(() => expect(writers[1]?.notify).toBeTypeOf('function'));
1450
+ writers[1].notify({
1451
+ method: 'thread/started',
1452
+ params: { thread: { id: 'thread-2', status: { type: 'active' } } },
1453
+ });
1454
+ await vi.waitFor(async () => expect((await app.inject(`/api/sessions/${liveId}`)).json()).toMatchObject({
1455
+ agentActivity: { root: { state: 'working' } },
1456
+ }));
1457
+ await app.close();
1458
+ });
1359
1459
  it('uses the relay root for detached history reads and Open never resumes', async () => {
1360
1460
  const root = await mkdtemp(join(tmpdir(), 'gestalt-mobile-root-'));
1361
1461
  const dataDir = await mkdtemp(join(tmpdir(), 'gestalt-mobile-state-'));
@@ -32,7 +32,7 @@ import { GitFetchCoordinator } from './platform/git/git-fetch-coordinator.js';
32
32
  import { GitSummaryCache } from './platform/git/git-summary-cache.js';
33
33
  import { SessionSupervisor } from './platform/runtime/session-supervisor.js';
34
34
  import { mapWithConcurrency } from './platform/runtime/concurrency.js';
35
- import { RelaySession } from './features/sessions/model/relay-session.js';
35
+ import { RelaySession, } from './features/sessions/model/relay-session.js';
36
36
  import { AgentActivityRegistry } from './features/agent-activity/registry.js';
37
37
  import { decodeAgentActivityFacts } from './platform/codex/activity-facts.js';
38
38
  import { resolvedServerRequestId, toPendingInteraction } from './platform/codex/server-request.js';
@@ -130,6 +130,11 @@ export async function composeRelayApp(options) {
130
130
  if (!session || !runtime)
131
131
  return;
132
132
  const history = await runtime.readHistory(session);
133
+ // Forget may commit while the detached history reader is still in
134
+ // flight. Revalidate ownership before handing its result to the
135
+ // registry, whose publisher intentionally keeps the journal strict.
136
+ if (!sessions.find(sessionId))
137
+ return;
133
138
  const occurredAt = new Date().toISOString();
134
139
  activity.observe({
135
140
  sessionId,
@@ -138,7 +143,12 @@ export async function composeRelayApp(options) {
138
143
  ...(session.threadId ? { threadId: session.threadId } : {}),
139
144
  ...(history.activeTurnId ? { turnId: history.activeTurnId } : {}),
140
145
  });
141
- activity.childrenReconciled(sessionId, occurredAt, await runtime.listDirectChildren(session));
146
+ const children = await runtime.listDirectChildren(session);
147
+ // The writer read is also asynchronous; it cannot publish after the
148
+ // durable owner has gone away either.
149
+ if (!sessions.find(sessionId))
150
+ return;
151
+ activity.childrenReconciled(sessionId, occurredAt, children);
142
152
  },
143
153
  });
144
154
  const autopilot = new AutopilotCoordinator({
@@ -244,6 +254,14 @@ export async function composeRelayApp(options) {
244
254
  profiles: options.profiles,
245
255
  launch: options.launchAppServer ?? launchCodexAppServer,
246
256
  });
257
+ const recoverExecutionPolicy = async (session) => {
258
+ if (session.executionPolicy || !session.threadId)
259
+ return session;
260
+ const executionPolicy = await recentThreads.executionPolicy(session.threadId);
261
+ return executionPolicy
262
+ ? { ...session, executionPolicy, updatedAt: new Date().toISOString() }
263
+ : session;
264
+ };
247
265
  const protocol = protocolCompatibility(options.installedCodexVersion, generatedProtocolVersion);
248
266
  const gitFetches = new GitFetchCoordinator(fetchUpstream);
249
267
  const gitSummaries = new GitSummaryCache(inspectGit);
@@ -297,6 +315,8 @@ export async function composeRelayApp(options) {
297
315
  }
298
316
  events.publish(journal.append(sessionId, 'plan.updated', { plan: update.plan, reason: update.reason }, occurredAt));
299
317
  }
318
+ if (update.kind === 'updated' && update.reason === 'supervision-start')
319
+ autopilot.supervisionStarted(sessionId);
300
320
  autopilot.planStatusChanged(sessionId);
301
321
  };
302
322
  runtime = options.startAppServers
@@ -486,7 +506,13 @@ export async function composeRelayApp(options) {
486
506
  profiles: options.profiles,
487
507
  models,
488
508
  sessions: {
489
- list: () => sessions.list().map((session) => withPendingInteractions(session)),
509
+ list: () => sessions.list().map((session) => {
510
+ const plan = supervisedPlans.find(session.id);
511
+ return {
512
+ ...withPendingInteractions(session),
513
+ ...(plan ? { plan } : {}),
514
+ };
515
+ }),
490
516
  },
491
517
  protocolCompatible: protocol.compatible,
492
518
  },
@@ -496,16 +522,17 @@ export async function composeRelayApp(options) {
496
522
  save: saveSession,
497
523
  find: (id) => withPendingInteractions(sessions.find(id)),
498
524
  list: () => sessions.list().map((session) => withPendingInteractions(session)),
525
+ plan: (id) => supervisedPlans.find(id),
499
526
  workspaces,
500
527
  profiles: options.profiles,
501
528
  skillProfiles,
502
529
  skillCatalog,
503
530
  defaultSkillProfile: options.explicitSkillProfile,
504
531
  activate: runtime
505
- ? async (session, settings) => {
532
+ ? async (session) => {
506
533
  const now = new Date().toISOString();
507
534
  dismissPendingInteractions(session.id, now);
508
- const started = await runtime.start(session, now, settings);
535
+ const started = await runtime.start(session, now);
509
536
  return started;
510
537
  }
511
538
  : undefined,
@@ -516,9 +543,9 @@ export async function composeRelayApp(options) {
516
543
  }
517
544
  : undefined,
518
545
  ensureWriter: runtime
519
- ? (session) => {
546
+ ? async (session) => {
520
547
  dismissPendingInteractions(session.id, new Date().toISOString());
521
- return runtime.ensureWriter(session, new Date().toISOString());
548
+ return runtime.ensureWriter(await recoverExecutionPolicy(session), new Date().toISOString());
522
549
  }
523
550
  : undefined,
524
551
  releaseWriter: runtime
@@ -556,7 +583,7 @@ export async function composeRelayApp(options) {
556
583
  : undefined,
557
584
  restore: runtime
558
585
  ? async (session) => {
559
- const restored = await runtime.restoreWithOutcome(session, new Date().toISOString());
586
+ const restored = await runtime.restoreWithOutcome(await recoverExecutionPolicy(session), new Date().toISOString());
560
587
  // An exit can be reported while resume is resolving. Do not let the
561
588
  // route persist a stale ready snapshot over that recovered exit.
562
589
  if (!runtime.ownsWriter(session.id))
@@ -576,6 +603,7 @@ export async function composeRelayApp(options) {
576
603
  list: () => sessions.list(),
577
604
  save: saveSession,
578
605
  read: (session) => runtime.readHistory(session),
606
+ executionPolicy: (candidate) => recentThreads.executionPolicy(candidate.id),
579
607
  })
580
608
  : undefined,
581
609
  release: (session) => {
@@ -94,6 +94,11 @@ export function projectAgentActivity(current, fact) {
94
94
  ? { nickname: before.nickname }
95
95
  : {}),
96
96
  ...(fact.childRole ? { role: fact.childRole } : before?.role ? { role: before.role } : {}),
97
+ ...(fact.childModel
98
+ ? { model: fact.childModel }
99
+ : before?.model
100
+ ? { model: before.model }
101
+ : {}),
97
102
  state: childState(fact.childStatus, fact.collaborationAction, before?.state),
98
103
  reason: childReason(fact.childStatus, fact.collaborationAction),
99
104
  observedAt: fact.occurredAt,
@@ -12,6 +12,7 @@ export class AgentActivityRegistry {
12
12
  #timers = new Map();
13
13
  #generation = new Map();
14
14
  #inFlight = new Map();
15
+ #disposed = new Set();
15
16
  constructor(publish, options = {}) {
16
17
  this.publish = publish;
17
18
  this.options = options;
@@ -20,6 +21,8 @@ export class AgentActivityRegistry {
20
21
  return this.#snapshots.get(sessionId) ?? createAgentActivitySnapshot(sessionId, now);
21
22
  }
22
23
  observe(fact) {
24
+ if (this.#disposed.has(fact.sessionId))
25
+ return this.snapshot(fact.sessionId, fact.occurredAt);
23
26
  const current = this.snapshot(fact.sessionId, fact.occurredAt);
24
27
  const next = withActivityConfidence(projectAgentActivity(current, fact), fact.kind === 'collaboration' && !fact.childId ? 'stale' : 'fresh');
25
28
  if (next !== current) {
@@ -34,6 +37,8 @@ export class AgentActivityRegistry {
34
37
  return this.#qualify(sessionId, occurredAt, 'reconciling');
35
38
  }
36
39
  disconnected(sessionId, occurredAt) {
40
+ if (this.#disposed.has(sessionId))
41
+ return this.snapshot(sessionId, occurredAt);
37
42
  this.suspend(sessionId);
38
43
  const current = this.snapshot(sessionId, occurredAt);
39
44
  const next = withActivityConfidence(clearAgentActivityChildren(projectAgentActivity(current, { sessionId, occurredAt, kind: 'processExited' })), 'stale');
@@ -55,7 +60,7 @@ export class AgentActivityRegistry {
55
60
  return this.#qualify(sessionId, occurredAt, 'fresh');
56
61
  }
57
62
  refresh(sessionId) {
58
- if (!this.options.reconcile)
63
+ if (this.#disposed.has(sessionId) || !this.options.reconcile)
59
64
  return Promise.resolve();
60
65
  const existing = this.#inFlight.get(sessionId);
61
66
  if (existing)
@@ -71,6 +76,8 @@ export class AgentActivityRegistry {
71
76
  }
72
77
  /** Replaces the observed direct-child set; absent children are disconnected. */
73
78
  childrenReconciled(sessionId, occurredAt, children) {
79
+ if (this.#disposed.has(sessionId))
80
+ return this.snapshot(sessionId, occurredAt);
74
81
  let next = this.snapshot(sessionId, occurredAt);
75
82
  const seen = new Set(children.map((child) => child.id));
76
83
  for (const child of children)
@@ -83,6 +90,7 @@ export class AgentActivityRegistry {
83
90
  ...(child.status ? { childStatus: child.status } : {}),
84
91
  ...(child.nickname ? { childNickname: child.nickname } : {}),
85
92
  ...(child.role ? { childRole: child.role } : {}),
93
+ ...(child.model ? { childModel: child.model } : {}),
86
94
  });
87
95
  for (const child of next.subagents)
88
96
  if (!seen.has(child.id))
@@ -105,6 +113,7 @@ export class AgentActivityRegistry {
105
113
  return next;
106
114
  }
107
115
  dispose(sessionId) {
116
+ this.#disposed.add(sessionId);
108
117
  this.#snapshots.delete(sessionId);
109
118
  this.#timers.get(sessionId)?.();
110
119
  this.#timers.delete(sessionId);
@@ -142,6 +151,8 @@ export class AgentActivityRegistry {
142
151
  }
143
152
  }
144
153
  #qualify(sessionId, occurredAt, confidence, fact) {
154
+ if (this.#disposed.has(sessionId))
155
+ return this.snapshot(sessionId, occurredAt);
145
156
  const current = this.snapshot(sessionId, occurredAt);
146
157
  const next = fact
147
158
  ? withActivityConfidence(projectAgentActivity(current, { sessionId, occurredAt, ...fact }), confidence)
@@ -110,13 +110,22 @@ export class AutopilotCoordinator {
110
110
  disable(sessionId) {
111
111
  const now = this.deps.now();
112
112
  const prior = this.deps.store.find(sessionId) ?? disabledAutopilot(sessionId, now);
113
+ // Retain the current plan identity even when Autopilot has not previously
114
+ // been enabled. That makes an explicit Off a durable, plan-scoped choice
115
+ // instead of a generic disabled default that a later supervision signal
116
+ // could not distinguish from never having been configured.
117
+ const currentPlan = this.deps.plan(sessionId);
113
118
  const cancelled = this.cancelScheduledControl(prior, now);
114
- if (!prior.requestedEnabled && prior.state === 'disabled' && !cancelled)
119
+ if (!prior.requestedEnabled &&
120
+ prior.state === 'disabled' &&
121
+ !cancelled &&
122
+ (!currentPlan || prior.planIdentity === currentPlan.identity))
115
123
  return this.snapshot(sessionId);
116
124
  const next = {
117
125
  ...prior,
118
126
  state: 'disabled',
119
127
  requestedEnabled: false,
128
+ ...(currentPlan ? { planIdentity: currentPlan.identity } : {}),
120
129
  generation: prior.generation + 1,
121
130
  nextEvaluationAt: null,
122
131
  ...(cancelled ? { lastControlId: null } : {}),
@@ -275,6 +284,49 @@ export class AutopilotCoordinator {
275
284
  if (executionComplete(plan.plan))
276
285
  this.evaluate(sessionId);
277
286
  }
287
+ /**
288
+ * Records a validated, session-private supervision request. It intentionally
289
+ * does not discover plans: composition must first bind the update to this
290
+ * relay session. A manual Off is authoritative for the retained identity,
291
+ * while a new identity can receive its own explicit supervision request.
292
+ */
293
+ supervisionStarted(sessionId) {
294
+ const currentPlan = this.deps.plan(sessionId);
295
+ if (!currentPlan)
296
+ return { code: 'AUTOPILOT_PLAN_REQUIRED' };
297
+ if (executionComplete(currentPlan.plan))
298
+ return { code: 'AUTOPILOT_PLAN_COMPLETE' };
299
+ const now = this.deps.now();
300
+ const prior = this.deps.store.find(sessionId) ?? disabledAutopilot(sessionId, now);
301
+ if (prior.planIdentity === currentPlan.identity &&
302
+ !prior.requestedEnabled &&
303
+ prior.stopReason === 'manualDisabled')
304
+ return this.snapshot(sessionId);
305
+ if (prior.requestedEnabled && prior.planIdentity === currentPlan.identity)
306
+ return this.snapshot(sessionId);
307
+ const replacing = Boolean(prior.planIdentity && prior.planIdentity !== currentPlan.identity);
308
+ const cancelled = replacing ? this.cancelScheduledControl(prior, now) : undefined;
309
+ if (replacing)
310
+ this.cancelTimer(sessionId);
311
+ const next = {
312
+ ...prior,
313
+ state: 'monitoring',
314
+ requestedEnabled: true,
315
+ planIdentity: currentPlan.identity,
316
+ planFingerprint: fingerprint(currentPlan.plan),
317
+ generation: prior.generation + 1,
318
+ consecutiveNoProgress: 0,
319
+ nextEvaluationAt: null,
320
+ ...(cancelled ? { lastControlId: null } : {}),
321
+ stopReason: null,
322
+ updatedAt: now,
323
+ };
324
+ this.persist(next, cancelled);
325
+ const session = this.deps.session(sessionId);
326
+ if (session?.threadId && ['ready', 'turnActive'].includes(session.state))
327
+ this.evaluate(sessionId);
328
+ return this.snapshot(sessionId);
329
+ }
278
330
  /** Reacts only to fresh actor status; plan mutations are not scheduling signals. */
279
331
  activityChanged(sessionId) {
280
332
  const prior = this.deps.store.find(sessionId);
@@ -5,10 +5,14 @@
5
5
  */
6
6
  import { buildResumeCommand } from '../application/resume-command.js';
7
7
  export function registerListSessions(app, deps) {
8
- app.get('/api/sessions', async () => deps.list().map((session) => ({
9
- ...session,
10
- ...(deps.activity ? { agentActivity: deps.activity(session.id) } : {}),
11
- ...(deps.autopilot ? { autopilot: deps.autopilot(session.id) } : {}),
12
- resumeCommand: session.threadId ? buildResumeCommand(session) : null,
13
- })));
8
+ app.get('/api/sessions', async () => deps.list().map((session) => {
9
+ const plan = deps.plan?.(session.id) ?? null;
10
+ return {
11
+ ...session,
12
+ ...(deps.activity ? { agentActivity: deps.activity(session.id) } : {}),
13
+ ...(deps.autopilot ? { autopilot: deps.autopilot(session.id) } : {}),
14
+ ...(plan ? { plan } : {}),
15
+ resumeCommand: session.threadId ? buildResumeCommand(session) : null,
16
+ };
17
+ }));
14
18
  }
@@ -14,6 +14,21 @@ export function isSessionReadable(session) {
14
14
  export function relayOwnsWriter(session) {
15
15
  return session.state === 'ready' || session.state === 'turnActive';
16
16
  }
17
+ const sandboxes = new Set(['read-only', 'workspace-write', 'danger-full-access']);
18
+ const approvalPolicies = new Set(['untrusted', 'on-request', 'never']);
19
+ export function createSessionExecutionPolicy(input) {
20
+ if (input.sandbox !== undefined &&
21
+ (typeof input.sandbox !== 'string' || !sandboxes.has(input.sandbox)))
22
+ throw new DomainError('SESSION_EXECUTION_POLICY_INVALID');
23
+ const approvalPolicy = input.approvalPolicy ?? 'on-request';
24
+ if (typeof approvalPolicy !== 'string' ||
25
+ !approvalPolicies.has(approvalPolicy))
26
+ throw new DomainError('SESSION_EXECUTION_POLICY_INVALID');
27
+ return {
28
+ ...(input.sandbox === undefined ? {} : { sandbox: input.sandbox }),
29
+ approvalPolicy: approvalPolicy,
30
+ };
31
+ }
17
32
  export function createEffectiveSkillSelection(input) {
18
33
  return {
19
34
  ...(input.selectedProfileName === undefined
@@ -38,6 +53,7 @@ export class RelaySession {
38
53
  ...(input.model === undefined ? {} : { model: input.model }),
39
54
  ...(input.branch === undefined ? {} : { branch: input.branch }),
40
55
  effectiveSkillSelection: createEffectiveSkillSelection(input.effectiveSkillSelection),
56
+ executionPolicy: createSessionExecutionPolicy(input),
41
57
  threadId: null,
42
58
  state: 'starting',
43
59
  desiredState: 'active',
@@ -55,6 +71,9 @@ export class RelaySession {
55
71
  workspaceId: workspaceId(input.workspaceId),
56
72
  workspacePath: workspacePath(input.workspacePath),
57
73
  profile: profileName(input.profile),
74
+ ...(input.executionPolicy === undefined
75
+ ? {}
76
+ : { executionPolicy: createSessionExecutionPolicy(input.executionPolicy) }),
58
77
  threadId: threadId(input.threadId),
59
78
  state: 'stopped',
60
79
  desiredState: 'stopped',
@@ -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 { RelaySession } from '../model/relay-session.js';
6
+ import { RelaySession, } from '../model/relay-session.js';
7
7
  export class RecentThreadHistoryUnavailable extends Error {
8
8
  constructor() {
9
9
  super('RECENT_THREAD_HISTORY_UNAVAILABLE');
@@ -28,15 +28,24 @@ export async function promoteRecentThread(thread, deps) {
28
28
  async function promote(thread, deps) {
29
29
  const existing = deps.list().find((session) => session.threadId === thread.id);
30
30
  if (existing) {
31
+ const executionPolicy = existing.executionPolicy
32
+ ? undefined
33
+ : await deps.executionPolicy?.(thread);
34
+ const recovered = executionPolicy
35
+ ? { ...existing, executionPolicy, updatedAt: deps.now() }
36
+ : existing;
31
37
  try {
32
- await deps.read(existing);
38
+ await deps.read(recovered);
33
39
  }
34
40
  catch {
35
41
  throw new RecentThreadHistoryUnavailable();
36
42
  }
37
- return existing;
43
+ if (recovered !== existing)
44
+ deps.save(recovered);
45
+ return recovered;
38
46
  }
39
47
  const now = deps.now();
48
+ const executionPolicy = await deps.executionPolicy?.(thread);
40
49
  const imported = existing ??
41
50
  RelaySession.fromExistingThread({
42
51
  id: deps.createId(),
@@ -44,6 +53,7 @@ async function promote(thread, deps) {
44
53
  workspacePath: thread.cwd,
45
54
  profile: thread.profile,
46
55
  threadId: thread.id,
56
+ ...(executionPolicy === undefined ? {} : { executionPolicy }),
47
57
  now,
48
58
  }).snapshot;
49
59
  // Validate first: an unavailable thread must never leave an imported stub.
@@ -59,6 +59,7 @@ export function registerSessionRoutes(app, deps) {
59
59
  list: sessions.list,
60
60
  activity: sessions.agentActivity,
61
61
  autopilot: sessions.autopilotSnapshot,
62
+ plan: sessions.plan,
62
63
  });
63
64
  if (sessions.readHistory)
64
65
  registerGetHistory(app, {
@@ -44,17 +44,15 @@ export async function startSession(input, deps) {
44
44
  profile: input.profile,
45
45
  model,
46
46
  ...(branch ? { branch } : {}),
47
+ sandbox: input.sandbox,
48
+ approvalPolicy: input.approvalPolicy,
47
49
  effectiveSkillSelection,
48
50
  now: deps.now(),
49
51
  }).snapshot;
50
52
  deps.save(session);
51
53
  if (!deps.activate)
52
54
  return session;
53
- const active = await deps.activate(session, {
54
- model,
55
- sandbox: input.sandbox,
56
- approvalPolicy: input.approvalPolicy,
57
- });
55
+ const active = await deps.activate(session, {});
58
56
  deps.save(active);
59
57
  return active;
60
58
  }