gestalt-mobile 0.20.1 → 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 (35) hide show
  1. package/dist/client/assets/index-Db9p-xm2.css +1 -0
  2. package/dist/client/assets/index-NHzJXg1w.js +29 -0
  3. package/dist/client/index.html +2 -2
  4. package/dist/server/server/app.js +2 -0
  5. package/dist/server/server/composition.contracts.js +142 -6
  6. package/dist/server/server/composition.js +39 -8
  7. package/dist/server/server/features/agent-activity/model.js +5 -0
  8. package/dist/server/server/features/agent-activity/registry.js +12 -1
  9. package/dist/server/server/features/autopilot/application/service.js +53 -1
  10. package/dist/server/server/features/files/application/ports.js +6 -0
  11. package/dist/server/server/features/files/copy-entry/endpoint.js +48 -0
  12. package/dist/server/server/features/files/delete-entry/endpoint.js +23 -0
  13. package/dist/server/server/features/files/domain/relative-directory.js +15 -0
  14. package/dist/server/server/features/files/domain/workspace-directory.js +6 -0
  15. package/dist/server/server/features/files/list-directory/endpoint.js +49 -0
  16. package/dist/server/server/features/files/move-entry/endpoint.js +4 -0
  17. package/dist/server/server/features/files/register-routes.js +19 -0
  18. package/dist/server/server/features/files/upload-file/endpoint.js +35 -0
  19. package/dist/server/server/features/sessions/list-sessions/endpoint.js +10 -6
  20. package/dist/server/server/features/sessions/model/relay-session.js +19 -0
  21. package/dist/server/server/features/sessions/promote-recent-thread/use-case.js +13 -3
  22. package/dist/server/server/features/sessions/register-routes.js +1 -0
  23. package/dist/server/server/features/sessions/start-session/use-case.js +3 -5
  24. package/dist/server/server/platform/auth/sqlite-authorization-store.js +98 -205
  25. package/dist/server/server/platform/auth/sqlite.js +54 -0
  26. package/dist/server/server/platform/codex/activity-facts.js +4 -0
  27. package/dist/server/server/platform/codex/recent-thread-lister.js +37 -11
  28. package/dist/server/server/platform/codex/rollout-execution-policy.js +68 -0
  29. package/dist/server/server/platform/codex/session-runtime.js +76 -17
  30. package/dist/server/server/platform/filesystem/filesystem-workspace-files.js +559 -0
  31. package/dist/server/server/platform/persistence/migrate.js +5 -1
  32. package/dist/server/server/platform/persistence/sqlite-session-repository.js +11 -3
  33. package/package.json +1 -1
  34. package/dist/client/assets/index-CnXbn39K.js +0 -29
  35. package/dist/client/assets/index-DYERmevO.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-CnXbn39K.js"></script>
20
- <link rel="stylesheet" crossorigin href="/assets/index-DYERmevO.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>
@@ -17,6 +17,7 @@ import { registerAuthorizationBoundary } from './platform/http/authorization-bou
17
17
  import { registerSkillRoutes } from './features/skills/register-routes.js';
18
18
  import { registerOrgPlanAttentionRoutes } from './features/org-plan-attention/register-routes.js';
19
19
  import { registerAutopilotRoutes } from './features/autopilot/register-routes.js';
20
+ import { registerFileRoutes } from './features/files/register-routes.js';
20
21
  export async function buildApp(deps) {
21
22
  // Keep the default boundary finite even when an endpoint forgot a narrower schema.
22
23
  const app = fastify({ logger: false, bodyLimit: 1024 * 1024 });
@@ -48,6 +49,7 @@ export async function buildApp(deps) {
48
49
  if (deps.orgPlanAttention)
49
50
  registerOrgPlanAttentionRoutes(app, deps.orgPlanAttention);
50
51
  registerPlanRoutes(app, deps);
52
+ registerFileRoutes(app, deps);
51
53
  registerGitRoutes(app, deps);
52
54
  registerSkillRoutes(app, deps);
53
55
  registerProblemHandler(app, Boolean(deps.staticDir));
@@ -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-'));
@@ -1512,6 +1612,42 @@ describe('production composition', () => {
1512
1612
  ['GET', '/api/sessions/:id/plan', '/api/sessions/session-1/plan', 'protected'],
1513
1613
  ['HEAD', '/api/sessions/:id/plan', '/api/sessions/session-1/plan', 'protected'],
1514
1614
  ['DELETE', '/api/sessions/:id/plan', '/api/sessions/session-1/plan', 'protected'],
1615
+ [
1616
+ 'GET',
1617
+ '/api/workspaces/:workspaceId/files',
1618
+ '/api/workspaces/workspace-1/files',
1619
+ 'protected',
1620
+ ],
1621
+ [
1622
+ 'HEAD',
1623
+ '/api/workspaces/:workspaceId/files',
1624
+ '/api/workspaces/workspace-1/files',
1625
+ 'protected',
1626
+ ],
1627
+ [
1628
+ 'POST',
1629
+ '/api/workspaces/:workspaceId/files/copy',
1630
+ '/api/workspaces/workspace-1/files/copy',
1631
+ 'protected',
1632
+ ],
1633
+ [
1634
+ 'POST',
1635
+ '/api/workspaces/:workspaceId/files/move',
1636
+ '/api/workspaces/workspace-1/files/move',
1637
+ 'protected',
1638
+ ],
1639
+ [
1640
+ 'PUT',
1641
+ '/api/workspaces/:workspaceId/files/upload',
1642
+ '/api/workspaces/workspace-1/files/upload',
1643
+ 'protected',
1644
+ ],
1645
+ [
1646
+ 'DELETE',
1647
+ '/api/workspaces/:workspaceId/files',
1648
+ '/api/workspaces/workspace-1/files',
1649
+ 'protected',
1650
+ ],
1515
1651
  [
1516
1652
  'GET',
1517
1653
  '/api/workspaces/:workspaceId/plans',
@@ -9,6 +9,7 @@ import { homedir } from 'node:os';
9
9
  import { basename, dirname, join, resolve } from 'node:path';
10
10
  import { buildApp } from './app.js';
11
11
  import { FilesystemWorkspaceCatalog } from './platform/catalog/filesystem-workspace-catalog.js';
12
+ import { FilesystemWorkspaceFiles } from './platform/filesystem/filesystem-workspace-files.js';
12
13
  import { protocolCompatibility } from './platform/codex/protocol-compatibility.js';
13
14
  import { launchCodexAppServer } from './platform/codex/codex-process-launcher.js';
14
15
  import { CodexModelCatalog } from './platform/codex/codex-model-catalog.js';
@@ -31,7 +32,7 @@ import { GitFetchCoordinator } from './platform/git/git-fetch-coordinator.js';
31
32
  import { GitSummaryCache } from './platform/git/git-summary-cache.js';
32
33
  import { SessionSupervisor } from './platform/runtime/session-supervisor.js';
33
34
  import { mapWithConcurrency } from './platform/runtime/concurrency.js';
34
- import { RelaySession } from './features/sessions/model/relay-session.js';
35
+ import { RelaySession, } from './features/sessions/model/relay-session.js';
35
36
  import { AgentActivityRegistry } from './features/agent-activity/registry.js';
36
37
  import { decodeAgentActivityFacts } from './platform/codex/activity-facts.js';
37
38
  import { resolvedServerRequestId, toPendingInteraction } from './platform/codex/server-request.js';
@@ -129,6 +130,11 @@ export async function composeRelayApp(options) {
129
130
  if (!session || !runtime)
130
131
  return;
131
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;
132
138
  const occurredAt = new Date().toISOString();
133
139
  activity.observe({
134
140
  sessionId,
@@ -137,7 +143,12 @@ export async function composeRelayApp(options) {
137
143
  ...(session.threadId ? { threadId: session.threadId } : {}),
138
144
  ...(history.activeTurnId ? { turnId: history.activeTurnId } : {}),
139
145
  });
140
- 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);
141
152
  },
142
153
  });
143
154
  const autopilot = new AutopilotCoordinator({
@@ -216,6 +227,7 @@ export async function composeRelayApp(options) {
216
227
  notifyAutopilotActivity = (sessionId) => autopilot.activityChanged(sessionId);
217
228
  options.onAutopilotCoordinator?.(autopilot);
218
229
  const workspaces = new FilesystemWorkspaceCatalog(root);
230
+ const workspaceFiles = new FilesystemWorkspaceFiles();
219
231
  const models = new CodexModelCatalog(root, options.launchAppServer ?? launchCodexAppServer);
220
232
  const skillProfiles = new FilesystemSkillProfileStore(options.homeDirectory ?? homedir());
221
233
  const skillCatalog = (profile) => new CodexSkillCatalog(profile, options.launchAppServer ?? launchCodexAppServer);
@@ -242,6 +254,14 @@ export async function composeRelayApp(options) {
242
254
  profiles: options.profiles,
243
255
  launch: options.launchAppServer ?? launchCodexAppServer,
244
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
+ };
245
265
  const protocol = protocolCompatibility(options.installedCodexVersion, generatedProtocolVersion);
246
266
  const gitFetches = new GitFetchCoordinator(fetchUpstream);
247
267
  const gitSummaries = new GitSummaryCache(inspectGit);
@@ -295,6 +315,8 @@ export async function composeRelayApp(options) {
295
315
  }
296
316
  events.publish(journal.append(sessionId, 'plan.updated', { plan: update.plan, reason: update.reason }, occurredAt));
297
317
  }
318
+ if (update.kind === 'updated' && update.reason === 'supervision-start')
319
+ autopilot.supervisionStarted(sessionId);
298
320
  autopilot.planStatusChanged(sessionId);
299
321
  };
300
322
  runtime = options.startAppServers
@@ -484,7 +506,13 @@ export async function composeRelayApp(options) {
484
506
  profiles: options.profiles,
485
507
  models,
486
508
  sessions: {
487
- 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
+ }),
488
516
  },
489
517
  protocolCompatible: protocol.compatible,
490
518
  },
@@ -494,16 +522,17 @@ export async function composeRelayApp(options) {
494
522
  save: saveSession,
495
523
  find: (id) => withPendingInteractions(sessions.find(id)),
496
524
  list: () => sessions.list().map((session) => withPendingInteractions(session)),
525
+ plan: (id) => supervisedPlans.find(id),
497
526
  workspaces,
498
527
  profiles: options.profiles,
499
528
  skillProfiles,
500
529
  skillCatalog,
501
530
  defaultSkillProfile: options.explicitSkillProfile,
502
531
  activate: runtime
503
- ? async (session, settings) => {
532
+ ? async (session) => {
504
533
  const now = new Date().toISOString();
505
534
  dismissPendingInteractions(session.id, now);
506
- const started = await runtime.start(session, now, settings);
535
+ const started = await runtime.start(session, now);
507
536
  return started;
508
537
  }
509
538
  : undefined,
@@ -514,9 +543,9 @@ export async function composeRelayApp(options) {
514
543
  }
515
544
  : undefined,
516
545
  ensureWriter: runtime
517
- ? (session) => {
546
+ ? async (session) => {
518
547
  dismissPendingInteractions(session.id, new Date().toISOString());
519
- return runtime.ensureWriter(session, new Date().toISOString());
548
+ return runtime.ensureWriter(await recoverExecutionPolicy(session), new Date().toISOString());
520
549
  }
521
550
  : undefined,
522
551
  releaseWriter: runtime
@@ -554,7 +583,7 @@ export async function composeRelayApp(options) {
554
583
  : undefined,
555
584
  restore: runtime
556
585
  ? async (session) => {
557
- const restored = await runtime.restoreWithOutcome(session, new Date().toISOString());
586
+ const restored = await runtime.restoreWithOutcome(await recoverExecutionPolicy(session), new Date().toISOString());
558
587
  // An exit can be reported while resume is resolving. Do not let the
559
588
  // route persist a stale ready snapshot over that recovered exit.
560
589
  if (!runtime.ownsWriter(session.id))
@@ -574,6 +603,7 @@ export async function composeRelayApp(options) {
574
603
  list: () => sessions.list(),
575
604
  save: saveSession,
576
605
  read: (session) => runtime.readHistory(session),
606
+ executionPolicy: (candidate) => recentThreads.executionPolicy(candidate.id),
577
607
  })
578
608
  : undefined,
579
609
  release: (session) => {
@@ -654,6 +684,7 @@ export async function composeRelayApp(options) {
654
684
  },
655
685
  },
656
686
  workspacePlanRoutes: { workspaces, plans: workspacePlanCatalog },
687
+ workspaceFileRoutes: { workspaces, files: workspaceFiles },
657
688
  autopilot,
658
689
  ...(runtime
659
690
  ? {
@@ -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);
@@ -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,48 @@
1
+ export function registerCopyEntry(app, deps) {
2
+ app.post('/api/workspaces/:workspaceId/files/copy', async (request, reply) => mutation(request, reply, deps, 'copy'));
3
+ }
4
+ export async function mutation(request, reply, deps, operation) {
5
+ const body = request.body;
6
+ if (!body ||
7
+ typeof body.source !== 'string' ||
8
+ typeof body.destinationDirectory !== 'string' ||
9
+ !['reject', 'replace', 'keep-both'].includes(body.conflict ?? ''))
10
+ return reply.code(400).send({ code: 'INVALID_FILE_MUTATION' });
11
+ const method = deps.files[operation];
12
+ if (!method)
13
+ return reply.code(503).send({ code: 'WORKSPACE_FILES_UNAVAILABLE' });
14
+ try {
15
+ const workspace = await deps.workspaces.resolve(request.params.workspaceId);
16
+ const result = await method.call(deps.files, workspace.realPath, body);
17
+ return map(reply, result);
18
+ }
19
+ catch (error) {
20
+ return reply
21
+ .code(error instanceof Error && error.message === 'WORKSPACE_NOT_FOUND' ? 404 : 503)
22
+ .send({
23
+ code: error instanceof Error && error.message === 'WORKSPACE_NOT_FOUND'
24
+ ? 'WORKSPACE_NOT_FOUND'
25
+ : 'WORKSPACE_FILES_UNAVAILABLE',
26
+ });
27
+ }
28
+ }
29
+ export function map(reply, result) {
30
+ if (result.kind === 'available')
31
+ return reply.send(result);
32
+ const mapped = {
33
+ missing: [404, 'FILE_NOT_FOUND'],
34
+ protected: [403, 'FILE_PROTECTED'],
35
+ symlink: [409, 'SYMLINK_UNSUPPORTED'],
36
+ 'invalid-destination': [400, 'INVALID_FILE_DESTINATION'],
37
+ 'source-inside-destination': [409, 'SOURCE_INSIDE_DESTINATION'],
38
+ 'same-parent': [409, 'SAME_PARENT_MOVE'],
39
+ conflict: [409, 'FILE_CONFLICT'],
40
+ 'replace-unsupported': [409, 'FILE_REPLACE_UNSUPPORTED'],
41
+ unreadable: [503, 'WORKSPACE_FILES_UNAVAILABLE'],
42
+ };
43
+ const [status, code] = mapped[result.kind] ?? [503, 'WORKSPACE_FILES_UNAVAILABLE'];
44
+ return reply.code(status).send({
45
+ code,
46
+ ...(result.kind === 'conflict' ? { replaceAllowed: result.replaceAllowed } : {}),
47
+ });
48
+ }
@@ -0,0 +1,23 @@
1
+ import { map } from '../copy-entry/endpoint.js';
2
+ export function registerDeleteEntry(app, deps) {
3
+ app.delete('/api/workspaces/:workspaceId/files', async (request, reply) => {
4
+ const body = request.body;
5
+ if (typeof body?.path !== 'string' || body.recursive !== true)
6
+ return reply.code(400).send({ code: 'INVALID_DELETE_REQUEST' });
7
+ if (!deps.files.delete)
8
+ return reply.code(503).send({ code: 'WORKSPACE_FILES_UNAVAILABLE' });
9
+ try {
10
+ const w = await deps.workspaces.resolve(request.params.workspaceId);
11
+ return map(reply, await deps.files.delete(w.realPath, { path: body.path, recursive: true }));
12
+ }
13
+ catch (error) {
14
+ return reply
15
+ .code(error instanceof Error && error.message === 'WORKSPACE_NOT_FOUND' ? 404 : 503)
16
+ .send({
17
+ code: error instanceof Error && error.message === 'WORKSPACE_NOT_FOUND'
18
+ ? 'WORKSPACE_NOT_FOUND'
19
+ : 'WORKSPACE_FILES_UNAVAILABLE',
20
+ });
21
+ }
22
+ });
23
+ }
@@ -0,0 +1,15 @@
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 parseRelativeDirectory(value) {
7
+ if (value === '')
8
+ return value;
9
+ if (value.includes('\\') || value.includes('\0') || value.startsWith('/') || value.endsWith('/'))
10
+ return null;
11
+ const parts = value.split('/');
12
+ if (parts.some((part) => part === '' || part === '.' || part === '..' || part === '.git'))
13
+ return null;
14
+ return value;
15
+ }
@@ -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 {};