@opengeni/api-router 0.5.7 → 0.7.3

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.
package/src/mcp/server.ts CHANGED
@@ -1,11 +1,19 @@
1
1
  import {
2
2
  CreateScheduledTaskRequest,
3
+ defaultRepositoryMountPath,
4
+ SESSION_EVENT_RAW_DELTA_TYPES,
5
+ SessionEventPayloadMode,
6
+ SessionEventReadDirection,
7
+ SessionEventReadMode,
8
+ SessionEventSemanticClass,
9
+ SessionEventType,
3
10
  SessionMcpCredentialUpdateInput,
4
11
  VariableSetVariableName,
5
12
  type AccessGrant,
6
13
  type GitHubRepository,
7
14
  type Permission,
8
15
  type ResourceRef,
16
+ type SessionAuthorizationOperation,
9
17
  UpdateScheduledTaskRequest,
10
18
  } from "@opengeni/contracts";
11
19
  import {
@@ -21,12 +29,18 @@ import {
21
29
  getSessionTurn,
22
30
  getVariableSet,
23
31
  getVariableSetByName,
24
- listGitHubInstallationIdsForWorkspace,
32
+ areGitHubRepositoriesAllowedForWorkspace,
25
33
  listScheduledTaskRuns,
26
34
  listScheduledTasks,
27
- listSessionEvents,
28
- listSessions,
35
+ listSessionEventPage,
36
+ listSessionDiscoverySummaries,
37
+ projectEffectiveControlForRelatedAccess,
38
+ projectSessionForRelatedAccess,
39
+ type SessionDiscoveryCursor,
40
+ type SessionDiscoveryOrderBy,
29
41
  listRigs,
42
+ listRigChangeMonitoringSummaries,
43
+ listRigVersionMonitoringSummaries,
30
44
  listSocialConnections,
31
45
  listSocialPosts,
32
46
  listVariableSets,
@@ -38,6 +52,7 @@ import {
38
52
  requireSession,
39
53
  saveWorkspaceMemory,
40
54
  searchWorkspaceMemories,
55
+ serializeEffectiveSessionControl,
41
56
  setSessionGoalStatus,
42
57
  setVariableSetVariable,
43
58
  updateScheduledTask,
@@ -49,20 +64,21 @@ import {
49
64
  import { appendAndPublishEvents } from "@opengeni/events";
50
65
  import {
51
66
  createGitHubAppInstallationToken,
52
- createSignedState,
53
67
  GitHubAppConfigurationError,
54
68
  githubAppMissingSettings,
55
- listGitHubAppRepositories,
56
- stateMaxAgeSeconds,
57
69
  } from "@opengeni/github";
58
70
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
59
71
  import * as z4 from "zod/v4";
60
- import { hasPermission } from "@opengeni/core";
72
+ import {
73
+ hasPermission,
74
+ requireSessionAuthorization,
75
+ requireSessionAuthorizationListScope,
76
+ type ResolvedSessionAuthorization,
77
+ } from "@opengeni/core";
61
78
  import { recordWorkspaceUsage, requireLimit } from "@opengeni/core";
62
79
  import type { ApiRouteDeps } from "@opengeni/core";
80
+ import { listWorkspaceGitHubRepositories } from "../github-access";
63
81
  import {
64
- listRigChangesForApi,
65
- listRigVersionsForApi,
66
82
  promoteVerifiedDefinitionEditChangeForApi,
67
83
  proposeRigChangeForApi,
68
84
  requireRigChangeForApi,
@@ -103,13 +119,18 @@ import {
103
119
  type FleetServices,
104
120
  type RunOnOp,
105
121
  } from "@opengeni/core";
106
- import { capEventPage, capSessionDetail } from "./session-view";
122
+ import {
123
+ boundSessionEventMcpPage,
124
+ boundSessionDetailMcp,
125
+ boundRigDetailMcp,
126
+ SESSION_EVENT_MCP_MAX_BYTES,
127
+ } from "./session-view";
107
128
  import type { ToolspaceMcpSurface } from "./toolspace";
108
129
 
109
130
  export type McpServerOptions = {
110
- // Origin of the HTTP request that reached the MCP route; last-resort base
111
- // for links the server mints (github_connect_link) when neither
112
- // OPENGENI_PUBLIC_BASE_URL nor the manifest base URL is configured.
131
+ // Origin of the HTTP request that reached the MCP route. Retained in the
132
+ // options ABI for browser-oriented tools; github_connect_link does not use
133
+ // it or mint state while new installation binding is disabled.
113
134
  requestOrigin?: string | null;
114
135
  toolspace?: ToolspaceMcpSurface | null;
115
136
  workspaceMemoryEnabled?: boolean | undefined;
@@ -148,8 +169,13 @@ export function buildOpenGeniMcpServer(
148
169
  inputSchema: { title: z4.string().min(1).max(200) },
149
170
  },
150
171
  async ({ title }) => {
151
- const result = await updateSessionTitle(deps, grant.workspaceId, sessionId, title, "agent");
152
- return json({ ok: true, updated: result.updated, title: result.title ?? title });
172
+ await authorizeFirstPartySession(deps, grant, sessionId, "session.title.write");
173
+ const result = await updateSessionTitle(deps, grant, sessionId, title, "agent");
174
+ return json({
175
+ ok: true,
176
+ updated: result.updated,
177
+ title: result.title ?? title,
178
+ });
153
179
  },
154
180
  );
155
181
  }
@@ -178,19 +204,20 @@ export function buildOpenGeniMcpServer(
178
204
  registerRigTools(server, deps, grant, can, sessionId, json);
179
205
  }
180
206
 
181
- // Orchestration, variableSet, and GitHub-connect tools are permission-gated
207
+ // Orchestration, variableSet, and GitHub status/token tools are permission-gated
182
208
  // at registration: a grant without the permission does not see the tool.
183
209
  // Sandboxed workers reach this server with the first-party delegated
184
210
  // permission set (firstPartyMcpPermissions in @opengeni/runtime), which is
185
211
  // POWERFUL BY DEFAULT — it carries sessions:*, variable sets:*, and github:use,
186
- // so agents can spawn/read sessions, manage variable set variables,
187
- // and mint GitHub install links out of the box. A user DEMOTES a specific
212
+ // so agents can spawn/read sessions, manage variable set variables, inspect
213
+ // GitHub connection availability, and refresh scoped tokens for already-bound
214
+ // repositories out of the box. A user DEMOTES a specific
188
215
  // session by setting a narrower session.firstPartyMcpPermissions (capped to
189
216
  // the creator's own grant); operators still cap what any session can be given.
190
217
  registerWorkspaceOrchestrationTools(server, deps, grant, can, sessionId, toolspaceMode, json);
191
218
  registerVariableSetTools(server, deps, grant, can, json);
192
219
  if (can("github:use")) {
193
- registerGitHubConnectTool(server, deps, grant, options, json);
220
+ registerGitHubConnectTool(server, deps, json);
194
221
  // TOKEN-BROKER (B1): the agent-refreshable git token. Session-scoped (keys off the
195
222
  // worker-signed sessionId claim so it mints for THIS session's repos), gated on
196
223
  // the same github:use capability as github_connect_link.
@@ -214,7 +241,9 @@ export function buildOpenGeniMcpServer(
214
241
  if (file.status !== "ready") {
215
242
  throw new Error(`file is ${file.status}`);
216
243
  }
217
- const signed = await deps.objectStorage.createGetUrl({ key: file.objectKey });
244
+ const signed = await deps.objectStorage.createGetUrl({
245
+ key: file.objectKey,
246
+ });
218
247
  return json({
219
248
  file: {
220
249
  id: file.id,
@@ -246,11 +275,7 @@ export function buildOpenGeniMcpServer(
246
275
  },
247
276
  async ({ limit }) => {
248
277
  try {
249
- const installationIds = await listGitHubInstallationIdsForWorkspace(
250
- deps.db,
251
- grant.workspaceId,
252
- );
253
- const repositories = await listGitHubAppRepositories(deps.settings, { installationIds });
278
+ const repositories = await listWorkspaceGitHubRepositories(deps, grant.workspaceId);
254
279
  const visible = typeof limit === "number" ? repositories.slice(0, limit) : repositories;
255
280
  return json({
256
281
  repositories: visible.map((repository) =>
@@ -380,7 +405,9 @@ export function buildOpenGeniMcpServer(
380
405
  inputSchema: { limit: z4.number().int().positive().optional() },
381
406
  },
382
407
  async ({ limit }) =>
383
- json({ tasks: await listScheduledTasks(deps.db, grant.workspaceId, limit ?? 100) }),
408
+ json({
409
+ tasks: await listScheduledTasks(deps.db, grant.workspaceId, limit ?? 100),
410
+ }),
384
411
  );
385
412
 
386
413
  server.registerTool(
@@ -429,7 +456,11 @@ export function buildOpenGeniMcpServer(
429
456
  payload,
430
457
  toolsProvided: scheduledTaskToolsProvided(args),
431
458
  });
432
- await syncCreatedScheduledTask({ db: deps.db, workflowClient: deps.workflowClient, task });
459
+ await syncCreatedScheduledTask({
460
+ db: deps.db,
461
+ workflowClient: deps.workflowClient,
462
+ task,
463
+ });
433
464
  return json(task);
434
465
  },
435
466
  );
@@ -526,7 +557,10 @@ export function buildOpenGeniMcpServer(
526
557
  {
527
558
  description:
528
559
  "Trigger a scheduled task immediately. Pass a stable triggerId to make a retried trigger idempotent (one charge, one run).",
529
- inputSchema: { id: z4.string().uuid(), triggerId: z4.string().min(1).max(128).optional() },
560
+ inputSchema: {
561
+ id: z4.string().uuid(),
562
+ triggerId: z4.string().min(1).max(128).optional(),
563
+ },
530
564
  },
531
565
  async ({ id, triggerId }) => {
532
566
  const task = await requireScheduledTask(deps.db, grant.workspaceId, id);
@@ -584,7 +618,10 @@ export function buildOpenGeniMcpServer(
584
618
  "scheduled_task_runs_list",
585
619
  {
586
620
  description: "List runs for a scheduled task.",
587
- inputSchema: { taskId: z4.string().uuid(), limit: z4.number().int().positive().optional() },
621
+ inputSchema: {
622
+ taskId: z4.string().uuid(),
623
+ limit: z4.number().int().positive().optional(),
624
+ },
588
625
  },
589
626
  async ({ taskId, limit }) =>
590
627
  json({
@@ -686,6 +723,7 @@ function registerGoalTools(
686
723
  },
687
724
  },
688
725
  async ({ text, successCriteria, maxAutoContinuations }) => {
726
+ await authorizeFirstPartySession(deps, grant, sessionId, "session.goal.write");
689
727
  await requireSession(deps.db, grant.workspaceId, sessionId);
690
728
  const callerTurnId =
691
729
  typeof grant.metadata?.["turnId"] === "string"
@@ -730,6 +768,7 @@ function registerGoalTools(
730
768
  },
731
769
  },
732
770
  async ({ text, successCriteria, progressNote }) => {
771
+ await authorizeFirstPartySession(deps, grant, sessionId, "session.goal.write");
733
772
  await requireSession(deps.db, grant.workspaceId, sessionId);
734
773
  const existing = await getSessionGoal(deps.db, grant.workspaceId, sessionId);
735
774
  if (!existing) {
@@ -767,6 +806,7 @@ function registerGoalTools(
767
806
  inputSchema: { evidence: z4.string().min(1) },
768
807
  },
769
808
  async ({ evidence }) => {
809
+ await authorizeFirstPartySession(deps, grant, sessionId, "session.goal.write");
770
810
  await requireSession(deps.db, grant.workspaceId, sessionId);
771
811
  const existing = await getSessionGoal(deps.db, grant.workspaceId, sessionId);
772
812
  if (!existing) {
@@ -796,6 +836,7 @@ function registerGoalTools(
796
836
  inputSchema: { rationale: z4.string().min(1) },
797
837
  },
798
838
  async ({ rationale }) => {
839
+ await authorizeFirstPartySession(deps, grant, sessionId, "session.goal.write");
799
840
  await requireSession(deps.db, grant.workspaceId, sessionId);
800
841
  const existing = await getSessionGoal(deps.db, grant.workspaceId, sessionId);
801
842
  if (!existing) {
@@ -826,7 +867,22 @@ function registerGoalTools(
826
867
  );
827
868
  }
828
869
 
829
- type JsonResult = (value: unknown) => { content: Array<{ type: "text"; text: string }> };
870
+ type JsonResult = (value: unknown) => {
871
+ content: Array<{ type: "text"; text: string }>;
872
+ };
873
+
874
+ async function authorizeFirstPartySession(
875
+ deps: ApiRouteDeps,
876
+ grant: AccessGrant,
877
+ sessionId: string,
878
+ operation: SessionAuthorizationOperation,
879
+ ): Promise<ResolvedSessionAuthorization | null> {
880
+ return await requireSessionAuthorization(deps, grant, {
881
+ sessionId,
882
+ operation,
883
+ surface: "first_party_mcp",
884
+ });
885
+ }
830
886
 
831
887
  const MemoryKindSchema = z4.enum(["preference", "semantic", "procedural", "decision", "episodic"]);
832
888
 
@@ -969,7 +1025,11 @@ function registerFleetTools(
969
1025
  sessionId: string,
970
1026
  json: JsonResult,
971
1027
  ): void {
972
- const services: FleetServices = { db: deps.db, settings: deps.settings, bus: deps.bus };
1028
+ const services: FleetServices = {
1029
+ db: deps.db,
1030
+ settings: deps.settings,
1031
+ bus: deps.bus,
1032
+ };
973
1033
 
974
1034
  // Resolve the session's group sandbox (the default/home fleet member) at
975
1035
  // call-time via the shared helper (same context the user-authenticated swap
@@ -1026,7 +1086,11 @@ function registerFleetTools(
1026
1086
  workdir: z4.string().optional(),
1027
1087
  }),
1028
1088
  z4.object({ kind: z4.literal("read"), path: z4.string().min(1) }),
1029
- z4.object({ kind: z4.literal("write"), path: z4.string().min(1), content: z4.string() }),
1089
+ z4.object({
1090
+ kind: z4.literal("write"),
1091
+ path: z4.string().min(1),
1092
+ content: z4.string(),
1093
+ }),
1030
1094
  ]),
1031
1095
  },
1032
1096
  },
@@ -1046,7 +1110,10 @@ function registerFleetTools(
1046
1110
  },
1047
1111
  async ({ kind, name }) =>
1048
1112
  json(
1049
- await provisionSandbox(services, await fleetContext(), { kind, ...(name ? { name } : {}) }),
1113
+ await provisionSandbox(services, await fleetContext(), {
1114
+ kind,
1115
+ ...(name ? { name } : {}),
1116
+ }),
1050
1117
  ),
1051
1118
  );
1052
1119
  }
@@ -1100,24 +1167,31 @@ function registerRigTools(
1100
1167
  server.registerTool(
1101
1168
  "rig_get",
1102
1169
  {
1103
- description: "Get a rig, its versions, and recent changes.",
1170
+ description:
1171
+ "Get one rig's bounded active definition plus compact historical version/change summaries. Historical setup scripts, checks, payloads, and verification logs are represented by counts/byte facts rather than copied into model context; use the access-controlled REST detail endpoints for exact retained definitions.",
1104
1172
  inputSchema: {
1105
1173
  rigId: z4.string().uuid(),
1174
+ versionLimit: z4.number().int().positive().optional(),
1106
1175
  changeLimit: z4.number().int().positive().optional(),
1107
1176
  },
1108
1177
  },
1109
- async ({ rigId, changeLimit }) => {
1178
+ async ({ rigId, versionLimit, changeLimit }) => {
1110
1179
  const rig = await requireRigForApi(deps.db, grant.workspaceId, rigId);
1111
- return json({
1112
- rig,
1113
- versions: await listRigVersionsForApi({ db: deps.db }, grant.workspaceId, rig.id),
1114
- changes: await listRigChangesForApi(
1115
- { db: deps.db },
1180
+ const [versions, changes] = await Promise.all([
1181
+ listRigVersionMonitoringSummaries(
1182
+ deps.db,
1116
1183
  grant.workspaceId,
1117
1184
  rig.id,
1118
- boundedMcpLimit(changeLimit),
1185
+ boundedRigHistoryLimit(versionLimit),
1119
1186
  ),
1120
- });
1187
+ listRigChangeMonitoringSummaries(
1188
+ deps.db,
1189
+ grant.workspaceId,
1190
+ rig.id,
1191
+ boundedRigHistoryLimit(changeLimit),
1192
+ ),
1193
+ ]);
1194
+ return json(boundRigDetailMcp(rig, versions, changes));
1121
1195
  },
1122
1196
  );
1123
1197
 
@@ -1237,6 +1311,7 @@ function exactAgentCommandContext(
1237
1311
  return {
1238
1312
  accountId: grant.accountId,
1239
1313
  workspaceId: grant.workspaceId,
1314
+ subjectId: grant.subjectId,
1240
1315
  callerSessionId,
1241
1316
  callerTurnId: turnId,
1242
1317
  callerAttemptId: attemptId,
@@ -1257,11 +1332,47 @@ function registerWorkspaceOrchestrationTools(
1257
1332
  server.registerTool(
1258
1333
  "sessions_list",
1259
1334
  {
1260
- description: "List sessions in this workspace, newest first.",
1261
- inputSchema: { limit: z4.number().int().positive().optional() },
1335
+ description: `List compact high-level session status in this workspace. Defaults to creation order; use orderBy=updatedAt with decimal activity-revision updatedAfter/updatedThrough tokens for gap-free indexed incremental monitoring independent of application clocks. Cursors are opaque revision-fenced keysets. includeLastMessage is opt-in; its rendered previews share a deterministic ${SESSION_DISCOVERY_PREVIEW_MAX_BYTES}-byte UTF-8 aggregate budget, and omitted previews include a bounded session_events drill-down input (exact message type, direction=before, limit=1, monitoring summary). Use session_get for exact known targets and detailed resources/tools/settings. The list never returns full session objects or history.`,
1336
+ inputSchema: {
1337
+ limit: z4.number().int().positive().max(100).optional(),
1338
+ cursor: z4.string().max(512).optional(),
1339
+ includeLastMessage: z4.boolean().optional(),
1340
+ orderBy: z4.enum(["createdAt", "updatedAt"]).optional(),
1341
+ updatedAfter: z4.string().max(64).optional(),
1342
+ },
1343
+ },
1344
+ async ({ limit, cursor, includeLastMessage, orderBy: requestedOrderBy, updatedAfter }) => {
1345
+ const authorizationScope = await requireSessionAuthorizationListScope(
1346
+ deps,
1347
+ grant,
1348
+ "first_party_mcp",
1349
+ );
1350
+ const decodedCursor = cursor ? decodeSessionDiscoveryCursor(cursor) : undefined;
1351
+ const orderBy: SessionDiscoveryOrderBy =
1352
+ requestedOrderBy ?? decodedCursor?.orderBy ?? "createdAt";
1353
+ if (decodedCursor && decodedCursor.orderBy !== orderBy) {
1354
+ throw new Error("sessions_list cursor order does not match orderBy");
1355
+ }
1356
+ const normalizedUpdatedAfter =
1357
+ updatedAfter !== undefined
1358
+ ? normalizeSessionDiscoveryRevision(updatedAfter, "updatedAfter")
1359
+ : (decodedCursor?.updatedAfter ?? undefined);
1360
+ if (normalizedUpdatedAfter !== undefined && orderBy !== "updatedAt") {
1361
+ throw new Error("sessions_list updatedAfter requires orderBy=updatedAt");
1362
+ }
1363
+ if (decodedCursor && decodedCursor.updatedAfter !== (normalizedUpdatedAfter ?? null)) {
1364
+ throw new Error("sessions_list cursor does not match updatedAfter");
1365
+ }
1366
+ const page = await listSessionDiscoverySummaries(deps.db, grant.workspaceId, {
1367
+ limit: boundedSessionDiscoveryLimit(limit),
1368
+ ...(decodedCursor ? { cursor: decodedCursor } : {}),
1369
+ includeLastMessage: includeLastMessage === true,
1370
+ orderBy,
1371
+ ...(normalizedUpdatedAfter ? { updatedAfter: normalizedUpdatedAfter } : {}),
1372
+ ...(authorizationScope ? { authorizationScope } : {}),
1373
+ });
1374
+ return json(capSessionDiscoveryPage(page, includeLastMessage === true));
1262
1375
  },
1263
- async ({ limit }) =>
1264
- json({ sessions: await listSessions(deps.db, grant.workspaceId, boundedMcpLimit(limit)) }),
1265
1376
  );
1266
1377
 
1267
1378
  server.registerTool(
@@ -1272,15 +1383,25 @@ function registerWorkspaceOrchestrationTools(
1272
1383
  inputSchema: { sessionId: z4.string().uuid() },
1273
1384
  },
1274
1385
  async ({ sessionId }) => {
1386
+ const authorization = await authorizeFirstPartySession(
1387
+ deps,
1388
+ grant,
1389
+ sessionId,
1390
+ "session.read",
1391
+ );
1275
1392
  const session = await getSession(deps.db, grant.workspaceId, sessionId);
1276
1393
  if (!session) {
1277
1394
  throw new Error("session not found");
1278
1395
  }
1279
1396
  const queue = await getSessionQueueSnapshot(deps.db, grant.workspaceId, sessionId);
1280
- return json({
1281
- ...capSessionDetail(session),
1282
- effectiveControl: queue?.effectiveControl ?? null,
1283
- });
1397
+ const projected = projectSessionForRelatedAccess(
1398
+ {
1399
+ ...session,
1400
+ effectiveControl: queue?.effectiveControl ?? session.effectiveControl,
1401
+ },
1402
+ authorization?.relatedSessionAccess ?? "root",
1403
+ );
1404
+ return json(boundSessionDetailMcp(projected));
1284
1405
  },
1285
1406
  );
1286
1407
 
@@ -1288,28 +1409,83 @@ function registerWorkspaceOrchestrationTools(
1288
1409
  "session_events",
1289
1410
  {
1290
1411
  description:
1291
- "Read a session's event timeline (oldest first), to monitor another session's progress. Pass `after` = the highest event `sequence` already seen to page forward; the response's `nextAfter` is that cursor. The response is BYTE-CAPPED for a monitoring glance: fat per-event payloads (a worker's verbatim tool outputs, message/reasoning bodies) are clamped, and an over-budget page is reduced to its head + tail with a marker page the gap with `after`/`limit`, or read the worker's session notebook, if you need omitted content verbatim. `nextAfter` always advances past every event the page covered, so paging never skips real events.",
1412
+ "Read a compact semantic tail only when session_get status is insufficient. With no cursor, this returns the newest matching events and excludes raw message/reasoning/command/PTY deltas. Use `latest` as an exclusive lookup for the newest event in exactly one semantic class; it cannot be combined with type or class filters. Use nextBefore to page older or explicit after/nextAfter to page forward. Type/class filters run in the RLS-scoped database query. payloadMode none|summary|full controls retained audit payload projection, but every model result is independently byte-capped with explicit truncation and exact covered sequence bounds. Exact retained forensic payloads require the access-controlled REST/SDK events API with mode=forensic&payloadMode=full; generic source bytes never retained by the audit boundary remain unavailable.",
1292
1413
  inputSchema: {
1293
1414
  sessionId: z4.string().uuid(),
1294
1415
  after: z4.number().int().nonnegative().optional(),
1416
+ before: z4.number().int().positive().optional(),
1295
1417
  limit: z4.number().int().positive().optional(),
1418
+ direction: z4.enum(SessionEventReadDirection.options).optional(),
1419
+ mode: z4.enum(SessionEventReadMode.options).optional(),
1420
+ payloadMode: z4.enum(SessionEventPayloadMode.options).optional(),
1421
+ includeTypes: z4.array(z4.enum(SessionEventType.options)).max(100).optional(),
1422
+ excludeTypes: z4.array(z4.enum(SessionEventType.options)).max(100).optional(),
1423
+ includeClasses: z4
1424
+ .array(z4.enum(SessionEventSemanticClass.options))
1425
+ .max(SessionEventSemanticClass.options.length)
1426
+ .optional(),
1427
+ excludeClasses: z4
1428
+ .array(z4.enum(SessionEventSemanticClass.options))
1429
+ .max(SessionEventSemanticClass.options.length)
1430
+ .optional(),
1431
+ latest: z4.enum(SessionEventSemanticClass.options).optional(),
1296
1432
  },
1297
1433
  },
1298
- async ({ sessionId, after, limit }) => {
1434
+ async ({
1435
+ sessionId,
1436
+ after,
1437
+ before,
1438
+ limit,
1439
+ direction: requestedDirection,
1440
+ mode: requestedMode,
1441
+ payloadMode: requestedPayloadMode,
1442
+ includeTypes,
1443
+ excludeTypes,
1444
+ includeClasses,
1445
+ excludeClasses,
1446
+ latest,
1447
+ }) => {
1448
+ await authorizeFirstPartySession(deps, grant, sessionId, "session.events.read");
1299
1449
  await requireSession(deps.db, grant.workspaceId, sessionId);
1300
- const events = await listSessionEvents(
1301
- deps.db,
1302
- grant.workspaceId,
1303
- sessionId,
1304
- after ?? 0,
1305
- boundedMcpLimit(limit),
1306
- );
1307
- const capped = capEventPage(events);
1308
- return json({
1309
- events: capped.events,
1310
- nextAfter: capped.nextAfter ?? after ?? 0,
1311
- ...(capped.truncated ? { truncated: true } : {}),
1450
+ if (
1451
+ latest &&
1452
+ [includeTypes, excludeTypes, includeClasses, excludeClasses].some(
1453
+ (filter) => filter !== undefined,
1454
+ )
1455
+ ) {
1456
+ throw new Error("latest cannot be combined with event filters");
1457
+ }
1458
+ const mode = requestedMode ?? (after !== undefined ? "forensic" : "monitoring");
1459
+ const direction = latest
1460
+ ? "before"
1461
+ : (requestedDirection ??
1462
+ (before !== undefined ? "before" : after !== undefined ? "after" : "before"));
1463
+ const payloadMode = requestedPayloadMode ?? (mode === "monitoring" ? "summary" : "full");
1464
+ const dbPage = await listSessionEventPage(deps.db, grant.workspaceId, sessionId, {
1465
+ after: after ?? 0,
1466
+ ...(before !== undefined ? { before } : {}),
1467
+ direction,
1468
+ limit: latest ? 1 : boundedSessionEventMcpLimit(limit),
1469
+ payloadMode,
1470
+ includeTypes: includeTypes ?? [],
1471
+ excludeTypes: excludeTypes ?? [],
1472
+ includeClasses: latest ? [latest] : (includeClasses ?? []),
1473
+ excludeClasses: excludeClasses ?? [],
1474
+ ...(mode === "monitoring" ? { defaultExcludeTypes: SESSION_EVENT_RAW_DELTA_TYPES } : {}),
1475
+ maxBytes: SESSION_EVENT_MCP_MAX_BYTES * 4,
1312
1476
  });
1477
+ return json(
1478
+ boundSessionEventMcpPage({
1479
+ events: dbPage.events,
1480
+ mode,
1481
+ payloadMode,
1482
+ direction,
1483
+ sourceHasMore: dbPage.hasMore,
1484
+ sourceTruncatedBy: dbPage.truncatedBy,
1485
+ after: after ?? 0,
1486
+ before: before ?? null,
1487
+ }),
1488
+ );
1313
1489
  },
1314
1490
  );
1315
1491
  }
@@ -1360,7 +1536,14 @@ function registerWorkspaceOrchestrationTools(
1360
1536
  idempotencyKey: z4.string().min(1).max(200).optional(),
1361
1537
  // First-party MCP token permissions for the spawned session; every
1362
1538
  // permission must be held by this grant (validated in the domain).
1363
- firstPartyMcpPermissions: z4.array(z4.string()).optional(),
1539
+ // A goal requires goals:manage in the resulting set; it is never
1540
+ // silently added beyond the inherited or explicit authority.
1541
+ firstPartyMcpPermissions: z4
1542
+ .array(z4.string())
1543
+ .optional()
1544
+ .describe(
1545
+ "Optional first-party capability set for the child. Omit to inherit this session's effective permissions. An explicit set may only narrow capabilities held by this session. A goal-bearing child requires goals:manage in the resulting set; creation fails rather than adding it implicitly.",
1546
+ ),
1364
1547
  // Shared-sandbox placement (addendum 05 §D). OMIT (default) to SHARE the
1365
1548
  // creator's box — one filesystem/repo/desktop, N independent conversations;
1366
1549
  // this is the SAFE DEFAULT. Pass "new" for a fresh isolated box (a different
@@ -1394,7 +1577,12 @@ function registerWorkspaceOrchestrationTools(
1394
1577
  // arbitrary session's wake channel without sessions:control on it.
1395
1578
  },
1396
1579
  },
1397
- async (args) => json(await createSessionForRequest(deps, grant, grant.workspaceId, args)),
1580
+ async (args) => {
1581
+ if (callerSessionId !== null) {
1582
+ await authorizeFirstPartySession(deps, grant, callerSessionId, "session.child.create");
1583
+ }
1584
+ return json(await createSessionForRequest(deps, grant, grant.workspaceId, args));
1585
+ },
1398
1586
  );
1399
1587
  }
1400
1588
 
@@ -1414,6 +1602,7 @@ function registerWorkspaceOrchestrationTools(
1414
1602
  },
1415
1603
  },
1416
1604
  async ({ sessionId: targetSessionId, text, idempotencyKey, mcpCredentialUpdates }) => {
1605
+ await authorizeFirstPartySession(deps, grant, targetSessionId, "session.append");
1417
1606
  if (callerSessionId !== null) {
1418
1607
  if ((mcpCredentialUpdates?.length ?? 0) > 0) {
1419
1608
  throw new Error("internal session updates cannot change MCP credentials");
@@ -1464,6 +1653,12 @@ function registerWorkspaceOrchestrationTools(
1464
1653
  },
1465
1654
  },
1466
1655
  async ({ sessionId, idempotencyKey, reason }) => {
1656
+ const authorization = await authorizeFirstPartySession(
1657
+ deps,
1658
+ grant,
1659
+ sessionId,
1660
+ "session.control",
1661
+ );
1467
1662
  if (callerSessionId !== null) {
1468
1663
  const controlled = await controlAgentSessionWorkstream(
1469
1664
  deps,
@@ -1477,27 +1672,37 @@ function registerWorkspaceOrchestrationTools(
1477
1672
  );
1478
1673
  return json({
1479
1674
  receiptId: controlled.receipt.id,
1480
- effectiveControl: controlled.control,
1675
+ effectiveControl: projectEffectiveControlForRelatedAccess(
1676
+ serializeEffectiveSessionControl(controlled.control),
1677
+ sessionId,
1678
+ authorization?.relatedSessionAccess ?? "root",
1679
+ ),
1481
1680
  interruptionCount: controlled.interruptionCount,
1482
1681
  replay: controlled.replay,
1483
1682
  });
1484
1683
  }
1485
- return json(
1486
- await controlHumanSessionWorkstream(
1487
- deps,
1488
- {
1489
- accountId: grant.accountId,
1490
- workspaceId: grant.workspaceId,
1491
- sessionId,
1492
- subjectId: grant.subjectId,
1493
- },
1494
- {
1495
- action: "pause",
1496
- clientEventId: idempotencyKey,
1497
- ...(reason ? { reason } : {}),
1498
- },
1499
- ),
1684
+ const controlled = await controlHumanSessionWorkstream(
1685
+ deps,
1686
+ {
1687
+ accountId: grant.accountId,
1688
+ workspaceId: grant.workspaceId,
1689
+ sessionId,
1690
+ subjectId: grant.subjectId,
1691
+ },
1692
+ {
1693
+ action: "pause",
1694
+ clientEventId: idempotencyKey,
1695
+ ...(reason ? { reason } : {}),
1696
+ },
1500
1697
  );
1698
+ return json({
1699
+ ...controlled,
1700
+ effectiveControl: projectEffectiveControlForRelatedAccess(
1701
+ controlled.effectiveControl,
1702
+ sessionId,
1703
+ authorization?.relatedSessionAccess ?? "root",
1704
+ ),
1705
+ });
1501
1706
  },
1502
1707
  );
1503
1708
 
@@ -1513,6 +1718,12 @@ function registerWorkspaceOrchestrationTools(
1513
1718
  },
1514
1719
  },
1515
1720
  async ({ sessionId, idempotencyKey, reason }) => {
1721
+ const authorization = await authorizeFirstPartySession(
1722
+ deps,
1723
+ grant,
1724
+ sessionId,
1725
+ "session.control",
1726
+ );
1516
1727
  if (callerSessionId !== null) {
1517
1728
  const controlled = await controlAgentSessionWorkstream(
1518
1729
  deps,
@@ -1526,27 +1737,37 @@ function registerWorkspaceOrchestrationTools(
1526
1737
  );
1527
1738
  return json({
1528
1739
  receiptId: controlled.receipt.id,
1529
- effectiveControl: controlled.control,
1740
+ effectiveControl: projectEffectiveControlForRelatedAccess(
1741
+ serializeEffectiveSessionControl(controlled.control),
1742
+ sessionId,
1743
+ authorization?.relatedSessionAccess ?? "root",
1744
+ ),
1530
1745
  interruptionCount: controlled.interruptionCount,
1531
1746
  replay: controlled.replay,
1532
1747
  });
1533
1748
  }
1534
- return json(
1535
- await controlHumanSessionWorkstream(
1536
- deps,
1537
- {
1538
- accountId: grant.accountId,
1539
- workspaceId: grant.workspaceId,
1540
- sessionId,
1541
- subjectId: grant.subjectId,
1542
- },
1543
- {
1544
- action: "resume",
1545
- clientEventId: idempotencyKey,
1546
- ...(reason ? { reason } : {}),
1547
- },
1548
- ),
1749
+ const controlled = await controlHumanSessionWorkstream(
1750
+ deps,
1751
+ {
1752
+ accountId: grant.accountId,
1753
+ workspaceId: grant.workspaceId,
1754
+ sessionId,
1755
+ subjectId: grant.subjectId,
1756
+ },
1757
+ {
1758
+ action: "resume",
1759
+ clientEventId: idempotencyKey,
1760
+ ...(reason ? { reason } : {}),
1761
+ },
1549
1762
  );
1763
+ return json({
1764
+ ...controlled,
1765
+ effectiveControl: projectEffectiveControlForRelatedAccess(
1766
+ controlled.effectiveControl,
1767
+ sessionId,
1768
+ authorization?.relatedSessionAccess ?? "root",
1769
+ ),
1770
+ });
1550
1771
  },
1551
1772
  );
1552
1773
 
@@ -1563,6 +1784,7 @@ function registerWorkspaceOrchestrationTools(
1563
1784
  },
1564
1785
  },
1565
1786
  async ({ sessionId, instruction, idempotencyKey }) => {
1787
+ await authorizeFirstPartySession(deps, grant, sessionId, "session.steer");
1566
1788
  const result = await steerAgentSession(
1567
1789
  deps,
1568
1790
  exactAgentCommandContext(grant, callerSessionId),
@@ -1590,15 +1812,14 @@ function registerWorkspaceOrchestrationTools(
1590
1812
  },
1591
1813
  },
1592
1814
  async ({ session_id, title }) => {
1815
+ await authorizeFirstPartySession(deps, grant, session_id, "session.title.write");
1593
1816
  await requireSession(deps.db, grant.workspaceId, session_id);
1594
- const result = await updateSessionTitle(
1595
- deps,
1596
- grant.workspaceId,
1597
- session_id,
1598
- title,
1599
- "agent",
1600
- );
1601
- return json({ ok: true, updated: result.updated, title: result.title ?? title });
1817
+ const result = await updateSessionTitle(deps, grant, session_id, title, "agent");
1818
+ return json({
1819
+ ok: true,
1820
+ updated: result.updated,
1821
+ title: result.title ?? title,
1822
+ });
1602
1823
  },
1603
1824
  );
1604
1825
  }
@@ -1703,7 +1924,11 @@ function registerVariableSetTools(
1703
1924
  variableSetId: variableSet.id,
1704
1925
  variableName: parsedName.data,
1705
1926
  });
1706
- const responseVariableSet = { id: variableSet.id, name: variableSet.name, created };
1927
+ const responseVariableSet = {
1928
+ id: variableSet.id,
1929
+ name: variableSet.name,
1930
+ created,
1931
+ };
1707
1932
  return json({
1708
1933
  variableSet: responseVariableSet,
1709
1934
  environment: responseVariableSet,
@@ -1750,22 +1975,15 @@ function registerVariableSetTools(
1750
1975
  }
1751
1976
  }
1752
1977
 
1753
- // GitHub connect link for manager-style agents: mirrors GET .../github/app but
1754
- // returns a browser entry URL (.../github/connect) that plants the CSRF state
1755
- // cookie before forwarding to GitHub, because an MCP-issued link is opened in
1756
- // a browser that never called the API directly.
1757
- function registerGitHubConnectTool(
1758
- server: McpServer,
1759
- deps: ApiRouteDeps,
1760
- grant: AccessGrant,
1761
- options: McpServerOptions,
1762
- json: JsonResult,
1763
- ): void {
1978
+ // The tool remains registered for compatibility, but every new installation
1979
+ // binding entry point is fail-closed until a provider-supported authority
1980
+ // proof stronger than installation visibility is available.
1981
+ function registerGitHubConnectTool(server: McpServer, deps: ApiRouteDeps, json: JsonResult): void {
1764
1982
  server.registerTool(
1765
1983
  "github_connect_link",
1766
1984
  {
1767
1985
  description:
1768
- "Create a workspace-bound GitHub App install link to share with a human. Opening it redirects to GitHub to install the app and select repositories for this workspace; completing the connection requires the person to be signed in to this OpenGeni deployment with github:manage. The link expires.",
1986
+ "Report GitHub App connection availability. New installation binding is disabled until GitHub installation authority can be proven, so installUrl and linkUrl are null.",
1769
1987
  inputSchema: {},
1770
1988
  },
1771
1989
  async () => {
@@ -1773,28 +1991,19 @@ function registerGitHubConnectTool(
1773
1991
  const missing = githubAppMissingSettings(settings);
1774
1992
  const slug = settings.githubAppSlug?.trim() || null;
1775
1993
  if (missing.length > 0 || !slug) {
1776
- return json({ configured: false, appSlug: slug, installUrl: null, missing });
1777
- }
1778
- const base = (
1779
- settings.publicBaseUrl ??
1780
- settings.githubAppManifestBaseUrl ??
1781
- options.requestOrigin ??
1782
- ""
1783
- ).replace(/\/+$/, "");
1784
- if (!base) {
1785
- throw new Error(
1786
- "github_connect_link requires OPENGENI_PUBLIC_BASE_URL (or OPENGENI_GITHUB_APP_MANIFEST_BASE_URL) so the install link can route through this deployment",
1787
- );
1994
+ return json({
1995
+ configured: false,
1996
+ appSlug: slug,
1997
+ installUrl: null,
1998
+ linkUrl: null,
1999
+ missing,
2000
+ });
1788
2001
  }
1789
- const state = createSignedState(deps.githubStateSecret, {
1790
- accountId: grant.accountId,
1791
- workspaceId: grant.workspaceId,
1792
- });
1793
2002
  return json({
1794
2003
  configured: true,
1795
2004
  appSlug: slug,
1796
- installUrl: `${base}/v1/workspaces/${grant.workspaceId}/github/connect?state=${encodeURIComponent(state)}`,
1797
- expiresInSeconds: stateMaxAgeSeconds,
2005
+ installUrl: null,
2006
+ linkUrl: null,
1798
2007
  missing: [],
1799
2008
  });
1800
2009
  },
@@ -1845,9 +2054,20 @@ function registerGitHubTokenTool(
1845
2054
  if (selected.some((item) => item.installationId !== installationId)) {
1846
2055
  throw new Error("GitHub App repository resources must belong to one installation");
1847
2056
  }
2057
+ const repositoryIds = selected.map((item) => item.repositoryId);
2058
+ if (
2059
+ !(await areGitHubRepositoriesAllowedForWorkspace(
2060
+ deps.db,
2061
+ grant.workspaceId,
2062
+ installationId,
2063
+ repositoryIds,
2064
+ ))
2065
+ ) {
2066
+ throw new Error("this workspace no longer authorizes the session's GitHub repositories");
2067
+ }
1848
2068
  const token = await createGitHubAppInstallationToken(deps.settings, {
1849
2069
  installationId,
1850
- repositoryIds: selected.map((item) => item.repositoryId),
2070
+ repositoryIds,
1851
2071
  });
1852
2072
  return json({
1853
2073
  token,
@@ -1881,9 +2101,12 @@ function repositoryWithScheduledTaskResource(
1881
2101
  kind: "repository",
1882
2102
  uri,
1883
2103
  ref: repository.defaultBranch,
1884
- mountPath: repositoryMountPath(uri),
2104
+ mountPath: defaultRepositoryMountPath(uri),
1885
2105
  ...(repository.private
1886
- ? { githubInstallationId: repository.installationId, githubRepositoryId: repository.id }
2106
+ ? {
2107
+ githubInstallationId: repository.installationId,
2108
+ githubRepositoryId: repository.id,
2109
+ }
1887
2110
  : {}),
1888
2111
  },
1889
2112
  };
@@ -1892,12 +2115,7 @@ function repositoryWithScheduledTaskResource(
1892
2115
  function normalizedRepositoryUri(value: string): string {
1893
2116
  const url = new URL(value);
1894
2117
  const path = url.pathname.replace(/^\/+|\/+$/g, "").replace(/\.git$/, "");
1895
- return `https://${url.hostname.toLowerCase()}/${path}.git`;
1896
- }
1897
-
1898
- function repositoryMountPath(uri: string): string {
1899
- const url = new URL(uri);
1900
- return `repos/${url.pathname.replace(/^\/+|\/+$/g, "").replace(/\.git$/, "")}`;
2118
+ return `https://${url.host.toLowerCase()}/${path}.git`;
1901
2119
  }
1902
2120
 
1903
2121
  function boundedMcpLimit(limit: number | undefined): number {
@@ -1907,6 +2125,416 @@ function boundedMcpLimit(limit: number | undefined): number {
1907
2125
  return Math.min(500, Math.max(1, Math.floor(limit)));
1908
2126
  }
1909
2127
 
2128
+ function boundedRigHistoryLimit(limit: number | undefined): number {
2129
+ if (!limit || !Number.isFinite(limit)) return 20;
2130
+ return Math.min(100, Math.max(1, Math.floor(limit)));
2131
+ }
2132
+
2133
+ function boundedSessionEventMcpLimit(limit: number | undefined): number {
2134
+ if (!limit || !Number.isFinite(limit)) return 40;
2135
+ return Math.min(250, Math.max(1, Math.floor(limit)));
2136
+ }
2137
+
2138
+ const SESSION_DISCOVERY_DEFAULT_LIMIT = 20;
2139
+ const SESSION_DISCOVERY_MAX_LIMIT = 100;
2140
+ const SESSION_DISCOVERY_TEXT_CHARS = 600;
2141
+ const SESSION_DISCOVERY_PREVIEW_MAX_BYTES = 16_384;
2142
+ const SESSION_DISCOVERY_PREVIEW_OMISSION_REASON = "aggregatePreviewBudget" as const;
2143
+ const SESSION_DISCOVERY_PAGE_MAX_BYTES = 128_000;
2144
+ const SESSION_DISCOVERY_PREVIEW_DRILL_DOWN_TOOL = "session_events" as const;
2145
+ const SESSION_DISCOVERY_PREVIEW_DRILL_DOWN_BASE_INPUT = {
2146
+ direction: "before",
2147
+ limit: 1,
2148
+ mode: "monitoring",
2149
+ payloadMode: "summary",
2150
+ } as const;
2151
+
2152
+ function sessionDiscoveryPreviewDrillDownInput(sessionId: string, type: SessionEventType) {
2153
+ return {
2154
+ sessionId,
2155
+ includeTypes: [type],
2156
+ ...SESSION_DISCOVERY_PREVIEW_DRILL_DOWN_BASE_INPUT,
2157
+ };
2158
+ }
2159
+
2160
+ function boundedSessionDiscoveryLimit(limit: number | undefined): number {
2161
+ if (!limit || !Number.isFinite(limit)) return SESSION_DISCOVERY_DEFAULT_LIMIT;
2162
+ return Math.min(SESSION_DISCOVERY_MAX_LIMIT, Math.max(1, Math.floor(limit)));
2163
+ }
2164
+
2165
+ export function encodeSessionDiscoveryCursor(cursor: SessionDiscoveryCursor): string {
2166
+ return Buffer.from(
2167
+ JSON.stringify({
2168
+ v: 2,
2169
+ orderBy: cursor.orderBy,
2170
+ sortRevision: cursor.sortRevision,
2171
+ sortAt: cursor.sortAt,
2172
+ id: cursor.id,
2173
+ snapshotAt: cursor.snapshotAt,
2174
+ snapshotRevision: cursor.snapshotRevision,
2175
+ updatedAfter: cursor.updatedAfter,
2176
+ }),
2177
+ "utf8",
2178
+ ).toString("base64url");
2179
+ }
2180
+
2181
+ const SESSION_DISCOVERY_TIMESTAMP = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,6})?Z$/;
2182
+ const SESSION_DISCOVERY_REVISION = /^(?:0|[1-9]\d*)$/;
2183
+ const SESSION_DISCOVERY_REVISION_MAX = 9_223_372_036_854_775_807n;
2184
+ const SESSION_DISCOVERY_UUID =
2185
+ /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
2186
+
2187
+ function normalizeSessionDiscoveryTimestamp(value: string, label: string): string {
2188
+ if (!SESSION_DISCOVERY_TIMESTAMP.test(value) || Number.isNaN(new Date(value).getTime())) {
2189
+ throw new Error(`sessions_list ${label} must be an ISO UTC date-time`);
2190
+ }
2191
+ // Preserve up to six fractional digits. Converting through JS Date would
2192
+ // discard PostgreSQL microseconds and can skip equal-millisecond rows.
2193
+ return value;
2194
+ }
2195
+
2196
+ function normalizeSessionDiscoveryRevision(value: string, label: string): string {
2197
+ if (!SESSION_DISCOVERY_REVISION.test(value)) {
2198
+ throw new Error(`sessions_list ${label} must be a decimal activity revision`);
2199
+ }
2200
+ const revision = BigInt(value);
2201
+ if (revision > SESSION_DISCOVERY_REVISION_MAX) {
2202
+ throw new Error(`sessions_list ${label} exceeds the database activity revision range`);
2203
+ }
2204
+ return revision.toString();
2205
+ }
2206
+
2207
+ export function decodeSessionDiscoveryCursor(value: string): SessionDiscoveryCursor {
2208
+ try {
2209
+ const parsed = JSON.parse(Buffer.from(value, "base64url").toString("utf8")) as {
2210
+ v?: unknown;
2211
+ orderBy?: unknown;
2212
+ sortRevision?: unknown;
2213
+ sortAt?: unknown;
2214
+ createdAt?: unknown;
2215
+ id?: unknown;
2216
+ snapshotAt?: unknown;
2217
+ snapshotRevision?: unknown;
2218
+ updatedAfter?: unknown;
2219
+ };
2220
+ if (
2221
+ parsed.v === undefined &&
2222
+ typeof parsed.createdAt === "string" &&
2223
+ typeof parsed.id === "string" &&
2224
+ SESSION_DISCOVERY_UUID.test(parsed.id)
2225
+ ) {
2226
+ const createdAt = normalizeSessionDiscoveryTimestamp(
2227
+ parsed.createdAt,
2228
+ "legacy cursor createdAt",
2229
+ );
2230
+ return {
2231
+ orderBy: "createdAt",
2232
+ sortRevision: "0",
2233
+ sortAt: createdAt,
2234
+ id: parsed.id,
2235
+ snapshotAt: createdAt,
2236
+ snapshotRevision: "0",
2237
+ updatedAfter: null,
2238
+ };
2239
+ }
2240
+ // The timestamp-fenced v1 format was never safe for updated-order
2241
+ // continuation. Preserve rolling compatibility only for creation cursors,
2242
+ // whose immutable ordering does not need an activity revision.
2243
+ if (
2244
+ parsed.v === 1 &&
2245
+ parsed.orderBy === "createdAt" &&
2246
+ typeof parsed.sortAt === "string" &&
2247
+ typeof parsed.snapshotAt === "string" &&
2248
+ parsed.updatedAfter === null &&
2249
+ typeof parsed.id === "string" &&
2250
+ SESSION_DISCOVERY_UUID.test(parsed.id)
2251
+ ) {
2252
+ return {
2253
+ orderBy: "createdAt",
2254
+ sortRevision: "0",
2255
+ sortAt: normalizeSessionDiscoveryTimestamp(parsed.sortAt, "cursor sortAt"),
2256
+ id: parsed.id,
2257
+ snapshotAt: normalizeSessionDiscoveryTimestamp(parsed.snapshotAt, "cursor snapshotAt"),
2258
+ snapshotRevision: "0",
2259
+ updatedAfter: null,
2260
+ };
2261
+ }
2262
+ if (
2263
+ parsed.v !== 2 ||
2264
+ (parsed.orderBy !== "createdAt" && parsed.orderBy !== "updatedAt") ||
2265
+ typeof parsed.sortRevision !== "string" ||
2266
+ typeof parsed.sortAt !== "string" ||
2267
+ typeof parsed.snapshotAt !== "string" ||
2268
+ typeof parsed.snapshotRevision !== "string" ||
2269
+ (parsed.updatedAfter !== null && typeof parsed.updatedAfter !== "string") ||
2270
+ typeof parsed.id !== "string" ||
2271
+ !SESSION_DISCOVERY_UUID.test(parsed.id)
2272
+ ) {
2273
+ throw new Error("invalid cursor fields");
2274
+ }
2275
+ const sortAt = normalizeSessionDiscoveryTimestamp(parsed.sortAt, "cursor sortAt");
2276
+ const snapshotAt = normalizeSessionDiscoveryTimestamp(parsed.snapshotAt, "cursor snapshotAt");
2277
+ const sortRevision = normalizeSessionDiscoveryRevision(
2278
+ parsed.sortRevision,
2279
+ "cursor sortRevision",
2280
+ );
2281
+ const snapshotRevision = normalizeSessionDiscoveryRevision(
2282
+ parsed.snapshotRevision,
2283
+ "cursor snapshotRevision",
2284
+ );
2285
+ const normalizedUpdatedAfter =
2286
+ parsed.updatedAfter === null
2287
+ ? null
2288
+ : normalizeSessionDiscoveryRevision(parsed.updatedAfter, "cursor updatedAfter");
2289
+ if (normalizedUpdatedAfter !== null && parsed.orderBy !== "updatedAt") {
2290
+ throw new Error("incremental cursor requires updatedAt order");
2291
+ }
2292
+ if (parsed.orderBy === "createdAt" && (sortRevision !== "0" || snapshotRevision !== "0")) {
2293
+ throw new Error("creation cursor cannot carry activity revisions");
2294
+ }
2295
+ return {
2296
+ orderBy: parsed.orderBy,
2297
+ sortRevision,
2298
+ sortAt,
2299
+ id: parsed.id,
2300
+ snapshotAt,
2301
+ snapshotRevision,
2302
+ updatedAfter: normalizedUpdatedAfter,
2303
+ };
2304
+ } catch {
2305
+ throw new Error("sessions_list cursor is invalid");
2306
+ }
2307
+ }
2308
+
2309
+ function capSessionDiscoveryText(
2310
+ value: string | null,
2311
+ maxChars = SESSION_DISCOVERY_TEXT_CHARS,
2312
+ originalChars?: number | null,
2313
+ ) {
2314
+ if (value === null) {
2315
+ return { text: value, truncated: false };
2316
+ }
2317
+ const projectedChars = Array.from(value);
2318
+ const sourceChars = Math.max(projectedChars.length, originalChars ?? projectedChars.length);
2319
+ if (sourceChars <= maxChars) {
2320
+ return { text: value, truncated: false };
2321
+ }
2322
+
2323
+ // The database supplies only a bounded prefix plus the original character
2324
+ // count. Iterate to a stable marker width so the reported omission includes
2325
+ // the characters replaced by the marker itself.
2326
+ let bodyChars = maxChars;
2327
+ let marker = "";
2328
+ for (let attempt = 0; attempt < 4; attempt += 1) {
2329
+ const omittedChars = Math.max(0, sourceChars - bodyChars);
2330
+ marker = `…[${omittedChars} chars truncated]…`;
2331
+ const nextBodyChars = Math.max(0, maxChars - Array.from(marker).length);
2332
+ if (nextBodyChars === bodyChars) break;
2333
+ bodyChars = nextBodyChars;
2334
+ }
2335
+ return {
2336
+ text: `${projectedChars.slice(0, bodyChars).join("")}${marker}`,
2337
+ truncated: true,
2338
+ };
2339
+ }
2340
+
2341
+ export function capSessionDiscoveryPage(
2342
+ page: Awaited<ReturnType<typeof listSessionDiscoverySummaries>>,
2343
+ includeLastMessage: boolean,
2344
+ ) {
2345
+ const projected = page.sessions.map((session) => {
2346
+ const title = capSessionDiscoveryText(session.title, 200, session.titleOriginalChars);
2347
+ const goal = session.goal
2348
+ ? capSessionDiscoveryText(
2349
+ session.goal.text,
2350
+ SESSION_DISCOVERY_TEXT_CHARS,
2351
+ session.goal.textOriginalChars,
2352
+ )
2353
+ : null;
2354
+ const preview = includeLastMessage
2355
+ ? capSessionDiscoveryText(
2356
+ session.latestMessage?.preview ?? null,
2357
+ SESSION_DISCOVERY_TEXT_CHARS,
2358
+ session.latestMessage?.previewOriginalChars,
2359
+ )
2360
+ : null;
2361
+ const blocker = session.effectiveControl.primaryBlocker;
2362
+ const blockerDisplayName = blocker
2363
+ ? capSessionDiscoveryText(blocker.displayName, 200, blocker.displayNameOriginalChars)
2364
+ : null;
2365
+ return {
2366
+ id: session.id,
2367
+ title: title.text,
2368
+ titleTruncated: title.truncated,
2369
+ parentSessionId: session.parentSessionId,
2370
+ isRoot: session.parentSessionId === null,
2371
+ status: session.status,
2372
+ pause: {
2373
+ state: session.effectiveControl.state,
2374
+ additionalBlockerCount: session.effectiveControl.additionalBlockerCount,
2375
+ source: blocker
2376
+ ? {
2377
+ kind: blocker.kind,
2378
+ ...(blocker.sessionId ? { sessionId: blocker.sessionId } : {}),
2379
+ displayName: blockerDisplayName!.text,
2380
+ displayNameTruncated: blockerDisplayName!.truncated,
2381
+ }
2382
+ : null,
2383
+ },
2384
+ goal: session.goal
2385
+ ? {
2386
+ status: session.goal.status,
2387
+ summary: goal!.text,
2388
+ summaryTruncated: goal!.truncated,
2389
+ }
2390
+ : null,
2391
+ queuedPromptCount: session.queuedPromptCount,
2392
+ children: session.treeStats,
2393
+ ...(includeLastMessage
2394
+ ? {
2395
+ latestMessage: session.latestMessage
2396
+ ? {
2397
+ type: session.latestMessage.type,
2398
+ preview: preview!.text,
2399
+ previewTruncated: preview!.truncated,
2400
+ }
2401
+ : null,
2402
+ }
2403
+ : {}),
2404
+ createdAt: session.createdAt,
2405
+ updatedAt: session.updatedAt,
2406
+ };
2407
+ });
2408
+
2409
+ // The database order is already the useful discovery order. Spend the
2410
+ // separate preview budget in that order so the first page retains previews
2411
+ // for the most relevant rows and later rows remain discoverable by status.
2412
+ let budgetBytes = 0;
2413
+ const budgeted = includeLastMessage
2414
+ ? projected.map((session) => {
2415
+ const latestMessage = session.latestMessage;
2416
+ if (!latestMessage || latestMessage.preview === null) return session;
2417
+ const candidateBytes = Buffer.byteLength(latestMessage.preview, "utf8");
2418
+ if (budgetBytes + candidateBytes <= SESSION_DISCOVERY_PREVIEW_MAX_BYTES) {
2419
+ budgetBytes += candidateBytes;
2420
+ return session;
2421
+ }
2422
+ return {
2423
+ ...session,
2424
+ latestMessage: {
2425
+ ...latestMessage,
2426
+ preview: null,
2427
+ previewOmitted: true,
2428
+ previewOmissionReason: SESSION_DISCOVERY_PREVIEW_OMISSION_REASON,
2429
+ previewDrillDownTool: SESSION_DISCOVERY_PREVIEW_DRILL_DOWN_TOOL,
2430
+ previewDrillDownInput: sessionDiscoveryPreviewDrillDownInput(
2431
+ session.id,
2432
+ latestMessage.type,
2433
+ ),
2434
+ },
2435
+ };
2436
+ })
2437
+ : projected;
2438
+
2439
+ let kept = budgeted;
2440
+ const build = () => {
2441
+ const previewBytes = includeLastMessage
2442
+ ? kept.reduce(
2443
+ (total, session) =>
2444
+ total +
2445
+ (session.latestMessage?.preview === null || !session.latestMessage
2446
+ ? 0
2447
+ : Buffer.byteLength(session.latestMessage.preview, "utf8")),
2448
+ 0,
2449
+ )
2450
+ : 0;
2451
+ const previewOmittedCount = includeLastMessage
2452
+ ? kept.filter((session) => {
2453
+ const latestMessage = session.latestMessage;
2454
+ return (
2455
+ latestMessage != null &&
2456
+ "previewOmitted" in latestMessage &&
2457
+ latestMessage.previewOmitted === true
2458
+ );
2459
+ }).length
2460
+ : 0;
2461
+ const lastKept = kept.at(-1);
2462
+ const droppedForByteCap = kept.length < projected.length;
2463
+ const sourceLast = lastKept
2464
+ ? page.sessions.find((session) => session.id === lastKept.id)
2465
+ : undefined;
2466
+ const nextCursor = droppedForByteCap
2467
+ ? sourceLast
2468
+ ? encodeSessionDiscoveryCursor({
2469
+ orderBy: page.orderBy,
2470
+ sortRevision: sourceLast.sortRevision,
2471
+ sortAt: sourceLast.sortAt,
2472
+ id: sourceLast.id,
2473
+ snapshotAt: page.snapshotAt,
2474
+ snapshotRevision: page.snapshotRevision,
2475
+ updatedAfter: page.updatedAfter,
2476
+ })
2477
+ : null
2478
+ : page.nextCursor
2479
+ ? encodeSessionDiscoveryCursor(page.nextCursor)
2480
+ : null;
2481
+ const result = {
2482
+ sessions: kept,
2483
+ total: page.total,
2484
+ hasMore: page.hasMore || droppedForByteCap,
2485
+ nextCursor,
2486
+ orderBy: page.orderBy,
2487
+ snapshotAt: page.snapshotAt,
2488
+ snapshotRevision: page.snapshotRevision,
2489
+ updatedAfter: page.updatedAfter,
2490
+ updatedThrough: page.updatedThrough,
2491
+ ...(includeLastMessage
2492
+ ? {
2493
+ latestMessagePreviewBudget: {
2494
+ bytes: previewBytes,
2495
+ maxBytes: SESSION_DISCOVERY_PREVIEW_MAX_BYTES,
2496
+ omittedCount: previewOmittedCount,
2497
+ truncated: previewOmittedCount > 0,
2498
+ omissionReason:
2499
+ previewOmittedCount > 0 ? SESSION_DISCOVERY_PREVIEW_OMISSION_REASON : null,
2500
+ drillDownTool: SESSION_DISCOVERY_PREVIEW_DRILL_DOWN_TOOL,
2501
+ drillDownInput: {
2502
+ includeTypes: ["user.message", "agent.message.completed"] as const,
2503
+ ...SESSION_DISCOVERY_PREVIEW_DRILL_DOWN_BASE_INPUT,
2504
+ },
2505
+ },
2506
+ }
2507
+ : {}),
2508
+ responseTruncated: droppedForByteCap,
2509
+ ...(droppedForByteCap
2510
+ ? {
2511
+ truncationReason: `response exceeded ${SESSION_DISCOVERY_PAGE_MAX_BYTES} bytes; continue with nextCursor`,
2512
+ }
2513
+ : {}),
2514
+ bytes: 0,
2515
+ maxBytes: SESSION_DISCOVERY_PAGE_MAX_BYTES,
2516
+ };
2517
+ for (let attempt = 0; attempt < 8; attempt += 1) {
2518
+ const measured = Buffer.byteLength(JSON.stringify(result, null, 2), "utf8");
2519
+ if (result.bytes === measured) break;
2520
+ result.bytes = measured;
2521
+ }
2522
+ return result;
2523
+ };
2524
+
2525
+ let result = build();
2526
+ while (result.bytes > SESSION_DISCOVERY_PAGE_MAX_BYTES && kept.length > 1) {
2527
+ kept = kept.slice(0, -1);
2528
+ result = build();
2529
+ }
2530
+ if (result.bytes > SESSION_DISCOVERY_PAGE_MAX_BYTES) {
2531
+ throw new RangeError(
2532
+ `sessions_list metadata exceeds its ${SESSION_DISCOVERY_PAGE_MAX_BYTES}-byte envelope`,
2533
+ );
2534
+ }
2535
+ return result;
2536
+ }
2537
+
1910
2538
  function parseMcpDate(raw: string, label: string): Date {
1911
2539
  const date = new Date(raw);
1912
2540
  if (Number.isNaN(date.getTime())) {