@opengeni/api-router 0.5.3 → 0.5.5

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 (49) hide show
  1. package/dist/app.d.ts +9 -1
  2. package/dist/app.js +7 -1
  3. package/dist/{chunk-3HIA43CC.js → chunk-HBEJMWD3.js} +5470 -2223
  4. package/dist/chunk-HBEJMWD3.js.map +1 -0
  5. package/dist/index.d.ts +2 -1
  6. package/dist/index.js +279 -55
  7. package/dist/index.js.map +1 -1
  8. package/package.json +20 -20
  9. package/src/app.ts +583 -166
  10. package/src/auth/managed-auth.ts +32 -16
  11. package/src/http/auth.ts +8 -1
  12. package/src/http/common.ts +6 -2
  13. package/src/http/sse.ts +84 -8
  14. package/src/index.ts +178 -75
  15. package/src/integrations/oauth-client.ts +403 -120
  16. package/src/integrations/provider-domain.ts +4 -1
  17. package/src/mcp/documents.ts +173 -94
  18. package/src/mcp/server.ts +1600 -693
  19. package/src/mcp/session-view.ts +8 -2
  20. package/src/mcp/toolspace.ts +175 -84
  21. package/src/observability.ts +7 -1
  22. package/src/routes/api-keys.ts +39 -23
  23. package/src/routes/billing.ts +180 -65
  24. package/src/routes/capabilities.ts +17 -8
  25. package/src/routes/catalog-assets.ts +5 -2
  26. package/src/routes/codex.ts +244 -63
  27. package/src/routes/connections.ts +71 -33
  28. package/src/routes/documents.ts +242 -92
  29. package/src/routes/enrollments.ts +100 -70
  30. package/src/routes/environments.ts +205 -136
  31. package/src/routes/files.ts +164 -39
  32. package/src/routes/github.ts +123 -50
  33. package/src/routes/install.ts +9 -2
  34. package/src/routes/machines.ts +9 -8
  35. package/src/routes/packs.ts +141 -89
  36. package/src/routes/rigs.ts +189 -0
  37. package/src/routes/scheduled-tasks.ts +51 -9
  38. package/src/routes/sessions.ts +870 -328
  39. package/src/routes/social.ts +50 -38
  40. package/src/routes/workspace-capture.ts +238 -0
  41. package/src/routes/workspaces.ts +146 -13
  42. package/src/sandbox/access.ts +11 -3
  43. package/src/sandbox/auth-callout.ts +5 -1
  44. package/src/sandbox/channel-a.ts +104 -27
  45. package/src/sandbox/enrollment.ts +13 -3
  46. package/src/sandbox/machines.ts +68 -59
  47. package/src/sandbox/metrics-ingestion.ts +238 -17
  48. package/src/sandbox/viewer.ts +172 -46
  49. package/dist/chunk-3HIA43CC.js.map +0 -1
package/src/mcp/server.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import {
2
2
  CreateScheduledTaskRequest,
3
3
  SessionMcpCredentialUpdateInput,
4
- WorkspaceEnvironmentVariableName,
4
+ VariableSetVariableName,
5
5
  type AccessGrant,
6
6
  type GitHubRepository,
7
7
  type Permission,
@@ -9,30 +9,42 @@ import {
9
9
  UpdateScheduledTaskRequest,
10
10
  } from "@opengeni/contracts";
11
11
  import {
12
- countWorkspaceEnvironments,
13
- createWorkspaceEnvironment,
12
+ correctWorkspaceMemory,
13
+ countVariableSets,
14
+ beginRigChangeVerificationAttempt,
15
+ createVariableSet,
14
16
  deleteScheduledTask,
15
- encryptEnvironmentValue,
17
+ encryptVariableSetValue,
16
18
  getSession,
17
19
  getSessionGoal,
18
- getWorkspaceEnvironment,
19
- getWorkspaceEnvironmentByName,
20
+ getSessionQueueSnapshot,
21
+ getSessionTurn,
22
+ getVariableSet,
23
+ getVariableSetByName,
20
24
  listGitHubInstallationIdsForWorkspace,
21
25
  listScheduledTaskRuns,
22
26
  listScheduledTasks,
23
27
  listSessionEvents,
24
28
  listSessions,
29
+ listRigs,
25
30
  listSocialConnections,
26
31
  listSocialPosts,
27
- listWorkspaceEnvironments,
32
+ listVariableSets,
33
+ MEMORY_CORRECT_TOOL_DESCRIPTION,
34
+ MEMORY_SAVE_TOOL_DESCRIPTION,
35
+ MEMORY_SEARCH_TOOL_DESCRIPTION,
28
36
  requireFile,
29
37
  requireScheduledTask,
30
38
  requireSession,
39
+ saveWorkspaceMemory,
40
+ searchWorkspaceMemories,
31
41
  setSessionGoalStatus,
32
- setWorkspaceEnvironmentVariable,
42
+ setVariableSetVariable,
33
43
  updateScheduledTask,
34
44
  updateSessionGoal,
35
45
  upsertSessionGoal,
46
+ RigChangeAlreadyVerifyingError,
47
+ RigChangeTransitionError,
36
48
  } from "@opengeni/db";
37
49
  import { appendAndPublishEvents } from "@opengeni/events";
38
50
  import {
@@ -49,11 +61,17 @@ import { hasPermission } from "@opengeni/core";
49
61
  import { recordWorkspaceUsage, requireLimit } from "@opengeni/core";
50
62
  import type { ApiRouteDeps } from "@opengeni/core";
51
63
  import {
52
- assertAllowedEnvironmentVariableName,
64
+ listRigChangesForApi,
65
+ listRigVersionsForApi,
66
+ promoteVerifiedDefinitionEditChangeForApi,
67
+ proposeRigChangeForApi,
68
+ requireRigChangeForApi,
69
+ requireRigForApi,
70
+ assertAllowedVariableSetVariableName,
53
71
  MAX_ENVIRONMENTS_PER_WORKSPACE,
54
72
  MAX_VARIABLES_PER_ENVIRONMENT,
55
- recordEnvironmentAuditEvent,
56
- requireEnvironmentEncryption,
73
+ recordVariableSetAuditEvent,
74
+ requireVariableSetEncryption,
57
75
  } from "@opengeni/core";
58
76
  import {
59
77
  createValidatedScheduledTask,
@@ -65,7 +83,16 @@ import {
65
83
  syncUpdatedScheduledTask,
66
84
  validatedScheduledTaskUpdate,
67
85
  } from "@opengeni/core";
68
- import { acceptSessionUserMessage, createSessionForRequest, updateSessionTitle, workflowIdForSession } from "@opengeni/core";
86
+ import {
87
+ acceptSessionUserMessage,
88
+ controlAgentSessionWorkstream,
89
+ controlHumanSessionWorkstream,
90
+ createSessionForRequest,
91
+ sendAgentSessionMessage,
92
+ steerAgentSession,
93
+ updateSessionTitle,
94
+ type AgentSessionCommandContext,
95
+ } from "@opengeni/core";
69
96
  import {
70
97
  buildFleetContextForSession,
71
98
  listFleet,
@@ -85,36 +112,58 @@ export type McpServerOptions = {
85
112
  // OPENGENI_PUBLIC_BASE_URL nor the manifest base URL is configured.
86
113
  requestOrigin?: string | null;
87
114
  toolspace?: ToolspaceMcpSurface | null;
115
+ workspaceMemoryEnabled?: boolean | undefined;
88
116
  };
89
117
 
90
- export function buildOpenGeniMcpServer(deps: ApiRouteDeps, grant: AccessGrant, options: McpServerOptions = {}): McpServer {
118
+ export function buildOpenGeniMcpServer(
119
+ deps: ApiRouteDeps,
120
+ grant: AccessGrant,
121
+ options: McpServerOptions = {},
122
+ ): McpServer {
91
123
  const server = new McpServer({
92
124
  name: "opengeni",
93
125
  version: "1.0.0",
94
126
  });
95
- const json = (value: unknown) => ({ content: [{ type: "text" as const, text: JSON.stringify(value, null, 2) }] });
127
+ const json = (value: unknown) => ({
128
+ content: [{ type: "text" as const, text: JSON.stringify(value, null, 2) }],
129
+ });
96
130
  const can = (permission: Permission) => hasPermission(grant.permissions, permission);
97
131
  const toolspaceMode = options.toolspace != null;
98
132
 
99
133
  // Session-scoped tools key off the worker-asserted sessionId claim (signed
100
134
  // into the delegated token by the worker, never agent-controlled).
101
- const sessionId = typeof grant.metadata?.["sessionId"] === "string" ? grant.metadata["sessionId"] as string : null;
135
+ const sessionId =
136
+ typeof grant.metadata?.["sessionId"] === "string"
137
+ ? (grant.metadata["sessionId"] as string)
138
+ : null;
102
139
  // set_session_title names the agent's OWN session — pure session metadata,
103
140
  // not a goal operation — so it is available on every session, gated only on
104
141
  // the signed sessionId (NOT goals:manage, and NOT on a goal existing).
105
142
  if (sessionId !== null && (!toolspaceMode || can("sessions:control"))) {
106
- server.registerTool("set_session_title", {
107
- description: "Set this session's display title to a concise 3-7 word summary. Call once early to name the session; calling again replaces it unless a human has manually set the title.",
108
- inputSchema: { title: z4.string().min(1).max(200) },
109
- }, async ({ title }) => {
110
- const result = await updateSessionTitle(deps, grant.workspaceId, sessionId, title, "agent");
111
- return json({ ok: true, updated: result.updated, title: result.title ?? title });
112
- });
143
+ server.registerTool(
144
+ "set_session_title",
145
+ {
146
+ description:
147
+ "Set this session's display title to a concise 3-7 word summary. The title persists across turns: call once on a new untitled session, then only when the topic materially changes. Never call it as routine setup after a continuation, resume, or interruption, or merely to reassert the same title. A human-set title cannot be replaced.",
148
+ inputSchema: { title: z4.string().min(1).max(200) },
149
+ },
150
+ 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 });
153
+ },
154
+ );
113
155
  }
114
156
  // Goal tools require goals:manage (in the default first-party permission set).
115
157
  if (sessionId !== null && can("goals:manage")) {
116
158
  registerGoalTools(server, deps, grant, sessionId, json);
117
159
  }
160
+ // Toolspace grants are the sandbox's narrowed proxy surface. Unlike the
161
+ // normal first-party worker token, a bare toolspace:call token does not see
162
+ // unpermissioned session tools; memory follows that title/goal parity and
163
+ // stays on the normal first-party MCP surface only.
164
+ if (!toolspaceMode && sessionId !== null && options.workspaceMemoryEnabled === true) {
165
+ registerMemoryTools(server, deps, grant, sessionId, json);
166
+ }
118
167
 
119
168
  // Fleet tools (M7 bring-your-own-compute): list / attach / swap / run_on /
120
169
  // provision over the session's Modal box + the workspace's enrolled machines.
@@ -125,18 +174,21 @@ export function buildOpenGeniMcpServer(deps: ApiRouteDeps, grant: AccessGrant, o
125
174
  if (!toolspaceMode && sessionId !== null && deps.settings.sandboxSelfhostedEnabled) {
126
175
  registerFleetTools(server, deps, grant, sessionId, json);
127
176
  }
177
+ if (!toolspaceMode) {
178
+ registerRigTools(server, deps, grant, can, sessionId, json);
179
+ }
128
180
 
129
- // Orchestration, environment, and GitHub-connect tools are permission-gated
181
+ // Orchestration, variableSet, and GitHub-connect tools are permission-gated
130
182
  // at registration: a grant without the permission does not see the tool.
131
183
  // Sandboxed workers reach this server with the first-party delegated
132
184
  // permission set (firstPartyMcpPermissions in @opengeni/runtime), which is
133
- // POWERFUL BY DEFAULT — it carries sessions:*, environments:*, and github:use,
134
- // so agents can spawn/read sessions, manage workspace environment variables,
185
+ // POWERFUL BY DEFAULT — it carries sessions:*, variable sets:*, and github:use,
186
+ // so agents can spawn/read sessions, manage variable set variables,
135
187
  // and mint GitHub install links out of the box. A user DEMOTES a specific
136
188
  // session by setting a narrower session.firstPartyMcpPermissions (capped to
137
189
  // the creator's own grant); operators still cap what any session can be given.
138
- registerWorkspaceOrchestrationTools(server, deps, grant, can, json);
139
- registerEnvironmentTools(server, deps, grant, can, json);
190
+ registerWorkspaceOrchestrationTools(server, deps, grant, can, sessionId, toolspaceMode, json);
191
+ registerVariableSetTools(server, deps, grant, can, json);
140
192
  if (can("github:use")) {
141
193
  registerGitHubConnectTool(server, deps, grant, options, json);
142
194
  // TOKEN-BROKER (B1): the agent-refreshable git token. Session-scoped (keys off the
@@ -148,248 +200,397 @@ export function buildOpenGeniMcpServer(deps: ApiRouteDeps, grant: AccessGrant, o
148
200
  }
149
201
 
150
202
  if (!toolspaceMode || can("files:read")) {
151
- server.registerTool("files_get_download_url", {
152
- description: "Create a short-lived download URL for a ready file asset.",
153
- inputSchema: { fileId: z4.string().uuid() },
154
- }, async ({ fileId }) => {
155
- if (!deps.objectStorage) {
156
- throw new Error("object storage is not configured");
157
- }
158
- const file = await requireFile(deps.db, grant.workspaceId, fileId);
159
- if (file.status !== "ready") {
160
- throw new Error(`file is ${file.status}`);
161
- }
162
- const signed = await deps.objectStorage.createGetUrl({ key: file.objectKey });
163
- return json({
164
- file: {
165
- id: file.id,
166
- filename: file.filename,
167
- safeFilename: file.safeFilename,
168
- contentType: file.contentType,
169
- sizeBytes: file.sizeBytes,
170
- sha256: file.sha256,
171
- status: file.status,
172
- createdAt: file.createdAt,
173
- updatedAt: file.updatedAt,
174
- },
175
- downloadUrl: {
176
- url: signed.url,
177
- expiresAt: signed.expiresAt.toISOString(),
203
+ server.registerTool(
204
+ "files_get_download_url",
205
+ {
206
+ description: "Create a short-lived download URL for a ready file asset.",
207
+ inputSchema: { fileId: z4.string().uuid() },
178
208
  },
179
- });
180
- });
209
+ async ({ fileId }) => {
210
+ if (!deps.objectStorage) {
211
+ throw new Error("object storage is not configured");
212
+ }
213
+ const file = await requireFile(deps.db, grant.workspaceId, fileId);
214
+ if (file.status !== "ready") {
215
+ throw new Error(`file is ${file.status}`);
216
+ }
217
+ const signed = await deps.objectStorage.createGetUrl({ key: file.objectKey });
218
+ return json({
219
+ file: {
220
+ id: file.id,
221
+ filename: file.filename,
222
+ safeFilename: file.safeFilename,
223
+ contentType: file.contentType,
224
+ sizeBytes: file.sizeBytes,
225
+ sha256: file.sha256,
226
+ status: file.status,
227
+ createdAt: file.createdAt,
228
+ updatedAt: file.updatedAt,
229
+ },
230
+ downloadUrl: {
231
+ url: signed.url,
232
+ expiresAt: signed.expiresAt.toISOString(),
233
+ },
234
+ });
235
+ },
236
+ );
181
237
  }
182
238
 
183
239
  if (!toolspaceMode || can("github:use")) {
184
- server.registerTool("github_repositories_list", {
185
- description: "List GitHub App repositories available as scheduled task repository resources. Use the returned resource object in scheduled task agentConfig.resources.",
186
- inputSchema: { limit: z4.number().int().positive().optional() },
187
- }, async ({ limit }) => {
188
- try {
189
- const installationIds = await listGitHubInstallationIdsForWorkspace(deps.db, grant.workspaceId);
190
- const repositories = await listGitHubAppRepositories(deps.settings, { installationIds });
191
- const visible = typeof limit === "number" ? repositories.slice(0, limit) : repositories;
192
- return json({ repositories: visible.map((repository) => repositoryWithScheduledTaskResource(repository)) });
193
- } catch (error) {
194
- if (error instanceof GitHubAppConfigurationError) {
195
- throw new Error(`GitHub App is not configured: ${error.missing.join(", ")}`);
196
- }
197
- throw error;
198
- }
199
- });
240
+ server.registerTool(
241
+ "github_repositories_list",
242
+ {
243
+ description:
244
+ "List GitHub App repositories available as scheduled task repository resources. Use the returned resource object in scheduled task agentConfig.resources.",
245
+ inputSchema: { limit: z4.number().int().positive().optional() },
246
+ },
247
+ async ({ limit }) => {
248
+ try {
249
+ const installationIds = await listGitHubInstallationIdsForWorkspace(
250
+ deps.db,
251
+ grant.workspaceId,
252
+ );
253
+ const repositories = await listGitHubAppRepositories(deps.settings, { installationIds });
254
+ const visible = typeof limit === "number" ? repositories.slice(0, limit) : repositories;
255
+ return json({
256
+ repositories: visible.map((repository) =>
257
+ repositoryWithScheduledTaskResource(repository),
258
+ ),
259
+ });
260
+ } catch (error) {
261
+ if (error instanceof GitHubAppConfigurationError) {
262
+ throw new Error(`GitHub App is not configured: ${error.missing.join(", ")}`, {
263
+ cause: error,
264
+ });
265
+ }
266
+ throw error;
267
+ }
268
+ },
269
+ );
200
270
  }
201
271
 
202
272
  if (!toolspaceMode || can("connections:read")) {
203
- server.registerTool("social_connections_list", {
204
- description: "List connected social media accounts available to social media analysis packs.",
205
- inputSchema: { limit: z4.number().int().positive().optional() },
206
- }, async ({ limit }) => json({ connections: await listSocialConnections(deps.db, grant.workspaceId, boundedMcpLimit(limit)) }));
207
-
208
- server.registerTool("social_posts_recent", {
209
- description: "List recent social media posts imported or synced into OpenGeni.",
210
- inputSchema: {
211
- connectionIds: z4.array(z4.string().uuid()).optional(),
212
- since: z4.string().optional(),
213
- windowHours: z4.number().int().positive().optional(),
214
- limit: z4.number().int().positive().optional(),
215
- },
216
- }, async ({ connectionIds, since, windowHours, limit }) => {
217
- const sinceDate = since ? parseMcpDate(since, "since") : new Date(Date.now() - (windowHours ?? 24) * 60 * 60 * 1000);
218
- return json({
219
- since: sinceDate.toISOString(),
220
- posts: await listSocialPosts(deps.db, {
221
- workspaceId: grant.workspaceId,
222
- ...(connectionIds?.length ? { connectionIds } : {}),
223
- since: sinceDate,
224
- limit: boundedMcpLimit(limit),
225
- }),
226
- });
227
- });
273
+ server.registerTool(
274
+ "social_connections_list",
275
+ {
276
+ description:
277
+ "List connected social media accounts available to social media analysis packs.",
278
+ inputSchema: { limit: z4.number().int().positive().optional() },
279
+ },
280
+ async ({ limit }) =>
281
+ json({
282
+ connections: await listSocialConnections(
283
+ deps.db,
284
+ grant.workspaceId,
285
+ boundedMcpLimit(limit),
286
+ ),
287
+ }),
288
+ );
228
289
 
229
- server.registerTool("social_daily_analysis_context", {
230
- description: "Collect social account and recent post context for a daily marketing analysis run.",
231
- inputSchema: {
232
- connectionIds: z4.array(z4.string().uuid()).optional(),
233
- documentBaseIds: z4.array(z4.string().uuid()).optional(),
234
- since: z4.string().optional(),
235
- windowHours: z4.number().int().positive().optional(),
236
- limit: z4.number().int().positive().optional(),
237
- },
238
- }, async ({ connectionIds, documentBaseIds, since, windowHours, limit }) => {
239
- const allConnections = await listSocialConnections(deps.db, grant.workspaceId, 500);
240
- const selectedIds = connectionIds && connectionIds.length > 0 ? new Set(connectionIds) : null;
241
- const connections = selectedIds
242
- ? allConnections.filter((connection) => selectedIds.has(connection.id))
243
- : allConnections.filter((connection) => connection.status === "connected");
244
- if (selectedIds) {
245
- const foundIds = new Set(connections.map((connection) => connection.id));
246
- const missing = [...selectedIds].filter((id) => !foundIds.has(id));
247
- if (missing.length > 0) {
248
- throw new Error(`Unknown social connection IDs: ${missing.join(", ")}`);
249
- }
250
- }
251
- const sinceDate = since ? parseMcpDate(since, "since") : new Date(Date.now() - (windowHours ?? 24) * 60 * 60 * 1000);
252
- const posts = connections.length > 0
253
- ? await listSocialPosts(deps.db, {
254
- workspaceId: grant.workspaceId,
255
- connectionIds: connections.map((connection) => connection.id),
256
- since: sinceDate,
257
- limit: boundedMcpLimit(limit),
258
- })
259
- : [];
260
- return json({
261
- generatedAt: new Date().toISOString(),
262
- window: {
263
- since: sinceDate.toISOString(),
264
- until: new Date().toISOString(),
265
- },
266
- documentBaseIds: documentBaseIds ?? [],
267
- connections,
268
- posts,
269
- instructions: [
270
- "Use docs MCP search tools for the supplied documentBaseIds when brand, campaign, or audience knowledge is needed.",
271
- "Report data gaps explicitly when posts or metrics are missing.",
272
- "Do not infer unpublished metrics or hidden platform data.",
273
- ],
274
- });
275
- });
290
+ server.registerTool(
291
+ "social_posts_recent",
292
+ {
293
+ description: "List recent social media posts imported or synced into OpenGeni.",
294
+ inputSchema: {
295
+ connectionIds: z4.array(z4.string().uuid()).optional(),
296
+ since: z4.string().optional(),
297
+ windowHours: z4.number().int().positive().optional(),
298
+ limit: z4.number().int().positive().optional(),
299
+ },
300
+ },
301
+ async ({ connectionIds, since, windowHours, limit }) => {
302
+ const sinceDate = since
303
+ ? parseMcpDate(since, "since")
304
+ : new Date(Date.now() - (windowHours ?? 24) * 60 * 60 * 1000);
305
+ return json({
306
+ since: sinceDate.toISOString(),
307
+ posts: await listSocialPosts(deps.db, {
308
+ workspaceId: grant.workspaceId,
309
+ ...(connectionIds?.length ? { connectionIds } : {}),
310
+ since: sinceDate,
311
+ limit: boundedMcpLimit(limit),
312
+ }),
313
+ });
314
+ },
315
+ );
276
316
 
317
+ server.registerTool(
318
+ "social_daily_analysis_context",
319
+ {
320
+ description:
321
+ "Collect social account and recent post context for a daily marketing analysis run.",
322
+ inputSchema: {
323
+ connectionIds: z4.array(z4.string().uuid()).optional(),
324
+ documentBaseIds: z4.array(z4.string().uuid()).optional(),
325
+ since: z4.string().optional(),
326
+ windowHours: z4.number().int().positive().optional(),
327
+ limit: z4.number().int().positive().optional(),
328
+ },
329
+ },
330
+ async ({ connectionIds, documentBaseIds, since, windowHours, limit }) => {
331
+ const allConnections = await listSocialConnections(deps.db, grant.workspaceId, 500);
332
+ const selectedIds =
333
+ connectionIds && connectionIds.length > 0 ? new Set(connectionIds) : null;
334
+ const connections = selectedIds
335
+ ? allConnections.filter((connection) => selectedIds.has(connection.id))
336
+ : allConnections.filter((connection) => connection.status === "connected");
337
+ if (selectedIds) {
338
+ const foundIds = new Set(connections.map((connection) => connection.id));
339
+ const missing = [...selectedIds].filter((id) => !foundIds.has(id));
340
+ if (missing.length > 0) {
341
+ throw new Error(`Unknown social connection IDs: ${missing.join(", ")}`);
342
+ }
343
+ }
344
+ const sinceDate = since
345
+ ? parseMcpDate(since, "since")
346
+ : new Date(Date.now() - (windowHours ?? 24) * 60 * 60 * 1000);
347
+ const posts =
348
+ connections.length > 0
349
+ ? await listSocialPosts(deps.db, {
350
+ workspaceId: grant.workspaceId,
351
+ connectionIds: connections.map((connection) => connection.id),
352
+ since: sinceDate,
353
+ limit: boundedMcpLimit(limit),
354
+ })
355
+ : [];
356
+ return json({
357
+ generatedAt: new Date().toISOString(),
358
+ window: {
359
+ since: sinceDate.toISOString(),
360
+ until: new Date().toISOString(),
361
+ },
362
+ documentBaseIds: documentBaseIds ?? [],
363
+ connections,
364
+ posts,
365
+ instructions: [
366
+ "Use docs MCP search tools for the supplied documentBaseIds when brand, campaign, or audience knowledge is needed.",
367
+ "Report data gaps explicitly when posts or metrics are missing.",
368
+ "Do not infer unpublished metrics or hidden platform data.",
369
+ ],
370
+ });
371
+ },
372
+ );
277
373
  }
278
374
 
279
375
  if (!toolspaceMode || can("scheduled_tasks:manage") || can("scheduled_tasks:run")) {
280
- server.registerTool("scheduled_tasks_list", {
281
- description: "List scheduled tasks.",
282
- inputSchema: { limit: z4.number().int().positive().optional() },
283
- }, async ({ limit }) => json({ tasks: await listScheduledTasks(deps.db, grant.workspaceId, limit ?? 100) }));
284
-
285
- server.registerTool("scheduled_tasks_get", {
286
- description: "Get one scheduled task.",
287
- inputSchema: { id: z4.string().uuid() },
288
- }, async ({ id }) => json(await requireScheduledTask(deps.db, grant.workspaceId, id)));
289
-
290
- server.registerTool("scheduled_tasks_create", {
291
- description: "Create a scheduled task.",
292
- inputSchema: {
293
- name: z4.string(),
294
- schedule: z4.unknown(),
295
- runMode: z4.string().optional(),
296
- overlapPolicy: z4.string().optional(),
297
- agentConfig: z4.unknown(),
298
- status: z4.string().optional(),
299
- environmentId: z4.string().uuid().optional(),
300
- metadata: z4.record(z4.string(), z4.unknown()).optional(),
301
- },
302
- }, async (args) => {
303
- const payload = CreateScheduledTaskRequest.parse(args);
304
- requireEnvironmentsUseForMcpAttachment(grant, payload.environmentId);
305
- await requireLimit(deps, { accountId: grant.accountId, workspaceId: grant.workspaceId, action: "schedule:create", quantity: 1 });
306
- const task = await createValidatedScheduledTask({ settings: deps.settings, db: deps.db, objectStorage: deps.objectStorage, grant, payload, toolsProvided: scheduledTaskToolsProvided(args) });
307
- await syncCreatedScheduledTask({ db: deps.db, workflowClient: deps.workflowClient, task });
308
- return json(task);
309
- });
376
+ server.registerTool(
377
+ "scheduled_tasks_list",
378
+ {
379
+ description: "List scheduled tasks.",
380
+ inputSchema: { limit: z4.number().int().positive().optional() },
381
+ },
382
+ async ({ limit }) =>
383
+ json({ tasks: await listScheduledTasks(deps.db, grant.workspaceId, limit ?? 100) }),
384
+ );
310
385
 
311
- server.registerTool("scheduled_tasks_update", {
312
- description: "Update a scheduled task.",
313
- inputSchema: {
314
- id: z4.string().uuid(),
315
- name: z4.string().optional(),
316
- schedule: z4.unknown().optional(),
317
- runMode: z4.string().optional(),
318
- overlapPolicy: z4.string().optional(),
319
- agentConfig: z4.unknown().optional(),
320
- status: z4.string().optional(),
321
- environmentId: z4.string().uuid().nullable().optional(),
322
- metadata: z4.record(z4.string(), z4.unknown()).optional(),
323
- },
324
- }, async ({ id, ...raw }) => {
325
- const existing = await requireScheduledTask(deps.db, grant.workspaceId, id);
326
- const payload = UpdateScheduledTaskRequest.parse(raw);
327
- requireEnvironmentsUseForMcpAttachment(grant, payload.environmentId);
328
- const update = await validatedScheduledTaskUpdate({ settings: deps.settings, db: deps.db, objectStorage: deps.objectStorage, grant, existing, payload, toolsProvided: scheduledTaskToolsProvided(raw) });
329
- const task = await updateScheduledTask(deps.db, grant.workspaceId, id, update);
330
- await syncUpdatedScheduledTask({ db: deps.db, workflowClient: deps.workflowClient, previous: existing, task });
331
- return json(task);
332
- });
386
+ server.registerTool(
387
+ "scheduled_tasks_get",
388
+ {
389
+ description: "Get one scheduled task.",
390
+ inputSchema: { id: z4.string().uuid() },
391
+ },
392
+ async ({ id }) => json(await requireScheduledTask(deps.db, grant.workspaceId, id)),
393
+ );
333
394
 
334
- server.registerTool("scheduled_tasks_pause", {
335
- description: "Pause a scheduled task.",
336
- inputSchema: { id: z4.string().uuid() },
337
- }, async ({ id }) => {
338
- const existing = await requireScheduledTask(deps.db, grant.workspaceId, id);
339
- const task = await updateScheduledTask(deps.db, grant.workspaceId, id, { status: "paused" });
340
- await syncUpdatedScheduledTask({ db: deps.db, workflowClient: deps.workflowClient, previous: existing, task });
341
- return json(task);
342
- });
395
+ server.registerTool(
396
+ "scheduled_tasks_create",
397
+ {
398
+ description: "Create a scheduled task.",
399
+ inputSchema: {
400
+ name: z4.string(),
401
+ schedule: z4.unknown(),
402
+ runMode: z4.string().optional(),
403
+ overlapPolicy: z4.string().optional(),
404
+ agentConfig: z4.unknown(),
405
+ status: z4.string().optional(),
406
+ variableSetId: z4.string().uuid().optional(),
407
+ // Deprecated alias of variableSetId; declared so MCP validation doesn't
408
+ // strip it before the contract parse maps it (rename back-compat).
409
+ environmentId: z4.string().uuid().optional(),
410
+ // Bind the task to a rig; declared so MCP validation doesn't strip it.
411
+ rigId: z4.string().uuid().nullable().optional(),
412
+ metadata: z4.record(z4.string(), z4.unknown()).optional(),
413
+ },
414
+ },
415
+ async (args) => {
416
+ const payload = CreateScheduledTaskRequest.parse(args);
417
+ requireVariableSetsUseForMcpAttachment(grant, payload.variableSetId);
418
+ await requireLimit(deps, {
419
+ accountId: grant.accountId,
420
+ workspaceId: grant.workspaceId,
421
+ action: "schedule:create",
422
+ quantity: 1,
423
+ });
424
+ const task = await createValidatedScheduledTask({
425
+ settings: deps.settings,
426
+ db: deps.db,
427
+ objectStorage: deps.objectStorage,
428
+ grant,
429
+ payload,
430
+ toolsProvided: scheduledTaskToolsProvided(args),
431
+ });
432
+ await syncCreatedScheduledTask({ db: deps.db, workflowClient: deps.workflowClient, task });
433
+ return json(task);
434
+ },
435
+ );
343
436
 
344
- server.registerTool("scheduled_tasks_resume", {
345
- description: "Resume a scheduled task.",
346
- inputSchema: { id: z4.string().uuid() },
347
- }, async ({ id }) => {
348
- const existing = await requireScheduledTask(deps.db, grant.workspaceId, id);
349
- const task = await updateScheduledTask(deps.db, grant.workspaceId, id, { status: "active" });
350
- await syncUpdatedScheduledTask({ db: deps.db, workflowClient: deps.workflowClient, previous: existing, task });
351
- return json(task);
352
- });
437
+ server.registerTool(
438
+ "scheduled_tasks_update",
439
+ {
440
+ description: "Update a scheduled task.",
441
+ inputSchema: {
442
+ id: z4.string().uuid(),
443
+ name: z4.string().optional(),
444
+ schedule: z4.unknown().optional(),
445
+ runMode: z4.string().optional(),
446
+ overlapPolicy: z4.string().optional(),
447
+ agentConfig: z4.unknown().optional(),
448
+ status: z4.string().optional(),
449
+ variableSetId: z4.string().uuid().nullable().optional(),
450
+ // Deprecated alias of variableSetId (rename back-compat); declared so MCP
451
+ // validation doesn't strip it before the contract parse maps it.
452
+ environmentId: z4.string().uuid().nullable().optional(),
453
+ // Bind the task to a rig; declared so MCP validation doesn't strip it.
454
+ rigId: z4.string().uuid().nullable().optional(),
455
+ metadata: z4.record(z4.string(), z4.unknown()).optional(),
456
+ },
457
+ },
458
+ async ({ id, ...raw }) => {
459
+ const existing = await requireScheduledTask(deps.db, grant.workspaceId, id);
460
+ const payload = UpdateScheduledTaskRequest.parse(raw);
461
+ requireVariableSetsUseForMcpAttachment(grant, payload.variableSetId);
462
+ const update = await validatedScheduledTaskUpdate({
463
+ settings: deps.settings,
464
+ db: deps.db,
465
+ objectStorage: deps.objectStorage,
466
+ grant,
467
+ existing,
468
+ payload,
469
+ toolsProvided: scheduledTaskToolsProvided(raw),
470
+ });
471
+ const task = await updateScheduledTask(deps.db, grant.workspaceId, id, update);
472
+ await syncUpdatedScheduledTask({
473
+ db: deps.db,
474
+ workflowClient: deps.workflowClient,
475
+ previous: existing,
476
+ task,
477
+ });
478
+ return json(task);
479
+ },
480
+ );
353
481
 
354
- server.registerTool("scheduled_tasks_trigger", {
355
- description: "Trigger a scheduled task immediately. Pass a stable triggerId to make a retried trigger idempotent (one charge, one run).",
356
- inputSchema: { id: z4.string().uuid(), triggerId: z4.string().min(1).max(128).optional() },
357
- }, async ({ id, triggerId }) => {
358
- const task = await requireScheduledTask(deps.db, grant.workspaceId, id);
359
- await requireLimit(deps, { accountId: grant.accountId, workspaceId: grant.workspaceId, action: "agent_run:create", quantity: 1, model: task.agentConfig.model ?? deps.settings.openaiModel });
360
- const triggerToken = scheduledTaskTriggerToken(triggerId);
361
- const agentRunUsageIdempotencyKey = manualScheduledTaskTriggerUsageKey(grant.workspaceId, task.id, triggerToken);
362
- const triggerWorkflowId = manualScheduledTaskTriggerWorkflowId(task.id, triggerToken);
363
- await deps.workflowClient.triggerScheduledTask({ task, agentRunUsageIdempotencyKey, triggerWorkflowId });
364
- await recordWorkspaceUsage(deps, {
365
- accountId: grant.accountId,
366
- workspaceId: grant.workspaceId,
367
- subjectId: grant.subjectId,
368
- eventType: "agent_run.created",
369
- quantity: 1,
370
- unit: "run",
371
- sourceResourceType: "scheduled_task",
372
- sourceResourceId: task.id,
373
- idempotencyKey: agentRunUsageIdempotencyKey,
374
- });
375
- return json(task);
376
- });
482
+ server.registerTool(
483
+ "scheduled_tasks_pause",
484
+ {
485
+ description: "Pause a scheduled task.",
486
+ inputSchema: { id: z4.string().uuid() },
487
+ },
488
+ async ({ id }) => {
489
+ const existing = await requireScheduledTask(deps.db, grant.workspaceId, id);
490
+ const task = await updateScheduledTask(deps.db, grant.workspaceId, id, {
491
+ status: "paused",
492
+ });
493
+ await syncUpdatedScheduledTask({
494
+ db: deps.db,
495
+ workflowClient: deps.workflowClient,
496
+ previous: existing,
497
+ task,
498
+ });
499
+ return json(task);
500
+ },
501
+ );
377
502
 
378
- server.registerTool("scheduled_tasks_delete", {
379
- description: "Delete a scheduled task.",
380
- inputSchema: { id: z4.string().uuid() },
381
- }, async ({ id }) => {
382
- const task = await requireScheduledTask(deps.db, grant.workspaceId, id);
383
- await deps.workflowClient.deleteScheduledTaskSchedule({ temporalScheduleId: task.temporalScheduleId });
384
- await deleteScheduledTask(deps.db, grant.workspaceId, id);
385
- return json({ ok: true });
386
- });
503
+ server.registerTool(
504
+ "scheduled_tasks_resume",
505
+ {
506
+ description: "Resume a scheduled task.",
507
+ inputSchema: { id: z4.string().uuid() },
508
+ },
509
+ async ({ id }) => {
510
+ const existing = await requireScheduledTask(deps.db, grant.workspaceId, id);
511
+ const task = await updateScheduledTask(deps.db, grant.workspaceId, id, {
512
+ status: "active",
513
+ });
514
+ await syncUpdatedScheduledTask({
515
+ db: deps.db,
516
+ workflowClient: deps.workflowClient,
517
+ previous: existing,
518
+ task,
519
+ });
520
+ return json(task);
521
+ },
522
+ );
523
+
524
+ server.registerTool(
525
+ "scheduled_tasks_trigger",
526
+ {
527
+ description:
528
+ "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() },
530
+ },
531
+ async ({ id, triggerId }) => {
532
+ const task = await requireScheduledTask(deps.db, grant.workspaceId, id);
533
+ await requireLimit(deps, {
534
+ accountId: grant.accountId,
535
+ workspaceId: grant.workspaceId,
536
+ action: "agent_run:create",
537
+ quantity: 1,
538
+ model: task.agentConfig.model ?? deps.settings.openaiModel,
539
+ });
540
+ const triggerToken = scheduledTaskTriggerToken(triggerId);
541
+ const agentRunUsageIdempotencyKey = manualScheduledTaskTriggerUsageKey(
542
+ grant.workspaceId,
543
+ task.id,
544
+ triggerToken,
545
+ );
546
+ const triggerWorkflowId = manualScheduledTaskTriggerWorkflowId(task.id, triggerToken);
547
+ await deps.workflowClient.triggerScheduledTask({
548
+ task,
549
+ agentRunUsageIdempotencyKey,
550
+ triggerWorkflowId,
551
+ });
552
+ await recordWorkspaceUsage(deps, {
553
+ accountId: grant.accountId,
554
+ workspaceId: grant.workspaceId,
555
+ subjectId: grant.subjectId,
556
+ eventType: "agent_run.created",
557
+ quantity: 1,
558
+ unit: "run",
559
+ sourceResourceType: "scheduled_task",
560
+ sourceResourceId: task.id,
561
+ idempotencyKey: agentRunUsageIdempotencyKey,
562
+ });
563
+ return json(task);
564
+ },
565
+ );
387
566
 
388
- server.registerTool("scheduled_task_runs_list", {
389
- description: "List runs for a scheduled task.",
390
- inputSchema: { taskId: z4.string().uuid(), limit: z4.number().int().positive().optional() },
391
- }, async ({ taskId, limit }) => json({ runs: await listScheduledTaskRuns(deps.db, grant.workspaceId, taskId, limit ?? 100) }));
567
+ server.registerTool(
568
+ "scheduled_tasks_delete",
569
+ {
570
+ description: "Delete a scheduled task.",
571
+ inputSchema: { id: z4.string().uuid() },
572
+ },
573
+ async ({ id }) => {
574
+ const task = await requireScheduledTask(deps.db, grant.workspaceId, id);
575
+ await deps.workflowClient.deleteScheduledTaskSchedule({
576
+ temporalScheduleId: task.temporalScheduleId,
577
+ });
578
+ await deleteScheduledTask(deps.db, grant.workspaceId, id);
579
+ return json({ ok: true });
580
+ },
581
+ );
392
582
 
583
+ server.registerTool(
584
+ "scheduled_task_runs_list",
585
+ {
586
+ description: "List runs for a scheduled task.",
587
+ inputSchema: { taskId: z4.string().uuid(), limit: z4.number().int().positive().optional() },
588
+ },
589
+ async ({ taskId, limit }) =>
590
+ json({
591
+ runs: await listScheduledTaskRuns(deps.db, grant.workspaceId, taskId, limit ?? 100),
592
+ }),
593
+ );
393
594
  }
394
595
 
395
596
  registerToolspaceProxyTools(server, options.toolspace ?? null);
@@ -402,18 +603,67 @@ function registerToolspaceProxyTools(server: McpServer, surface: ToolspaceMcpSur
402
603
  return;
403
604
  }
404
605
  for (const tool of surface.tools) {
405
- server.registerTool(tool.name, {
406
- ...(tool.description ? { description: tool.description } : {}),
407
- inputSchema: z4.object({}).passthrough(),
408
- _meta: {
409
- opengeni: {
410
- origin: "toolspace",
411
- subjectId: surface.subjectId,
412
- sessionId: surface.sessionId,
413
- ...(tool.inputSchema ? { inputSchema: tool.inputSchema } : {}),
606
+ server.registerTool(
607
+ tool.name,
608
+ {
609
+ ...(tool.description ? { description: tool.description } : {}),
610
+ inputSchema: z4.object({}).passthrough(),
611
+ _meta: {
612
+ opengeni: {
613
+ origin: "toolspace",
614
+ subjectId: surface.subjectId,
615
+ sessionId: surface.sessionId,
616
+ ...(tool.inputSchema ? { inputSchema: tool.inputSchema } : {}),
617
+ },
414
618
  },
415
619
  },
416
- }, async (args) => await tool.call(args));
620
+ async (args) => await tool.call(args),
621
+ );
622
+ }
623
+ }
624
+
625
+ /** Only a prompt explicitly supplied through the human/API channel may redirect a user-paused goal. */
626
+ export function isHumanDirectedTurn(turn: { source: string }): boolean {
627
+ return turn.source === "user" || turn.source === "api";
628
+ }
629
+
630
+ /**
631
+ * Sacred user pause: a goal a human paused (pausedReason 'user_pause') must
632
+ * never be resurrected by a MACHINE turn. Child-completion notification turns
633
+ * carry a "resume it now" nudge; without this guard the agent processing one of
634
+ * them would call goal_set and re-arm the exact autonomous loop the user just
635
+ * paused (the runaway that made Pause feel broken). A genuine user message
636
+ * still redirects freely (it is not a child-notification turn), and the
637
+ * human-driven API resume path (PATCH /goal) is unaffected.
638
+ *
639
+ * Classification is by CALLER IDENTITY — `callerTurnId` is the turn that minted
640
+ * this MCP token (signed into it by the worker at turn setup). We deliberately
641
+ * do NOT read the session's live `active_turn_id`: that pointer can flip to a
642
+ * different turn between reads (a machine turn ends and a human turn becomes
643
+ * active mid-check), which would misclassify the caller and, worst case, refuse
644
+ * a legitimate human `goal_set` — inverting the guard against the very human
645
+ * power it must preserve. A caller turn's source/metadata are immutable, so this
646
+ * read is race-free. No caller identity ⇒ fail OPEN (only a positively
647
+ * identified machine child-notification caller is refused).
648
+ */
649
+ export async function assertGoalReactivationAllowed(
650
+ deps: ApiRouteDeps,
651
+ workspaceId: string,
652
+ sessionId: string,
653
+ callerTurnId: string | null,
654
+ ): Promise<void> {
655
+ if (!callerTurnId) {
656
+ return;
657
+ }
658
+ const goal = await getSessionGoal(deps.db, workspaceId, sessionId);
659
+ if (!goal || goal.status !== "paused" || goal.pausedReason !== "user_pause") {
660
+ return;
661
+ }
662
+ const turn = await getSessionTurn(deps.db, workspaceId, callerTurnId);
663
+ if (turn && !isHumanDirectedTurn(turn)) {
664
+ throw new Error(
665
+ "This session was paused by the user. An internal turn cannot resume or replace the goal — only a new human/API prompt can. Report your findings and do not call goal_set.",
666
+ );
417
667
  }
418
668
  }
419
669
 
@@ -424,127 +674,287 @@ function registerGoalTools(
424
674
  sessionId: string,
425
675
  json: (value: unknown) => { content: Array<{ type: "text"; text: string }> },
426
676
  ): void {
427
- server.registerTool("goal_set", {
428
- description: "Set or replace this session's goal. While a goal is active the session keeps working: idle moments synthesize continuation turns until goal_complete or goal_pause is called. Replacing a goal reactivates it and resets the continuation budget.",
429
- inputSchema: {
430
- text: z4.string().min(1),
431
- successCriteria: z4.string().min(1).optional(),
432
- maxAutoContinuations: z4.number().int().positive().optional(),
677
+ server.registerTool(
678
+ "goal_set",
679
+ {
680
+ description:
681
+ "Set or replace this session's goal. While a goal is active the session keeps working: idle moments synthesize continuation turns until goal_complete or goal_pause is called. Replacing a goal reactivates it and resets the continuation budget.",
682
+ inputSchema: {
683
+ text: z4.string().min(1),
684
+ successCriteria: z4.string().min(1).optional(),
685
+ maxAutoContinuations: z4.number().int().positive().optional(),
686
+ },
433
687
  },
434
- }, async ({ text, successCriteria, maxAutoContinuations }) => {
435
- await requireSession(deps.db, grant.workspaceId, sessionId);
436
- const { goal, replaced } = await upsertSessionGoal(deps.db, {
437
- accountId: grant.accountId,
438
- workspaceId: grant.workspaceId,
439
- sessionId,
440
- text,
441
- successCriteria: successCriteria ?? null,
442
- maxAutoContinuations: maxAutoContinuations ?? null,
443
- createdBy: "agent",
444
- });
445
- await appendAndPublishEvents(deps.db, deps.bus, grant.workspaceId, sessionId, [{
446
- type: "goal.set",
447
- payload: {
448
- goalId: goal.id,
449
- text: goal.text,
450
- ...(goal.successCriteria ? { successCriteria: goal.successCriteria } : {}),
451
- version: goal.version,
452
- actor: "agent",
453
- replaced,
454
- },
455
- }]);
456
- return json(goal);
457
- });
688
+ async ({ text, successCriteria, maxAutoContinuations }) => {
689
+ await requireSession(deps.db, grant.workspaceId, sessionId);
690
+ const callerTurnId =
691
+ typeof grant.metadata?.["turnId"] === "string"
692
+ ? (grant.metadata["turnId"] as string)
693
+ : null;
694
+ await assertGoalReactivationAllowed(deps, grant.workspaceId, sessionId, callerTurnId);
695
+ const { goal, replaced } = await upsertSessionGoal(deps.db, {
696
+ accountId: grant.accountId,
697
+ workspaceId: grant.workspaceId,
698
+ sessionId,
699
+ text,
700
+ successCriteria: successCriteria ?? null,
701
+ maxAutoContinuations: maxAutoContinuations ?? null,
702
+ createdBy: "agent",
703
+ });
704
+ await appendAndPublishEvents(deps.db, deps.bus, grant.workspaceId, sessionId, [
705
+ {
706
+ type: "goal.set",
707
+ payload: {
708
+ goalId: goal.id,
709
+ text: goal.text,
710
+ ...(goal.successCriteria ? { successCriteria: goal.successCriteria } : {}),
711
+ version: goal.version,
712
+ actor: "agent",
713
+ replaced,
714
+ },
715
+ },
716
+ ]);
717
+ return json(goal);
718
+ },
719
+ );
458
720
 
459
- server.registerTool("goal_update", {
460
- description: "Revise the session goal's text or success criteria, or record a progress note. Counts as progress for the no-progress detector; the goal stays active.",
461
- inputSchema: {
462
- text: z4.string().min(1).optional(),
463
- successCriteria: z4.string().min(1).optional(),
464
- progressNote: z4.string().min(1).optional(),
721
+ server.registerTool(
722
+ "goal_update",
723
+ {
724
+ description:
725
+ "Revise the session goal's text or success criteria, or record a progress note. Counts as progress for the no-progress detector; the goal stays active.",
726
+ inputSchema: {
727
+ text: z4.string().min(1).optional(),
728
+ successCriteria: z4.string().min(1).optional(),
729
+ progressNote: z4.string().min(1).optional(),
730
+ },
465
731
  },
466
- }, async ({ text, successCriteria, progressNote }) => {
467
- await requireSession(deps.db, grant.workspaceId, sessionId);
468
- const existing = await getSessionGoal(deps.db, grant.workspaceId, sessionId);
469
- if (!existing) {
470
- throw new Error("this session has no goal; use goal_set first");
471
- }
472
- if (existing.status === "completed") {
473
- throw new Error("session goal is completed; use goal_set to start a new goal");
474
- }
475
- const goal = await updateSessionGoal(deps.db, grant.workspaceId, sessionId, {
476
- ...(text !== undefined ? { text } : {}),
477
- ...(successCriteria !== undefined ? { successCriteria } : {}),
478
- });
479
- await appendAndPublishEvents(deps.db, deps.bus, grant.workspaceId, sessionId, [{
480
- type: "goal.updated",
481
- payload: {
482
- goalId: goal.id,
483
- text: goal.text,
484
- ...(goal.successCriteria ? { successCriteria: goal.successCriteria } : {}),
485
- ...(progressNote ? { progressNote } : {}),
486
- version: goal.version,
487
- actor: "agent",
488
- },
489
- }]);
490
- return json(goal);
491
- });
732
+ async ({ text, successCriteria, progressNote }) => {
733
+ await requireSession(deps.db, grant.workspaceId, sessionId);
734
+ const existing = await getSessionGoal(deps.db, grant.workspaceId, sessionId);
735
+ if (!existing) {
736
+ throw new Error("this session has no goal; use goal_set first");
737
+ }
738
+ if (existing.status === "completed") {
739
+ throw new Error("session goal is completed; use goal_set to start a new goal");
740
+ }
741
+ const goal = await updateSessionGoal(deps.db, grant.workspaceId, sessionId, {
742
+ ...(text !== undefined ? { text } : {}),
743
+ ...(successCriteria !== undefined ? { successCriteria } : {}),
744
+ });
745
+ await appendAndPublishEvents(deps.db, deps.bus, grant.workspaceId, sessionId, [
746
+ {
747
+ type: "goal.updated",
748
+ payload: {
749
+ goalId: goal.id,
750
+ text: goal.text,
751
+ ...(goal.successCriteria ? { successCriteria: goal.successCriteria } : {}),
752
+ ...(progressNote ? { progressNote } : {}),
753
+ version: goal.version,
754
+ actor: "agent",
755
+ },
756
+ },
757
+ ]);
758
+ return json(goal);
759
+ },
760
+ );
492
761
 
493
- server.registerTool("goal_complete", {
494
- description: "Mark the session goal as completed. Requires concrete evidence (what was done and how it satisfies the success criteria). This is the explicit stop signal: no further continuation turns are synthesized.",
495
- inputSchema: { evidence: z4.string().min(1) },
496
- }, async ({ evidence }) => {
497
- await requireSession(deps.db, grant.workspaceId, sessionId);
498
- const existing = await getSessionGoal(deps.db, grant.workspaceId, sessionId);
499
- if (!existing) {
500
- throw new Error("this session has no goal; use goal_set first");
501
- }
502
- const { goal, changed } = await setSessionGoalStatus(deps.db, grant.workspaceId, sessionId, {
503
- status: "completed",
504
- evidence,
505
- });
506
- if (changed) {
507
- await appendAndPublishEvents(deps.db, deps.bus, grant.workspaceId, sessionId, [{
508
- type: "goal.completed",
509
- payload: { goalId: goal.id, evidence, version: goal.version },
510
- }]);
511
- }
512
- return json(goal);
513
- });
762
+ server.registerTool(
763
+ "goal_complete",
764
+ {
765
+ description:
766
+ "Mark the session goal as completed. Requires concrete evidence (what was done and how it satisfies the success criteria). Completion prevents further continuation turns.",
767
+ inputSchema: { evidence: z4.string().min(1) },
768
+ },
769
+ async ({ evidence }) => {
770
+ await requireSession(deps.db, grant.workspaceId, sessionId);
771
+ const existing = await getSessionGoal(deps.db, grant.workspaceId, sessionId);
772
+ if (!existing) {
773
+ throw new Error("this session has no goal; use goal_set first");
774
+ }
775
+ const { goal, changed } = await setSessionGoalStatus(deps.db, grant.workspaceId, sessionId, {
776
+ status: "completed",
777
+ evidence,
778
+ });
779
+ if (changed) {
780
+ await appendAndPublishEvents(deps.db, deps.bus, grant.workspaceId, sessionId, [
781
+ {
782
+ type: "goal.completed",
783
+ payload: { goalId: goal.id, evidence, version: goal.version },
784
+ },
785
+ ]);
786
+ }
787
+ return json(goal);
788
+ },
789
+ );
514
790
 
515
- server.registerTool("goal_pause", {
516
- description: "Pause the session goal with a rationale (blocked, not productive, needs human input). This is the explicit stop signal: no further continuation turns are synthesized until the goal is resumed or replaced.",
517
- inputSchema: { rationale: z4.string().min(1) },
518
- }, async ({ rationale }) => {
519
- await requireSession(deps.db, grant.workspaceId, sessionId);
520
- const existing = await getSessionGoal(deps.db, grant.workspaceId, sessionId);
521
- if (!existing) {
522
- throw new Error("this session has no goal; use goal_set first");
523
- }
524
- const { goal, changed } = await setSessionGoalStatus(deps.db, grant.workspaceId, sessionId, {
525
- status: "paused",
526
- rationale,
527
- pausedReason: "agent",
528
- });
529
- if (changed) {
530
- await appendAndPublishEvents(deps.db, deps.bus, grant.workspaceId, sessionId, [{
531
- type: "goal.paused",
532
- payload: {
533
- goalId: goal.id,
534
- actor: "agent",
535
- reason: "agent",
536
- rationale,
537
- autoContinuations: goal.autoContinuations,
538
- noProgressStreak: goal.noProgressStreak,
539
- },
540
- }]);
541
- }
542
- return json(goal);
543
- });
791
+ server.registerTool(
792
+ "goal_pause",
793
+ {
794
+ description:
795
+ "Pause the session goal with a rationale (blocked, not productive, needs human input). No further continuation turns are synthesized until the goal is resumed or replaced.",
796
+ inputSchema: { rationale: z4.string().min(1) },
797
+ },
798
+ async ({ rationale }) => {
799
+ await requireSession(deps.db, grant.workspaceId, sessionId);
800
+ const existing = await getSessionGoal(deps.db, grant.workspaceId, sessionId);
801
+ if (!existing) {
802
+ throw new Error("this session has no goal; use goal_set first");
803
+ }
804
+ const { goal, changed } = await setSessionGoalStatus(deps.db, grant.workspaceId, sessionId, {
805
+ status: "paused",
806
+ rationale,
807
+ pausedReason: "agent",
808
+ });
809
+ if (changed) {
810
+ await appendAndPublishEvents(deps.db, deps.bus, grant.workspaceId, sessionId, [
811
+ {
812
+ type: "goal.paused",
813
+ payload: {
814
+ goalId: goal.id,
815
+ actor: "agent",
816
+ reason: "agent",
817
+ rationale,
818
+ autoContinuations: goal.autoContinuations,
819
+ noProgressStreak: goal.noProgressStreak,
820
+ },
821
+ },
822
+ ]);
823
+ }
824
+ return json(goal);
825
+ },
826
+ );
544
827
  }
545
828
 
546
829
  type JsonResult = (value: unknown) => { content: Array<{ type: "text"; text: string }> };
547
830
 
831
+ const MemoryKindSchema = z4.enum(["preference", "semantic", "procedural", "decision", "episodic"]);
832
+
833
+ function memoryPreview(text: string): string {
834
+ const normalized = text.replace(/\s+/g, " ").trim();
835
+ return normalized.length <= 120 ? normalized : `${normalized.slice(0, 119)}…`;
836
+ }
837
+
838
+ function registerMemoryTools(
839
+ server: McpServer,
840
+ deps: ApiRouteDeps,
841
+ grant: AccessGrant,
842
+ sessionId: string,
843
+ json: JsonResult,
844
+ ): void {
845
+ server.registerTool(
846
+ "memory_search",
847
+ {
848
+ description: MEMORY_SEARCH_TOOL_DESCRIPTION,
849
+ inputSchema: {
850
+ query: z4.string().min(1),
851
+ kind: MemoryKindSchema.optional(),
852
+ limit: z4.number().int().positive().max(20).optional(),
853
+ },
854
+ },
855
+ async ({ query, kind, limit }) =>
856
+ json({
857
+ results: await searchWorkspaceMemories(
858
+ deps.db,
859
+ grant.workspaceId,
860
+ {
861
+ query,
862
+ ...(kind ? { kind } : {}),
863
+ ...(limit ? { limit } : {}),
864
+ },
865
+ deps.getDocumentServices().embedder,
866
+ ),
867
+ }),
868
+ );
869
+
870
+ server.registerTool(
871
+ "memory_save",
872
+ {
873
+ description: MEMORY_SAVE_TOOL_DESCRIPTION,
874
+ inputSchema: {
875
+ text: z4.string().min(1),
876
+ kind: MemoryKindSchema,
877
+ confidence: z4.number().min(0).max(1).optional(),
878
+ replaces_id: z4.string().min(1).optional(),
879
+ },
880
+ },
881
+ async ({ text, kind, confidence, replaces_id }) => {
882
+ const result = await saveWorkspaceMemory(
883
+ deps.db,
884
+ {
885
+ accountId: grant.accountId,
886
+ workspaceId: grant.workspaceId,
887
+ sessionId,
888
+ text,
889
+ kind,
890
+ ...(confidence !== undefined ? { confidence } : {}),
891
+ ...(replaces_id ? { replacesId: replaces_id } : {}),
892
+ origin: "agent",
893
+ },
894
+ deps.getDocumentServices().embedder,
895
+ );
896
+ await appendAndPublishEvents(deps.db, deps.bus, grant.workspaceId, sessionId, [
897
+ {
898
+ type: "memory.saved",
899
+ payload: {
900
+ memoryId: result.memory.id,
901
+ kind: result.memory.kind,
902
+ preview: memoryPreview(result.memory.text),
903
+ deduped: result.deduped,
904
+ ...(result.superseded ? { supersededMemoryId: result.superseded.id } : {}),
905
+ },
906
+ },
907
+ ]);
908
+ return json(result);
909
+ },
910
+ );
911
+
912
+ server.registerTool(
913
+ "memory_correct",
914
+ {
915
+ description: MEMORY_CORRECT_TOOL_DESCRIPTION,
916
+ inputSchema: {
917
+ id: z4.string().min(1),
918
+ reason: z4.string().min(1).optional(),
919
+ replacement_text: z4.string().min(1).optional(),
920
+ },
921
+ },
922
+ async ({ id, reason, replacement_text }) => {
923
+ const result = await correctWorkspaceMemory(
924
+ deps.db,
925
+ {
926
+ accountId: grant.accountId,
927
+ workspaceId: grant.workspaceId,
928
+ sessionId,
929
+ id,
930
+ ...(reason ? { reason } : {}),
931
+ ...(replacement_text ? { replacementText: replacement_text } : {}),
932
+ },
933
+ deps.getDocumentServices().embedder,
934
+ );
935
+ await appendAndPublishEvents(deps.db, deps.bus, grant.workspaceId, sessionId, [
936
+ {
937
+ type: "memory.corrected",
938
+ payload: {
939
+ memoryId: result.memory.id,
940
+ kind: result.memory.kind,
941
+ preview: memoryPreview(result.memory.text),
942
+ action: result.action,
943
+ ...(reason ? { reason: memoryPreview(reason) } : {}),
944
+ ...(result.replacement
945
+ ? {
946
+ replacementMemoryId: result.replacement.id,
947
+ replacementPreview: memoryPreview(result.replacement.text),
948
+ }
949
+ : {}),
950
+ },
951
+ },
952
+ ]);
953
+ return json(result);
954
+ },
955
+ );
956
+ }
957
+
548
958
  // Fleet tools (M7 bring-your-own-compute). Session-scoped (they steer THIS
549
959
  // session's active-sandbox pointer + reach the workspace's enrolled machines),
550
960
  // registered only with the worker-signed sessionId claim + the selfhosted flag.
@@ -572,300 +982,771 @@ function registerFleetTools(
572
982
  sessionId,
573
983
  });
574
984
 
575
- server.registerTool("sandboxes_list", {
576
- description:
577
- "List the sandboxes this session can run on: its own session sandbox (the Modal box) PLUS the workspace's enrolled selfhosted machines, each with liveness (online/reconnecting/offline) and an `active` marker for the currently-routed one. Use before sandbox_attach/sandbox_swap to pick a target. The `id` of any entry is the `target` for attach/swap/run_on.",
578
- inputSchema: {},
579
- }, async () => json(await listFleet(services, await fleetContext())));
580
-
581
- server.registerTool("sandbox_attach", {
582
- description:
583
- "Attach this session to a sandbox (make it the active sandbox the agent's next tool calls run on). Heterogeneous: a Modal box or an enrolled selfhosted machine. Validates the target is owned by this workspace and online, then repoints under an epoch fence. Identical mechanic to sandbox_swap; use `target` = a sandboxes_list `id`, or \"session\"/\"default\" for this session's own box.",
584
- inputSchema: { target: z4.string().min(1) },
585
- }, async ({ target }) => json(await swapActiveSandbox(services, await fleetContext(), target)));
586
-
587
- server.registerTool("sandbox_swap", {
588
- description:
589
- "Swap the active sandbox for this session mid-conversation (the next tool call runs on the new box). Heterogeneous Modal<->selfhosted<->selfhosted, single active at a time, flippable as many times as you like. Validates ownership + liveness, then bumps the active epoch (fencing any in-flight op, which retries against the new box). `target` = a sandboxes_list `id`, or \"session\"/\"default\" to swap back to this session's own box.",
590
- inputSchema: { target: z4.string().min(1) },
591
- }, async ({ target }) => json(await swapActiveSandbox(services, await fleetContext(), target)));
592
-
593
- server.registerTool("run_on", {
594
- description:
595
- "Run a ONE-OFF op on a SPECIFIC enrolled selfhosted machine WITHOUT changing this session's active sandbox (a side-channel to another machine). Ops: exec (run a command), read (read a file), write (write a file). `target` = a selfhosted sandboxes_list `id`. To make a machine the active sandbox instead, use sandbox_swap.",
596
- inputSchema: {
597
- target: z4.string().min(1),
598
- op: z4.discriminatedUnion("kind", [
599
- z4.object({ kind: z4.literal("exec"), cmd: z4.string().min(1), workdir: z4.string().optional() }),
600
- z4.object({ kind: z4.literal("read"), path: z4.string().min(1) }),
601
- z4.object({ kind: z4.literal("write"), path: z4.string().min(1), content: z4.string() }),
602
- ]),
985
+ server.registerTool(
986
+ "sandboxes_list",
987
+ {
988
+ description:
989
+ "List the sandboxes this session can run on: its own session sandbox (the Modal box) PLUS the workspace's enrolled selfhosted machines, each with liveness (online/reconnecting/offline) and an `active` marker for the currently-routed one. Use before sandbox_attach/sandbox_swap to pick a target. The `id` of any entry is the `target` for attach/swap/run_on.",
990
+ inputSchema: {},
991
+ },
992
+ async () => json(await listFleet(services, await fleetContext())),
993
+ );
994
+
995
+ server.registerTool(
996
+ "sandbox_attach",
997
+ {
998
+ description:
999
+ 'Attach this session to a sandbox (make it the active sandbox the agent\'s next tool calls run on). Heterogeneous: a Modal box or an enrolled selfhosted machine. Validates the target is owned by this workspace and online, then repoints under an epoch fence. Identical mechanic to sandbox_swap; use `target` = a sandboxes_list `id`, or "session"/"default" for this session\'s own box.',
1000
+ inputSchema: { target: z4.string().min(1) },
1001
+ },
1002
+ async ({ target }) => json(await swapActiveSandbox(services, await fleetContext(), target)),
1003
+ );
1004
+
1005
+ server.registerTool(
1006
+ "sandbox_swap",
1007
+ {
1008
+ description:
1009
+ 'Swap the active sandbox for this session mid-conversation (the next tool call runs on the new box). Heterogeneous Modal<->selfhosted<->selfhosted, single active at a time, flippable as many times as you like. Validates ownership + liveness, then bumps the active epoch (fencing any in-flight op, which retries against the new box). `target` = a sandboxes_list `id`, or "session"/"default" to swap back to this session\'s own box.',
1010
+ inputSchema: { target: z4.string().min(1) },
1011
+ },
1012
+ async ({ target }) => json(await swapActiveSandbox(services, await fleetContext(), target)),
1013
+ );
1014
+
1015
+ server.registerTool(
1016
+ "run_on",
1017
+ {
1018
+ description:
1019
+ "Run a ONE-OFF op on a SPECIFIC enrolled selfhosted machine WITHOUT changing this session's active sandbox (a side-channel to another machine). Ops: exec (run a command), read (read a file), write (write a file). `target` = a selfhosted sandboxes_list `id`. To make a machine the active sandbox instead, use sandbox_swap.",
1020
+ inputSchema: {
1021
+ target: z4.string().min(1),
1022
+ op: z4.discriminatedUnion("kind", [
1023
+ z4.object({
1024
+ kind: z4.literal("exec"),
1025
+ cmd: z4.string().min(1),
1026
+ workdir: z4.string().optional(),
1027
+ }),
1028
+ 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() }),
1030
+ ]),
1031
+ },
603
1032
  },
604
- }, async ({ target, op }) => json(await runOnSandbox(services, await fleetContext(), target, op as RunOnOp)));
605
-
606
- server.registerTool("sandbox_provision", {
607
- description:
608
- "Provision a new sandbox for the fleet. kind=selfhosted returns device-flow enrollment instructions to share with a HUMAN (install the agent + enroll their machine with loud whole-machine consent — the agent cannot self-consent). kind=modal creates a named Modal sandbox record (its box materializes on first swap).",
609
- inputSchema: {
610
- kind: z4.enum(["selfhosted", "modal"]),
611
- name: z4.string().min(1).max(120).optional(),
1033
+ async ({ target, op }) =>
1034
+ json(await runOnSandbox(services, await fleetContext(), target, op as RunOnOp)),
1035
+ );
1036
+
1037
+ server.registerTool(
1038
+ "sandbox_provision",
1039
+ {
1040
+ description:
1041
+ "Provision a new sandbox for the fleet. kind=selfhosted returns device-flow enrollment instructions to share with a HUMAN (install the agent + enroll their machine with loud whole-machine consent — the agent cannot self-consent). kind=modal creates a named Modal sandbox record, but it is NOT yet attachable as a swap target: routing a session onto a second Modal box is not supported yet, so sandbox_swap to its id is rejected. Use the session's own box (the default) or attach a Connected Machine instead.",
1042
+ inputSchema: {
1043
+ kind: z4.enum(["selfhosted", "modal"]),
1044
+ name: z4.string().min(1).max(120).optional(),
1045
+ },
612
1046
  },
613
- }, async ({ kind, name }) => json(await provisionSandbox(services, await fleetContext(), { kind, ...(name ? { name } : {}) })));
1047
+ async ({ kind, name }) =>
1048
+ json(
1049
+ await provisionSandbox(services, await fleetContext(), { kind, ...(name ? { name } : {}) }),
1050
+ ),
1051
+ );
1052
+ }
1053
+
1054
+ async function beginMcpRigVerificationAttempt(
1055
+ deps: ApiRouteDeps,
1056
+ workspaceId: string,
1057
+ changeId: string,
1058
+ ) {
1059
+ try {
1060
+ return await beginRigChangeVerificationAttempt(deps.db, workspaceId, changeId, {
1061
+ startedAt: new Date().toISOString(),
1062
+ });
1063
+ } catch (error) {
1064
+ if (
1065
+ error instanceof RigChangeAlreadyVerifyingError ||
1066
+ error instanceof RigChangeTransitionError
1067
+ ) {
1068
+ throw new Error(error.message, { cause: error });
1069
+ }
1070
+ throw error;
1071
+ }
1072
+ }
1073
+
1074
+ function verificationAttempt(change: {
1075
+ verification?: Record<string, unknown> | null;
1076
+ }): number | string {
1077
+ return typeof change.verification?.attempt === "number"
1078
+ ? change.verification.attempt
1079
+ : crypto.randomUUID();
1080
+ }
1081
+
1082
+ function registerRigTools(
1083
+ server: McpServer,
1084
+ deps: ApiRouteDeps,
1085
+ grant: AccessGrant,
1086
+ can: (permission: Permission) => boolean,
1087
+ sessionId: string | null,
1088
+ json: JsonResult,
1089
+ ): void {
1090
+ if (can("rigs:use")) {
1091
+ server.registerTool(
1092
+ "rig_list",
1093
+ {
1094
+ description: "List workspace rigs and their active versions.",
1095
+ inputSchema: {},
1096
+ },
1097
+ async () => json({ rigs: await listRigs(deps.db, grant.workspaceId) }),
1098
+ );
1099
+
1100
+ server.registerTool(
1101
+ "rig_get",
1102
+ {
1103
+ description: "Get a rig, its versions, and recent changes.",
1104
+ inputSchema: {
1105
+ rigId: z4.string().uuid(),
1106
+ changeLimit: z4.number().int().positive().optional(),
1107
+ },
1108
+ },
1109
+ async ({ rigId, changeLimit }) => {
1110
+ 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 },
1116
+ grant.workspaceId,
1117
+ rig.id,
1118
+ boundedMcpLimit(changeLimit),
1119
+ ),
1120
+ });
1121
+ },
1122
+ );
1123
+
1124
+ server.registerTool(
1125
+ "rig_propose_change",
1126
+ {
1127
+ description:
1128
+ "Propose an additive rig setup command for clean verification. Use the exact command that already worked in this sandbox.",
1129
+ inputSchema: {
1130
+ rigId: z4.string().uuid(),
1131
+ command: z4.string().min(1).max(8192),
1132
+ note: z4.string().max(2000).optional(),
1133
+ },
1134
+ },
1135
+ async ({ rigId, command, note }) => {
1136
+ const rig = await requireRigForApi(deps.db, grant.workspaceId, rigId);
1137
+ const change = await proposeRigChangeForApi(
1138
+ { db: deps.db },
1139
+ grant,
1140
+ rig,
1141
+ {
1142
+ kind: "setup_append",
1143
+ payload: { command, ...(note ? { note } : {}) },
1144
+ },
1145
+ sessionId ? { proposedBy: `session:${sessionId}` } : {},
1146
+ );
1147
+ const verifying = await beginMcpRigVerificationAttempt(deps, grant.workspaceId, change.id);
1148
+ await deps.workflowClient.startRigVerification({
1149
+ workspaceId: grant.workspaceId,
1150
+ changeId: change.id,
1151
+ workflowId: `rig-verification-change-${change.id}-attempt-${verificationAttempt(verifying)}`,
1152
+ });
1153
+ return json({ change: verifying, verificationStarted: true });
1154
+ },
1155
+ );
1156
+
1157
+ server.registerTool(
1158
+ "rig_verify",
1159
+ {
1160
+ description:
1161
+ "Trigger rig verification. Pass changeId for a proposed change, or omit it to re-verify the active version's checks.",
1162
+ inputSchema: {
1163
+ rigId: z4.string().uuid(),
1164
+ changeId: z4.string().uuid().optional(),
1165
+ },
1166
+ },
1167
+ async ({ rigId, changeId }) => {
1168
+ const rig = await requireRigForApi(deps.db, grant.workspaceId, rigId);
1169
+ if (changeId) {
1170
+ const change = await requireRigChangeForApi(deps.db, grant.workspaceId, rig.id, changeId);
1171
+ const verifying = await beginMcpRigVerificationAttempt(
1172
+ deps,
1173
+ grant.workspaceId,
1174
+ change.id,
1175
+ );
1176
+ await deps.workflowClient.startRigVerification({
1177
+ workspaceId: grant.workspaceId,
1178
+ changeId: change.id,
1179
+ workflowId: `rig-verification-change-${change.id}-attempt-${verificationAttempt(verifying)}`,
1180
+ });
1181
+ return json({ ok: true, changeId: change.id });
1182
+ }
1183
+ if (!rig.activeVersion) {
1184
+ throw new Error("rig has no active version");
1185
+ }
1186
+ await deps.workflowClient.startRigVerification({
1187
+ workspaceId: grant.workspaceId,
1188
+ versionId: rig.activeVersion.id,
1189
+ workflowId: `rig-verification-version-${rig.activeVersion.id}-${crypto.randomUUID()}`,
1190
+ });
1191
+ return json({ ok: true, versionId: rig.activeVersion.id });
1192
+ },
1193
+ );
1194
+ }
1195
+
1196
+ if (can("rigs:manage")) {
1197
+ server.registerTool(
1198
+ "rig_promote",
1199
+ {
1200
+ description:
1201
+ "Promote a verified definition_edit rig change to a new active immutable version. Requires rigs:manage.",
1202
+ inputSchema: {
1203
+ rigId: z4.string().uuid(),
1204
+ changeId: z4.string().uuid(),
1205
+ },
1206
+ },
1207
+ async ({ rigId, changeId }) => {
1208
+ const rig = await requireRigForApi(deps.db, grant.workspaceId, rigId);
1209
+ const change = await requireRigChangeForApi(deps.db, grant.workspaceId, rig.id, changeId);
1210
+ return json(
1211
+ await promoteVerifiedDefinitionEditChangeForApi({ db: deps.db }, grant, rig, change),
1212
+ );
1213
+ },
1214
+ );
1215
+ }
1216
+ }
1217
+
1218
+ // Workspace orchestration for manager-style agents. Session-authenticated
1219
+ // workers communicate through the typed internal-update plane; only a
1220
+ // sessionless operator can append a visible prompt through this surface.
1221
+ function exactAgentCommandContext(
1222
+ grant: AccessGrant,
1223
+ callerSessionId: string,
1224
+ ): AgentSessionCommandContext {
1225
+ const turnId = grant.metadata?.["turnId"];
1226
+ const attemptId = grant.metadata?.["attemptId"];
1227
+ const executionGeneration = grant.metadata?.["executionGeneration"];
1228
+ if (
1229
+ typeof turnId !== "string" ||
1230
+ typeof attemptId !== "string" ||
1231
+ typeof executionGeneration !== "number" ||
1232
+ !Number.isSafeInteger(executionGeneration) ||
1233
+ executionGeneration < 1
1234
+ ) {
1235
+ throw new Error("caller_attempt_claims_missing");
1236
+ }
1237
+ return {
1238
+ accountId: grant.accountId,
1239
+ workspaceId: grant.workspaceId,
1240
+ callerSessionId,
1241
+ callerTurnId: turnId,
1242
+ callerAttemptId: attemptId,
1243
+ callerExecutionGeneration: executionGeneration,
1244
+ };
614
1245
  }
615
1246
 
616
- // Workspace orchestration for manager-style agents: sessions are listed,
617
- // inspected, spawned, and steered with the same domain functions the REST
618
- // routes use, so limits, validation, and usage metering cannot drift.
619
1247
  function registerWorkspaceOrchestrationTools(
620
1248
  server: McpServer,
621
1249
  deps: ApiRouteDeps,
622
1250
  grant: AccessGrant,
623
1251
  can: (permission: Permission) => boolean,
1252
+ callerSessionId: string | null,
1253
+ toolspaceMode: boolean,
624
1254
  json: JsonResult,
625
1255
  ): void {
626
1256
  if (can("sessions:read")) {
627
- server.registerTool("sessions_list", {
628
- description: "List sessions in this workspace, newest first.",
629
- inputSchema: { limit: z4.number().int().positive().optional() },
630
- }, async ({ limit }) => json({ sessions: await listSessions(deps.db, grant.workspaceId, boundedMcpLimit(limit)) }));
631
-
632
- server.registerTool("session_get", {
633
- description: "Get one session: status, goal-bearing metadata, resources, tools, and environment attachment (names/ids only, never variable values). Unbounded agent-set fields (metadata, initial message) are clamped so monitoring another session cannot flood this context.",
634
- inputSchema: { sessionId: z4.string().uuid() },
635
- }, async ({ sessionId }) => {
636
- const session = await getSession(deps.db, grant.workspaceId, sessionId);
637
- if (!session) {
638
- throw new Error("session not found");
639
- }
640
- return json(capSessionDetail(session));
641
- });
1257
+ server.registerTool(
1258
+ "sessions_list",
1259
+ {
1260
+ description: "List sessions in this workspace, newest first.",
1261
+ inputSchema: { limit: z4.number().int().positive().optional() },
1262
+ },
1263
+ async ({ limit }) =>
1264
+ json({ sessions: await listSessions(deps.db, grant.workspaceId, boundedMcpLimit(limit)) }),
1265
+ );
642
1266
 
643
- server.registerTool("session_events", {
644
- description: "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.",
645
- inputSchema: {
646
- sessionId: z4.string().uuid(),
647
- after: z4.number().int().nonnegative().optional(),
648
- limit: z4.number().int().positive().optional(),
1267
+ server.registerTool(
1268
+ "session_get",
1269
+ {
1270
+ description:
1271
+ "Get another session you are managing: status, goal-bearing metadata, resources, tools, and variableSet attachment (names/ids only, never variable values). Do not call this with your own current session id to reconstruct context; your model-facing conversation history and persistent setting state are already supplied directly. Unbounded agent-set fields are clamped so monitoring another session cannot flood this context.",
1272
+ inputSchema: { sessionId: z4.string().uuid() },
649
1273
  },
650
- }, async ({ sessionId, after, limit }) => {
651
- await requireSession(deps.db, grant.workspaceId, sessionId);
652
- const events = await listSessionEvents(deps.db, grant.workspaceId, sessionId, after ?? 0, boundedMcpLimit(limit));
653
- const capped = capEventPage(events);
654
- return json({
655
- events: capped.events,
656
- nextAfter: capped.nextAfter ?? after ?? 0,
657
- ...(capped.truncated ? { truncated: true } : {}),
658
- });
659
- });
1274
+ async ({ sessionId }) => {
1275
+ const session = await getSession(deps.db, grant.workspaceId, sessionId);
1276
+ if (!session) {
1277
+ throw new Error("session not found");
1278
+ }
1279
+ const queue = await getSessionQueueSnapshot(deps.db, grant.workspaceId, sessionId);
1280
+ return json({
1281
+ ...capSessionDetail(session),
1282
+ effectiveControl: queue?.effectiveControl ?? null,
1283
+ });
1284
+ },
1285
+ );
1286
+
1287
+ server.registerTool(
1288
+ "session_events",
1289
+ {
1290
+ 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.",
1292
+ inputSchema: {
1293
+ sessionId: z4.string().uuid(),
1294
+ after: z4.number().int().nonnegative().optional(),
1295
+ limit: z4.number().int().positive().optional(),
1296
+ },
1297
+ },
1298
+ async ({ sessionId, after, limit }) => {
1299
+ 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 } : {}),
1312
+ });
1313
+ },
1314
+ );
660
1315
  }
661
1316
 
662
1317
  if (can("sessions:create")) {
663
- server.registerTool("session_create", {
664
- description: "Spawn a new agent session (a worker) with an initial message and optional goal, resources (e.g. repositories from github_repositories_list), tools, and workspace environment attachment. Environment attachment happens at creation only — it cannot be added to a running session — and requires the environments:use permission. When targetSandboxId names a machine, workingDir sets the working directory (cwd) the spawned session runs under on that machine.",
665
- inputSchema: {
666
- initialMessage: z4.string().min(1),
667
- // Per-session agent persona/system instructions for the spawned worker
668
- // (a per-agent-type prompt). Delivered system-level, composed AFTER the
669
- // workspace persona; never shown in the worker's timeline. Trimmed,
670
- // non-empty, max 32768 chars (re-validated by the contracts schema).
671
- instructions: z4.string().min(1).max(32768).optional(),
672
- goal: z4.unknown().optional(),
673
- resources: z4.array(z4.unknown()).optional(),
674
- tools: z4.array(z4.unknown()).optional(),
675
- // Per-session third-party MCP servers. Credential header values are
676
- // accepted only at create and never appear in responses/events.
677
- mcpServers: z4.array(z4.unknown()).optional(),
678
- environmentId: z4.string().uuid().optional(),
679
- model: z4.string().min(1).optional(),
680
- reasoningEffort: z4.string().optional(),
681
- sandboxBackend: z4.string().optional(),
682
- // Create-time machine targeting: an enrolled sandbox id (from
683
- // sandboxes_list) to run the spawned session on. Seeds the active-sandbox
684
- // pointer at creation so the FIRST turn lands on the chosen machine
685
- // (race-free). Ownership + liveness are validated in the domain via the
686
- // same path as sandbox_swap; an unowned/offline/unknown target 422s.
687
- targetSandboxId: z4.string().uuid().optional(),
688
- // The working directory (cwd) for a machine target: the path/cwd base the
689
- // spawned session's agent exec, terminal, and file dock run under. A
690
- // workspace_root-relative subdir or an absolute machine path. Only valid
691
- // WITH targetSandboxId (workingDir alone 422s); omitted workspace_root.
692
- workingDir: z4.string().optional(),
693
- metadata: z4.record(z4.string(), z4.unknown()).optional(),
694
- // Workspace-scoped CREATE idempotency key: a retried session_create with
695
- // the same key returns the already-spawned worker instead of a duplicate.
696
- idempotencyKey: z4.string().min(1).max(200).optional(),
697
- // First-party MCP token permissions for the spawned session; every
698
- // permission must be held by this grant (validated in the domain).
699
- firstPartyMcpPermissions: z4.array(z4.string()).optional(),
700
- // Shared-sandbox placement (addendum 05 §D). OMIT (default) to SHARE the
701
- // creator's box — one filesystem/repo/desktop, N independent conversations;
702
- // this is the SAFE DEFAULT. Pass "new" for a fresh isolated box (a different
703
- // repo set or a genuinely separate filesystem), or {groupId} (a sibling
704
- // session's `sandboxGroupId` from a prior session_create response) to join
705
- // that specific sibling's box.
706
- // Shared state must be compatible: a shared box requires the SAME image
707
- // (rejected at the lease layer, B3) and because the box's environment is
708
- // fixed at creation under the current mechanics — the SAME workspace
709
- // Environment. The domain layer is env-aware: an inherited default with a
710
- // different environmentId silently gets its OWN box (the spawn still works),
711
- // while an explicit shared/{groupId} with a mismatched environment 422s at
712
- // create. When the Environment is eventually evicted from the box manifest
713
- // (per-exec, like the git token), the env check dissolves on its own.
714
- // The description below is what the AGENT sees (this comment is invisible to
715
- // it); keep the two in sync.
716
- sandbox: z4.union([
717
- z4.literal("shared"),
718
- z4.literal("new"),
719
- z4.object({ groupId: z4.string().uuid() }),
720
- ]).describe(
721
- "Sandbox placement. OMIT (default) to SHARE the creator's box — one filesystem/repo/desktop, N independent conversations; this is the safe default. If the new session attaches a DIFFERENT environment than the creator's box, the platform automatically gives it its own box (the box environment is fixed at creation), so omitting stays safe. Pass 'new' for a fresh isolated box (different repo set or a genuinely separate filesystem). Pass {groupId} to join a specific sibling's box — requires the same environment (a mismatch is rejected at create) and the same image (a conflicting image is rejected when the box warms).",
722
- ).optional(),
723
- // The parent (manager) session is auto-inferred from the caller's
724
- // worker-signed sessionId claim, so a spawned worker's completion wakes
725
- // its manager automatically. There is deliberately no caller-supplied
726
- // parent parameter: it would let a sessions:create grant target an
727
- // arbitrary session's wake channel without sessions:control on it.
728
- },
729
- }, async (args) => json(await createSessionForRequest(deps, grant, grant.workspaceId, args)));
1318
+ server.registerTool(
1319
+ "session_create",
1320
+ {
1321
+ description:
1322
+ "Spawn a new agent session (a worker) with an initial message and optional goal, resources (e.g. repositories from github_repositories_list), tools, and variable set attachment. VariableSet attachment happens at creation only — it cannot be added to a running session — and requires the variable-sets:use permission. When targetSandboxId names a machine, workingDir sets the working directory (cwd) the spawned session runs under on that machine.",
1323
+ inputSchema: {
1324
+ initialMessage: z4.string().min(1),
1325
+ // Per-session agent persona/system instructions for the spawned worker
1326
+ // (a per-agent-type prompt). Delivered system-level, composed AFTER the
1327
+ // workspace persona; never shown in the worker's timeline. Trimmed,
1328
+ // non-empty, max 32768 chars (re-validated by the contracts schema).
1329
+ instructions: z4.string().min(1).max(32768).optional(),
1330
+ goal: z4.unknown().optional(),
1331
+ resources: z4.array(z4.unknown()).optional(),
1332
+ tools: z4.array(z4.unknown()).optional(),
1333
+ // Per-session third-party MCP servers. Credential header values are
1334
+ // accepted only at create and never appear in responses/events.
1335
+ mcpServers: z4.array(z4.unknown()).optional(),
1336
+ variableSetId: z4.string().uuid().optional(),
1337
+ // Deprecated alias of variableSetId (rename back-compat); declared so MCP
1338
+ // validation doesn't strip it before createSessionForRequest maps it.
1339
+ environmentId: z4.string().uuid().optional(),
1340
+ // Bind the spawned session to a rig (freezes its active version);
1341
+ // declared so MCP validation doesn't strip it before the domain reads it.
1342
+ rigId: z4.string().uuid().optional(),
1343
+ model: z4.string().min(1).optional(),
1344
+ reasoningEffort: z4.string().optional(),
1345
+ sandboxBackend: z4.string().optional(),
1346
+ // Create-time machine targeting: an enrolled sandbox id (from
1347
+ // sandboxes_list) to run the spawned session on. Seeds the active-sandbox
1348
+ // pointer at creation so the FIRST turn lands on the chosen machine
1349
+ // (race-free). Ownership + liveness are validated in the domain via the
1350
+ // same path as sandbox_swap; an unowned/offline/unknown target 422s.
1351
+ targetSandboxId: z4.string().uuid().optional(),
1352
+ // The working directory (cwd) for a machine target: the path/cwd base the
1353
+ // spawned session's agent exec, terminal, and file dock run under. A
1354
+ // workspace_root-relative subdir or an absolute machine path. Only valid
1355
+ // WITH targetSandboxId (workingDir alone 422s); omitted workspace_root.
1356
+ workingDir: z4.string().optional(),
1357
+ metadata: z4.record(z4.string(), z4.unknown()).optional(),
1358
+ // Workspace-scoped CREATE idempotency key: a retried session_create with
1359
+ // the same key returns the already-spawned worker instead of a duplicate.
1360
+ idempotencyKey: z4.string().min(1).max(200).optional(),
1361
+ // First-party MCP token permissions for the spawned session; every
1362
+ // permission must be held by this grant (validated in the domain).
1363
+ firstPartyMcpPermissions: z4.array(z4.string()).optional(),
1364
+ // Shared-sandbox placement (addendum 05 §D). OMIT (default) to SHARE the
1365
+ // creator's box one filesystem/repo/desktop, N independent conversations;
1366
+ // this is the SAFE DEFAULT. Pass "new" for a fresh isolated box (a different
1367
+ // repo set or a genuinely separate filesystem), or {groupId} (a sibling
1368
+ // session's `sandboxGroupId` from a prior session_create response) to join
1369
+ // that specific sibling's box.
1370
+ // Shared state must be compatible: a shared box requires the SAME image
1371
+ // (rejected at the lease layer, B3) and — because the box's variable set is
1372
+ // fixed at creation under the current mechanics — the SAME workspace
1373
+ // VariableSet. The domain layer is env-aware: an inherited default with a
1374
+ // different variableSetId silently gets its OWN box (the spawn still works),
1375
+ // while an explicit shared/{groupId} with a mismatched variableSet 422s at
1376
+ // create. When the VariableSet is eventually evicted from the box manifest
1377
+ // (per-exec, like the git token), the env check dissolves on its own.
1378
+ // The description below is what the AGENT sees (this comment is invisible to
1379
+ // it); keep the two in sync.
1380
+ sandbox: z4
1381
+ .union([
1382
+ z4.literal("shared"),
1383
+ z4.literal("new"),
1384
+ z4.object({ groupId: z4.string().uuid() }),
1385
+ ])
1386
+ .describe(
1387
+ "Sandbox placement. OMIT (default) to SHARE the creator's box — one filesystem/repo/desktop, N independent conversations; this is the safe default. If the new session attaches a DIFFERENT variableSet than the creator's box, the platform automatically gives it its own box (the box variable set is fixed at creation), so omitting stays safe. Pass 'new' for a fresh isolated box (different repo set or a genuinely separate filesystem). Pass {groupId} to join a specific sibling's box — requires the same variableSet (a mismatch is rejected at create) and the same image (a conflicting image is rejected when the box warms).",
1388
+ )
1389
+ .optional(),
1390
+ // The parent (manager) session is auto-inferred from the caller's
1391
+ // worker-signed sessionId claim, so a spawned worker's completion wakes
1392
+ // its manager automatically. There is deliberately no caller-supplied
1393
+ // parent parameter: it would let a sessions:create grant target an
1394
+ // arbitrary session's wake channel without sessions:control on it.
1395
+ },
1396
+ },
1397
+ async (args) => json(await createSessionForRequest(deps, grant, grant.workspaceId, args)),
1398
+ );
730
1399
  }
731
1400
 
732
- if (can("sessions:control")) {
733
- server.registerTool("session_send_message", {
734
- description: "Post a user message into an existing session; the session queues a turn and resumes if idle.",
735
- inputSchema: {
736
- sessionId: z4.string().uuid(),
737
- text: z4.string().min(1),
738
- // Header-value rotation only. URL/name/tool settings are immutable
739
- // after create; core enforces mcp_servers:attach on this field.
740
- mcpCredentialUpdates: z4.array(z4.unknown()).optional(),
1401
+ if (can("sessions:control") && !toolspaceMode) {
1402
+ server.registerTool(
1403
+ "session_send_message",
1404
+ {
1405
+ description:
1406
+ "Send information to another session. From an OpenGeni worker this becomes a coalescible internal update, never a visible prompt-queue row; pending updates are delivered together on the target's next inference. A sessionless operator call appends one visible prompt.",
1407
+ inputSchema: {
1408
+ sessionId: z4.string().uuid(),
1409
+ text: z4.string().min(1),
1410
+ idempotencyKey: z4.string().uuid(),
1411
+ // Header-value rotation only. URL/name/tool settings are immutable
1412
+ // after create; core enforces mcp_servers:attach on this field.
1413
+ mcpCredentialUpdates: z4.array(z4.unknown()).optional(),
1414
+ },
741
1415
  },
742
- }, async ({ sessionId, text, mcpCredentialUpdates }) => {
743
- const { accepted, turn } = await acceptSessionUserMessage(deps, grant, grant.workspaceId, sessionId, {
744
- text,
745
- toolsProvided: false,
746
- mcpCredentialUpdates: (mcpCredentialUpdates ?? []).map((update) => SessionMcpCredentialUpdateInput.parse(update)),
747
- });
748
- return json({ event: accepted, turnId: turn.id });
749
- });
1416
+ async ({ sessionId: targetSessionId, text, idempotencyKey, mcpCredentialUpdates }) => {
1417
+ if (callerSessionId !== null) {
1418
+ if ((mcpCredentialUpdates?.length ?? 0) > 0) {
1419
+ throw new Error("internal session updates cannot change MCP credentials");
1420
+ }
1421
+ const result = await sendAgentSessionMessage(
1422
+ deps,
1423
+ exactAgentCommandContext(grant, callerSessionId),
1424
+ { targetSessionId, text, idempotencyKey },
1425
+ );
1426
+ return json({
1427
+ delivered: true,
1428
+ updateId: result.updateId,
1429
+ delivery: "coalesced_internal_update",
1430
+ effectiveState: result.effectiveState,
1431
+ wakeRequested: result.wakeRevision !== null,
1432
+ resumeRequired: result.effectiveState === "paused",
1433
+ replay: result.replay,
1434
+ });
1435
+ }
1436
+ const { accepted, turn } = await acceptSessionUserMessage(
1437
+ deps,
1438
+ grant,
1439
+ grant.workspaceId,
1440
+ targetSessionId,
1441
+ {
1442
+ text,
1443
+ toolsProvided: false,
1444
+ delivery: "send",
1445
+ origin: "operator",
1446
+ clientEventId: idempotencyKey,
1447
+ mcpCredentialUpdates: (mcpCredentialUpdates ?? []).map((update) =>
1448
+ SessionMcpCredentialUpdateInput.parse(update),
1449
+ ),
1450
+ },
1451
+ );
1452
+ return json({ event: accepted, turnId: turn.id });
1453
+ },
1454
+ );
750
1455
 
751
- server.registerTool("session_interrupt", {
752
- description:
753
- "Interrupt a session in this workspace. mode='stop' (default) cancels the current turn AND pauses the session's active goal so it halts. mode='steer' cancels the current turn WITHOUT pausing the goal, so the session picks up its next queued turn (or, if nothing is queued, continues toward its active goal) — pair it with a preceding session_send_message to redirect a running session. Works whether the target is mid-turn or idle.",
754
- inputSchema: {
755
- sessionId: z4.string().uuid(),
756
- mode: z4.enum(["stop", "steer"]).optional(),
1456
+ server.registerTool(
1457
+ "session_pause",
1458
+ {
1459
+ description: "Pause this session. Waiting prompts stay saved and inert until Resume.",
1460
+ inputSchema: {
1461
+ sessionId: z4.string().uuid(),
1462
+ idempotencyKey: z4.string().uuid(),
1463
+ reason: z4.string().min(1).max(500).optional(),
1464
+ },
757
1465
  },
758
- }, async ({ sessionId, mode }) => {
759
- await requireSession(deps.db, grant.workspaceId, sessionId);
760
- const appended = await appendAndPublishEvents(deps.db, deps.bus, grant.workspaceId, sessionId, [{
761
- type: "user.interrupt",
762
- payload: mode === "steer" ? { reason: "steer" } : {},
763
- }]);
764
- const accepted = appended[0];
765
- if (!accepted) {
766
- throw new Error("failed to append interrupt event");
767
- }
768
- await deps.workflowClient.signalInterrupt({
769
- accountId: grant.accountId,
770
- workspaceId: grant.workspaceId,
771
- sessionId,
772
- eventId: accepted.id,
773
- workflowId: workflowIdForSession(sessionId),
774
- });
775
- return json({ event: accepted });
776
- });
1466
+ async ({ sessionId, idempotencyKey, reason }) => {
1467
+ if (callerSessionId !== null) {
1468
+ const controlled = await controlAgentSessionWorkstream(
1469
+ deps,
1470
+ exactAgentCommandContext(grant, callerSessionId),
1471
+ {
1472
+ targetSessionId: sessionId,
1473
+ action: "pause",
1474
+ idempotencyKey,
1475
+ reason: reason ?? "agent_mcp_pause",
1476
+ },
1477
+ );
1478
+ return json({
1479
+ receiptId: controlled.receipt.id,
1480
+ effectiveControl: controlled.control,
1481
+ interruptionCount: controlled.interruptionCount,
1482
+ replay: controlled.replay,
1483
+ });
1484
+ }
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
+ ),
1500
+ );
1501
+ },
1502
+ );
777
1503
 
778
- server.registerTool("set_other_session_title", {
779
- description: "Set another session's display title to a concise 3-7 word summary. The target session must belong to this workspace. Replaces an existing title unless a human has manually set it.",
780
- inputSchema: {
781
- session_id: z4.string().uuid(),
782
- title: z4.string().min(1).max(200),
1504
+ server.registerTool(
1505
+ "session_resume",
1506
+ {
1507
+ description:
1508
+ "Resume the selected session workstream through older parent/workspace pauses. This creates no message.",
1509
+ inputSchema: {
1510
+ sessionId: z4.string().uuid(),
1511
+ idempotencyKey: z4.string().uuid(),
1512
+ reason: z4.string().min(1).max(500).optional(),
1513
+ },
783
1514
  },
784
- }, async ({ session_id, title }) => {
785
- await requireSession(deps.db, grant.workspaceId, session_id);
786
- const result = await updateSessionTitle(deps, grant.workspaceId, session_id, title, "agent");
787
- return json({ ok: true, updated: result.updated, title: result.title ?? title });
788
- });
1515
+ async ({ sessionId, idempotencyKey, reason }) => {
1516
+ if (callerSessionId !== null) {
1517
+ const controlled = await controlAgentSessionWorkstream(
1518
+ deps,
1519
+ exactAgentCommandContext(grant, callerSessionId),
1520
+ {
1521
+ targetSessionId: sessionId,
1522
+ action: "resume",
1523
+ idempotencyKey,
1524
+ reason: reason ?? "agent_mcp_resume",
1525
+ },
1526
+ );
1527
+ return json({
1528
+ receiptId: controlled.receipt.id,
1529
+ effectiveControl: controlled.control,
1530
+ interruptionCount: controlled.interruptionCount,
1531
+ replay: controlled.replay,
1532
+ });
1533
+ }
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
+ ),
1549
+ );
1550
+ },
1551
+ );
1552
+
1553
+ if (callerSessionId !== null) {
1554
+ server.registerTool(
1555
+ "session_steer",
1556
+ {
1557
+ description:
1558
+ "Atomically replace another session's current direction and resume it. The instruction is an internal update, never a human queue row.",
1559
+ inputSchema: {
1560
+ sessionId: z4.string().uuid(),
1561
+ instruction: z4.string().min(1),
1562
+ idempotencyKey: z4.string().uuid(),
1563
+ },
1564
+ },
1565
+ async ({ sessionId, instruction, idempotencyKey }) => {
1566
+ const result = await steerAgentSession(
1567
+ deps,
1568
+ exactAgentCommandContext(grant, callerSessionId),
1569
+ { targetSessionId: sessionId, instruction, idempotencyKey },
1570
+ );
1571
+ return json({
1572
+ updateId: result.updateId,
1573
+ interruptionCount: result.interruptionCount,
1574
+ stoppingPreviousAttempt: result.interruptionCount > 0,
1575
+ effectiveState: result.effectiveState,
1576
+ replay: result.replay,
1577
+ });
1578
+ },
1579
+ );
1580
+ }
1581
+
1582
+ server.registerTool(
1583
+ "set_other_session_title",
1584
+ {
1585
+ description:
1586
+ "Set another session's display title to a concise 3-7 word summary. The target session must belong to this workspace. Replaces an existing title unless a human has manually set it.",
1587
+ inputSchema: {
1588
+ session_id: z4.string().uuid(),
1589
+ title: z4.string().min(1).max(200),
1590
+ },
1591
+ },
1592
+ async ({ session_id, title }) => {
1593
+ 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 });
1602
+ },
1603
+ );
789
1604
  }
790
1605
  }
791
1606
 
792
- // Environment management for manager-style agents. v1 deliberately accepts
1607
+ // VariableSet management for manager-style agents. v1 deliberately accepts
793
1608
  // variable VALUES in plain tool arguments: the calling model is trusted with
794
- // the secrets it is persisting (see docs/environments.md). Reads stay
1609
+ // the secrets it is persisting (see docs/variable-sets.md). Reads stay
795
1610
  // write-only — responses carry names and metadata, never values.
796
- function registerEnvironmentTools(
1611
+ function registerVariableSetTools(
797
1612
  server: McpServer,
798
1613
  deps: ApiRouteDeps,
799
1614
  grant: AccessGrant,
800
1615
  can: (permission: Permission) => boolean,
801
1616
  json: JsonResult,
802
1617
  ): void {
803
- if (can("environments:use")) {
804
- server.registerTool("environment_list", {
805
- description: "List workspace environments with variable names and metadata (versions, timestamps). Values are write-only and never returned.",
806
- inputSchema: {},
807
- }, async () => json({ environments: await listWorkspaceEnvironments(deps.db, grant.workspaceId) }));
808
- }
809
-
810
- if (can("environments:manage")) {
811
- server.registerTool("environment_set_variable", {
812
- description: "Set or rotate one variable in a workspace environment. Target by environmentId, or by environmentName (created if it does not exist). The value is encrypted at rest and injected into sandboxes of sessions the environment is attached to; it is never readable back through any API.",
813
- inputSchema: {
814
- environmentId: z4.string().uuid().optional(),
815
- environmentName: z4.string().min(1).optional(),
816
- name: z4.string().min(1),
817
- value: z4.string().min(1).max(32768),
818
- },
819
- }, async ({ environmentId, environmentName, name, value }) => {
820
- const key = requireEnvironmentEncryption(deps.settings);
821
- const parsedName = WorkspaceEnvironmentVariableName.safeParse(name);
822
- if (!parsedName.success) {
823
- throw new Error("environment variable names must match ^[A-Z][A-Z0-9_]*$");
824
- }
825
- assertAllowedEnvironmentVariableName(parsedName.data);
826
- if ((environmentId === undefined) === (environmentName === undefined)) {
827
- throw new Error("provide exactly one of environmentId or environmentName");
828
- }
829
- const trimmedEnvironmentName = environmentName?.trim();
830
- if (environmentName !== undefined && !trimmedEnvironmentName) {
831
- throw new Error("environment name is required");
832
- }
833
- let created = false;
834
- let environment = environmentId !== undefined
835
- ? await getWorkspaceEnvironment(deps.db, grant.workspaceId, environmentId)
836
- : await getWorkspaceEnvironmentByName(deps.db, grant.workspaceId, trimmedEnvironmentName!);
837
- if (!environment && environmentId !== undefined) {
838
- throw new Error("environment not found");
839
- }
840
- if (!environment) {
841
- if (await countWorkspaceEnvironments(deps.db, grant.workspaceId) >= MAX_ENVIRONMENTS_PER_WORKSPACE) {
842
- throw new Error(`a workspace supports at most ${MAX_ENVIRONMENTS_PER_WORKSPACE} environments`);
843
- }
844
- environment = await createWorkspaceEnvironment(deps.db, {
845
- accountId: grant.accountId,
846
- workspaceId: grant.workspaceId,
847
- name: trimmedEnvironmentName!,
848
- });
849
- created = true;
850
- await recordEnvironmentAuditEvent(deps.db, { grant, action: "environment.created", environmentId: environment.id });
851
- }
852
- const exists = environment.variables.some((variable) => variable.name === parsedName.data);
853
- if (!exists && environment.variables.length >= MAX_VARIABLES_PER_ENVIRONMENT) {
854
- throw new Error(`an environment supports at most ${MAX_VARIABLES_PER_ENVIRONMENT} variables`);
1618
+ const registerListTool = (name: string, description: string): void => {
1619
+ server.registerTool(
1620
+ name,
1621
+ {
1622
+ description,
1623
+ inputSchema: {},
1624
+ },
1625
+ async () => {
1626
+ const variableSets = await listVariableSets(deps.db, grant.workspaceId);
1627
+ return json({ variableSets, environments: variableSets });
1628
+ },
1629
+ );
1630
+ };
1631
+ const setVariableHandler = async ({
1632
+ variableSetId,
1633
+ variableSetName,
1634
+ environmentId,
1635
+ environmentName,
1636
+ name,
1637
+ value,
1638
+ }: {
1639
+ variableSetId?: string | undefined;
1640
+ variableSetName?: string | undefined;
1641
+ environmentId?: string | undefined;
1642
+ environmentName?: string | undefined;
1643
+ name: string;
1644
+ value: string;
1645
+ }) => {
1646
+ const key = requireVariableSetEncryption(deps.settings);
1647
+ const parsedName = VariableSetVariableName.safeParse(name);
1648
+ if (!parsedName.success) {
1649
+ throw new Error("variable set/environment variable names must match ^[A-Z][A-Z0-9_]*$");
1650
+ }
1651
+ assertAllowedVariableSetVariableName(parsedName.data);
1652
+ const targetId = variableSetId ?? environmentId;
1653
+ const targetName = variableSetName ?? environmentName;
1654
+ if ((targetId === undefined) === (targetName === undefined)) {
1655
+ throw new Error(
1656
+ "provide exactly one of variableSetId or variableSetName; deprecated aliases must provide exactly one of environmentId or environmentName",
1657
+ );
1658
+ }
1659
+ const trimmedVariableSetName = targetName?.trim();
1660
+ if (targetName !== undefined && !trimmedVariableSetName) {
1661
+ throw new Error("variable set name is required");
1662
+ }
1663
+ let created = false;
1664
+ let variableSet =
1665
+ targetId !== undefined
1666
+ ? await getVariableSet(deps.db, grant.workspaceId, targetId)
1667
+ : await getVariableSetByName(deps.db, grant.workspaceId, trimmedVariableSetName!);
1668
+ if (!variableSet && targetId !== undefined) {
1669
+ throw new Error("variable set/environment not found");
1670
+ }
1671
+ if (!variableSet) {
1672
+ if ((await countVariableSets(deps.db, grant.workspaceId)) >= MAX_ENVIRONMENTS_PER_WORKSPACE) {
1673
+ throw new Error(
1674
+ `a workspace supports at most ${MAX_ENVIRONMENTS_PER_WORKSPACE} variable sets`,
1675
+ );
855
1676
  }
856
- const metadata = await setWorkspaceEnvironmentVariable(deps.db, {
1677
+ variableSet = await createVariableSet(deps.db, {
857
1678
  accountId: grant.accountId,
858
1679
  workspaceId: grant.workspaceId,
859
- environmentId: environment.id,
860
- name: parsedName.data,
861
- valueEncrypted: encryptEnvironmentValue(key, value),
1680
+ name: trimmedVariableSetName!,
862
1681
  });
863
- await recordEnvironmentAuditEvent(deps.db, { grant, action: "environment.variable.set", environmentId: environment.id, variableName: parsedName.data });
864
- return json({
865
- environment: { id: environment.id, name: environment.name, created },
866
- variable: metadata,
1682
+ created = true;
1683
+ await recordVariableSetAuditEvent(deps.db, {
1684
+ grant,
1685
+ action: "variable_set.created",
1686
+ variableSetId: variableSet.id,
867
1687
  });
1688
+ }
1689
+ const exists = variableSet.variables.some((variable) => variable.name === parsedName.data);
1690
+ if (!exists && variableSet.variables.length >= MAX_VARIABLES_PER_ENVIRONMENT) {
1691
+ throw new Error(`a variable set supports at most ${MAX_VARIABLES_PER_ENVIRONMENT} variables`);
1692
+ }
1693
+ const metadata = await setVariableSetVariable(deps.db, {
1694
+ accountId: grant.accountId,
1695
+ workspaceId: grant.workspaceId,
1696
+ variableSetId: variableSet.id,
1697
+ name: parsedName.data,
1698
+ valueEncrypted: encryptVariableSetValue(key, value),
868
1699
  });
1700
+ await recordVariableSetAuditEvent(deps.db, {
1701
+ grant,
1702
+ action: "variable_set.variable.set",
1703
+ variableSetId: variableSet.id,
1704
+ variableName: parsedName.data,
1705
+ });
1706
+ const responseVariableSet = { id: variableSet.id, name: variableSet.name, created };
1707
+ return json({
1708
+ variableSet: responseVariableSet,
1709
+ environment: responseVariableSet,
1710
+ variable: metadata,
1711
+ });
1712
+ };
1713
+ const registerSetTool = (name: string, description: string): void => {
1714
+ server.registerTool(
1715
+ name,
1716
+ {
1717
+ description,
1718
+ inputSchema: {
1719
+ variableSetId: z4.string().uuid().optional(),
1720
+ variableSetName: z4.string().min(1).optional(),
1721
+ environmentId: z4.string().uuid().optional(),
1722
+ environmentName: z4.string().min(1).optional(),
1723
+ name: z4.string().min(1),
1724
+ value: z4.string().min(1).max(32768),
1725
+ },
1726
+ },
1727
+ setVariableHandler,
1728
+ );
1729
+ };
1730
+ if (can("variable-sets:use")) {
1731
+ registerListTool(
1732
+ "variable_set_list",
1733
+ "List variable sets with variable names and metadata (versions, timestamps). Values are write-only and never returned.",
1734
+ );
1735
+ registerListTool(
1736
+ "environment_list",
1737
+ "(deprecated alias of variable_set_list) List variable sets with variable names and metadata (versions, timestamps). Values are write-only and never returned.",
1738
+ );
1739
+ }
1740
+
1741
+ if (can("variable-sets:manage")) {
1742
+ registerSetTool(
1743
+ "variable_set_set_variable",
1744
+ "Set or rotate one variable in a variable set. Target by variableSetId, or by variableSetName (created if it does not exist). The value is encrypted at rest and injected into sandboxes of sessions the variable set is attached to; it is never readable back through any API.",
1745
+ );
1746
+ registerSetTool(
1747
+ "environment_set_variable",
1748
+ "(deprecated alias of variable_set_set_variable) Set or rotate one variable in a variable set. Target by variableSetId, or by variableSetName (created if it does not exist). The value is encrypted at rest and injected into sandboxes of sessions the variable set is attached to; it is never readable back through any API.",
1749
+ );
869
1750
  }
870
1751
  }
871
1752
 
@@ -880,32 +1761,44 @@ function registerGitHubConnectTool(
880
1761
  options: McpServerOptions,
881
1762
  json: JsonResult,
882
1763
  ): void {
883
- server.registerTool("github_connect_link", {
884
- description: "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.",
885
- inputSchema: {},
886
- }, async () => {
887
- const { settings } = deps;
888
- const missing = githubAppMissingSettings(settings);
889
- const slug = settings.githubAppSlug?.trim() || null;
890
- if (missing.length > 0 || !slug) {
891
- return json({ configured: false, appSlug: slug, installUrl: null, missing });
892
- }
893
- const base = (settings.publicBaseUrl ?? settings.githubAppManifestBaseUrl ?? options.requestOrigin ?? "").replace(/\/+$/, "");
894
- if (!base) {
895
- throw new Error("github_connect_link requires OPENGENI_PUBLIC_BASE_URL (or OPENGENI_GITHUB_APP_MANIFEST_BASE_URL) so the install link can route through this deployment");
896
- }
897
- const state = createSignedState(deps.githubStateSecret, {
898
- accountId: grant.accountId,
899
- workspaceId: grant.workspaceId,
900
- });
901
- return json({
902
- configured: true,
903
- appSlug: slug,
904
- installUrl: `${base}/v1/workspaces/${grant.workspaceId}/github/connect?state=${encodeURIComponent(state)}`,
905
- expiresInSeconds: stateMaxAgeSeconds,
906
- missing: [],
907
- });
908
- });
1764
+ server.registerTool(
1765
+ "github_connect_link",
1766
+ {
1767
+ 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.",
1769
+ inputSchema: {},
1770
+ },
1771
+ async () => {
1772
+ const { settings } = deps;
1773
+ const missing = githubAppMissingSettings(settings);
1774
+ const slug = settings.githubAppSlug?.trim() || null;
1775
+ 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
+ );
1788
+ }
1789
+ const state = createSignedState(deps.githubStateSecret, {
1790
+ accountId: grant.accountId,
1791
+ workspaceId: grant.workspaceId,
1792
+ });
1793
+ return json({
1794
+ configured: true,
1795
+ appSlug: slug,
1796
+ installUrl: `${base}/v1/workspaces/${grant.workspaceId}/github/connect?state=${encodeURIComponent(state)}`,
1797
+ expiresInSeconds: stateMaxAgeSeconds,
1798
+ missing: [],
1799
+ });
1800
+ },
1801
+ );
909
1802
  }
910
1803
 
911
1804
  // TOKEN-BROKER (B1): mint a FRESH short-lived GitHub App installation token for the
@@ -920,55 +1813,67 @@ function registerGitHubTokenTool(
920
1813
  sessionId: string,
921
1814
  json: JsonResult,
922
1815
  ): void {
923
- server.registerTool("github_token", {
924
- description: "Mint a fresh short-lived GitHub token for this session's repositories. Write it to $OPENGENI_GIT_TOKEN_FILE (default $HOME/.opengeni/git-token) to refresh git auth before the current token expires.",
925
- inputSchema: {},
926
- }, async () => {
927
- const session = await requireSession(deps.db, grant.workspaceId, sessionId);
928
- // Resolve the run-scoped installation + repository ids from THIS session's
929
- // repository resources (same shape sandboxEnvironmentForRun mints against). Only
930
- // private GitHub-App repos carry the installation/repository ids.
931
- const selected = (session.resources ?? []).flatMap((resource) => {
932
- if (resource.kind !== "repository") {
933
- return [];
1816
+ server.registerTool(
1817
+ "github_token",
1818
+ {
1819
+ description:
1820
+ "Mint a fresh short-lived GitHub token for this session's repositories. Write it to $OPENGENI_GIT_TOKEN_FILE (default $HOME/.opengeni/git-token) to refresh git auth before the current token expires.",
1821
+ inputSchema: {},
1822
+ },
1823
+ async () => {
1824
+ const session = await requireSession(deps.db, grant.workspaceId, sessionId);
1825
+ // Resolve the run-scoped installation + repository ids from THIS session's
1826
+ // repository resources (same shape sandboxEnvironmentForRun mints against). Only
1827
+ // private GitHub-App repos carry the installation/repository ids.
1828
+ const selected = (session.resources ?? []).flatMap((resource) => {
1829
+ if (resource.kind !== "repository") {
1830
+ return [];
1831
+ }
1832
+ const installationId = resource.githubInstallationId;
1833
+ const repositoryId = resource.githubRepositoryId;
1834
+ return typeof installationId === "number" &&
1835
+ installationId > 0 &&
1836
+ typeof repositoryId === "number" &&
1837
+ repositoryId > 0
1838
+ ? [{ installationId, repositoryId }]
1839
+ : [];
1840
+ });
1841
+ if (selected.length === 0) {
1842
+ throw new Error("this session has no GitHub App repository resources to mint a token for");
934
1843
  }
935
- const installationId = resource.githubInstallationId;
936
- const repositoryId = resource.githubRepositoryId;
937
- return typeof installationId === "number" && installationId > 0
938
- && typeof repositoryId === "number" && repositoryId > 0
939
- ? [{ installationId, repositoryId }]
940
- : [];
941
- });
942
- if (selected.length === 0) {
943
- throw new Error("this session has no GitHub App repository resources to mint a token for");
944
- }
945
- const installationId = selected[0]!.installationId;
946
- if (selected.some((item) => item.installationId !== installationId)) {
947
- throw new Error("GitHub App repository resources must belong to one installation");
948
- }
949
- const token = await createGitHubAppInstallationToken(deps.settings, {
950
- installationId,
951
- repositoryIds: selected.map((item) => item.repositoryId),
952
- });
953
- return json({
954
- token,
955
- tokenFile: "$OPENGENI_GIT_TOKEN_FILE (default $HOME/.opengeni/git-token)",
956
- });
957
- });
1844
+ const installationId = selected[0]!.installationId;
1845
+ if (selected.some((item) => item.installationId !== installationId)) {
1846
+ throw new Error("GitHub App repository resources must belong to one installation");
1847
+ }
1848
+ const token = await createGitHubAppInstallationToken(deps.settings, {
1849
+ installationId,
1850
+ repositoryIds: selected.map((item) => item.repositoryId),
1851
+ });
1852
+ return json({
1853
+ token,
1854
+ tokenFile: "$OPENGENI_GIT_TOKEN_FILE (default $HOME/.opengeni/git-token)",
1855
+ });
1856
+ },
1857
+ );
958
1858
  }
959
1859
 
960
1860
  // Defense-in-depth for invariant "agents cannot self-attach": the worker's
961
- // first-party delegated token never carries environments:use, so sandboxed
962
- // agents calling these MCP tools cannot attach a workspace environment.
963
- // Explicit detach (environmentId: null) is also an attachment change and is
1861
+ // first-party delegated token never carries variable-sets:use, so sandboxed
1862
+ // agents calling these MCP tools cannot attach a variable set.
1863
+ // Explicit detach (variableSetId: null) is also an attachment change and is
964
1864
  // blocked the same way.
965
- function requireEnvironmentsUseForMcpAttachment(grant: AccessGrant, environmentId: string | null | undefined): void {
966
- if (environmentId !== undefined && !hasPermission(grant.permissions, "environments:use")) {
967
- throw new Error("missing permission: environments:use");
1865
+ function requireVariableSetsUseForMcpAttachment(
1866
+ grant: AccessGrant,
1867
+ variableSetId: string | null | undefined,
1868
+ ): void {
1869
+ if (variableSetId !== undefined && !hasPermission(grant.permissions, "variable-sets:use")) {
1870
+ throw new Error("missing permission: variable-sets:use");
968
1871
  }
969
1872
  }
970
1873
 
971
- function repositoryWithScheduledTaskResource(repository: GitHubRepository): GitHubRepository & { resource: ResourceRef } {
1874
+ function repositoryWithScheduledTaskResource(
1875
+ repository: GitHubRepository,
1876
+ ): GitHubRepository & { resource: ResourceRef } {
972
1877
  const uri = normalizedRepositoryUri(repository.cloneUrl);
973
1878
  return {
974
1879
  ...repository,
@@ -977,7 +1882,9 @@ function repositoryWithScheduledTaskResource(repository: GitHubRepository): GitH
977
1882
  uri,
978
1883
  ref: repository.defaultBranch,
979
1884
  mountPath: repositoryMountPath(uri),
980
- ...(repository.private ? { githubInstallationId: repository.installationId, githubRepositoryId: repository.id } : {}),
1885
+ ...(repository.private
1886
+ ? { githubInstallationId: repository.installationId, githubRepositoryId: repository.id }
1887
+ : {}),
981
1888
  },
982
1889
  };
983
1890
  }