@opengeni/api-router 2.5.0 → 2.6.4-canary.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. package/dist/app.d.ts +2 -1
  2. package/dist/app.js +3 -1
  3. package/dist/auth/managed-auth-attempt-context.d.ts +5 -1
  4. package/dist/auth/managed-auth.d.ts +24 -1
  5. package/dist/{chunk-QESX7HDK.js → chunk-UOI7KAP3.js} +3240 -791
  6. package/dist/chunk-UOI7KAP3.js.map +1 -0
  7. package/dist/http/sse.d.ts +9 -0
  8. package/dist/index.js +117 -5
  9. package/dist/index.js.map +1 -1
  10. package/dist/interaction-metrics.d.ts +2 -0
  11. package/dist/mcp/company-brain-governed-writes.d.ts +1 -1
  12. package/dist/mcp/company-profile-agent-admin.d.ts +6 -6
  13. package/dist/mcp/remember.d.ts +2 -2
  14. package/dist/mcp/scheduled-task-view.d.ts +3 -3
  15. package/dist/mcp/server.d.ts +1 -0
  16. package/dist/mcp/session-view.d.ts +1 -0
  17. package/dist/mcp/session-wait.d.ts +19 -0
  18. package/dist/model-catalog.d.ts +5 -31
  19. package/dist/routes/api-keys.d.ts +2 -0
  20. package/dist/routes/browser-sessions.d.ts +1 -0
  21. package/dist/routes/computer-sessions.d.ts +12 -0
  22. package/dist/routes/managed-auth-session-sets.d.ts +7 -0
  23. package/dist/routes/workspaces.d.ts +1 -1
  24. package/dist/sandbox/metrics-ingestion.d.ts +16 -0
  25. package/dist/workspace-delete-observability.d.ts +10 -0
  26. package/package.json +19 -19
  27. package/src/app.ts +206 -29
  28. package/src/auth/managed-auth-attempt-context.ts +40 -3
  29. package/src/auth/managed-auth-session-adapter.ts +1 -0
  30. package/src/auth/managed-auth.ts +164 -4
  31. package/src/http/sse.ts +279 -45
  32. package/src/index.ts +13 -1
  33. package/src/integrations/oauth-client.ts +8 -1
  34. package/src/integrations/provider-oauth.ts +12 -2
  35. package/src/integrations/slack-interactions.ts +77 -37
  36. package/src/interaction-metrics.ts +30 -0
  37. package/src/mcp/company-brain-governed-writes.ts +38 -23
  38. package/src/mcp/company-profile-agent-admin.ts +7 -7
  39. package/src/mcp/remember.ts +19 -8
  40. package/src/mcp/server.ts +328 -27
  41. package/src/mcp/session-wait.ts +56 -8
  42. package/src/model-catalog.ts +45 -337
  43. package/src/routes/api-integrations.ts +2 -2
  44. package/src/routes/api-keys.ts +149 -7
  45. package/src/routes/automations.ts +186 -19
  46. package/src/routes/browser-sessions.ts +10 -2
  47. package/src/routes/capabilities.ts +3 -3
  48. package/src/routes/codex.ts +483 -74
  49. package/src/routes/company-profile.ts +64 -0
  50. package/src/routes/computer-sessions.ts +103 -1
  51. package/src/routes/connections.ts +271 -16
  52. package/src/routes/github.ts +0 -8
  53. package/src/routes/integration-facets.ts +8 -5
  54. package/src/routes/interaction-resources.ts +7 -1
  55. package/src/routes/managed-auth-session-sets.ts +199 -2
  56. package/src/routes/organization-memberships.ts +28 -8
  57. package/src/routes/packs.ts +5 -5
  58. package/src/routes/plugins.ts +2 -2
  59. package/src/routes/pr-review.ts +80 -4
  60. package/src/routes/scheduled-tasks.ts +47 -11
  61. package/src/routes/sessions.ts +75 -53
  62. package/src/routes/skills.ts +3 -3
  63. package/src/routes/supergrok.ts +3 -2
  64. package/src/routes/workspaces.ts +665 -56
  65. package/src/sandbox/channel-a.ts +10 -4
  66. package/src/sandbox/machines.ts +13 -6
  67. package/src/sandbox/metrics-ingestion.ts +157 -3
  68. package/src/sandbox/viewer.ts +20 -1
  69. package/src/workspace-delete-observability.ts +75 -0
  70. package/dist/chunk-QESX7HDK.js.map +0 -1
package/src/mcp/server.ts CHANGED
@@ -14,6 +14,7 @@ import {
14
14
  stableJson,
15
15
  compactSessionEventResult,
16
16
  sessionEventLatestClassToSemanticClass,
17
+ MemorySlackPublicationDistribution,
17
18
  SessionMcpCredentialUpdateInput,
18
19
  ToolAuthNeededPayload,
19
20
  VariableSetVariableName,
@@ -38,6 +39,7 @@ import {
38
39
  SESSION_GOAL_SUCCESS_CRITERIA_MAX_BYTES,
39
40
  SESSION_GOAL_TEXT_MAX_BYTES,
40
41
  SESSION_INSTRUCTIONS_MAX_CHARACTERS,
42
+ SESSION_TITLE_MAX_CHARACTERS,
41
43
  MAX_SELECTED_VARIABLE_SETS,
42
44
  sessionGoalUtf8Bytes,
43
45
  TASK_NOTE_LIST_DEFAULT_LIMIT,
@@ -90,6 +92,8 @@ import {
90
92
  readVariableSetSecretAtomically,
91
93
  recordSyncedSocialPosts,
92
94
  listVariableSets,
95
+ MEMORY_CORRECT_TOOL_DESCRIPTION,
96
+ MEMORY_SAVE_TOOL_DESCRIPTION,
93
97
  MEMORY_SEARCH_TOOL_DESCRIPTION,
94
98
  requireScheduledTask,
95
99
  requireSession,
@@ -119,7 +123,11 @@ import {
119
123
  acceptSessionHumanInputResponse,
120
124
  HumanInputResponseValidationError,
121
125
  } from "@opengeni/db";
122
- import { appendAndPublishTurnEventsFenced, publishDurableSessionEvents } from "@opengeni/events";
126
+ import {
127
+ appendAndPublishEvents,
128
+ appendAndPublishTurnEventsFenced,
129
+ publishDurableSessionEvents,
130
+ } from "@opengeni/events";
123
131
  import { allowedFirstPartyMcpToolsForSession, codemodeWorkspaceUrl } from "@opengeni/config";
124
132
  import {
125
133
  createSignedState,
@@ -141,12 +149,15 @@ import {
141
149
  authorizedSocialConnectionsForGrant,
142
150
  authorizedAtlassianConnectionsForGrant,
143
151
  buildCapabilityCatalog,
152
+ correctWorkspaceMemoryWithSlackPublication,
144
153
  nativeConnectionCapabilityRecommendations,
145
154
  requireLiveAgentAttemptAuthorization,
146
155
  requireSessionAuthorization,
147
156
  requireSessionAuthorizationListScope,
157
+ resolveWorkspaceCatalogSettings,
148
158
  SessionAuthorizationDeniedError,
149
159
  SessionAuthorizationUnavailableError,
160
+ saveWorkspaceMemoryWithSlackPublication,
150
161
  searchCapabilityCatalogItems,
151
162
  type ResolvedSessionAuthorization,
152
163
  } from "@opengeni/core";
@@ -191,6 +202,7 @@ import {
191
202
  ScheduledTaskSyncError,
192
203
  syncCreatedScheduledTask,
193
204
  syncUpdatedScheduledTask,
205
+ validateScheduledTaskMachineTarget,
194
206
  validateScheduledTaskTarget,
195
207
  updateScheduledTaskForApi,
196
208
  validatedScheduledTaskUpdate,
@@ -228,11 +240,13 @@ import {
228
240
  SESSION_EVENT_MCP_MAX_BYTES,
229
241
  } from "./session-view";
230
242
  import {
243
+ SESSION_WAIT_COMPLETION_EVENT_TYPES,
231
244
  SESSION_WAIT_DEFAULT_SECONDS,
232
245
  SESSION_WAIT_EVENT_TYPES,
233
246
  SESSION_WAIT_EVENTS_PER_TARGET,
234
247
  SESSION_WAIT_MAX_SECONDS,
235
248
  SESSION_WAIT_MAX_TARGETS,
249
+ sessionWaitCompletionEventMatches,
236
250
  waitForSessionChanges,
237
251
  } from "./session-wait";
238
252
  import {
@@ -275,6 +289,12 @@ export type McpServerOptions = {
275
289
 
276
290
  const ORCHESTRATION_FAILURE_CODE_MAX_LENGTH = 128;
277
291
  const ORCHESTRATION_FAILURE_MESSAGE_MAX_UTF8_BYTES = 1_024;
292
+ // Keep pathological raw MCP payloads away from Unicode normalization while
293
+ // leaving the shared DB normalizer authoritative for the exact post-NFKC
294
+ // code-point limit. The multiplier admits supplementary-plane characters,
295
+ // decomposed forms, and ordinary whitespace folding without reopening the
296
+ // API-wide request-body ceiling for this 200-code-point field.
297
+ const MCP_DISCOVERY_QUERY_MAX_UTF16_CODE_UNITS = WORK_DISCOVERY_QUERY_MAX_CHARS * 8;
278
298
 
279
299
  type OrchestrationToolName = "session_create" | "session_send_message";
280
300
 
@@ -409,9 +429,6 @@ const FIRST_PARTY_TOOL_AUTHORIZATION = {
409
429
  goal_complete: { sessionRequired: true, allOf: ["goals:manage"] },
410
430
  goal_pause: { sessionRequired: true, allOf: ["goals:manage"] },
411
431
  memory_search: { sessionRequired: true, allOf: ["documents:search"] },
412
- // Retired: never registered, so these are never consulted. The map must stay
413
- // total over the tool-name union, which still carries both names so that
414
- // previously written scheduled-task snapshots keep parsing.
415
432
  memory_save: { sessionRequired: true, allOf: ["documents:search"] },
416
433
  memory_correct: { sessionRequired: true, allOf: ["documents:search"] },
417
434
  preference_registry_summary: {
@@ -1702,7 +1719,7 @@ export function buildOpenGeniMcpServer(
1702
1719
  async ({ id, triggerId }) => {
1703
1720
  const task = await requireScheduledTask(deps.db, grant.workspaceId, id);
1704
1721
  if (task.action.kind === "agent_turn") {
1705
- await validateScheduledTaskTarget({
1722
+ const targetSession = await validateScheduledTaskTarget({
1706
1723
  db: deps.db,
1707
1724
  sessionAuthorization: deps.sessionAuthorization,
1708
1725
  authorizationSurface: "first_party_mcp",
@@ -1714,6 +1731,22 @@ export function buildOpenGeniMcpServer(
1714
1731
  agentConfig: task.agentConfig,
1715
1732
  missingTargetStatus: 404,
1716
1733
  });
1734
+ const catalogSourceSettings = deps.catalogSourceSettings ?? deps.settings;
1735
+ const catalogSettings = (
1736
+ await resolveWorkspaceCatalogSettings(deps.db, catalogSourceSettings, {
1737
+ accountId: grant.accountId,
1738
+ workspaceId: grant.workspaceId,
1739
+ ...(targetSession ? { retainedProductModelId: targetSession.model } : {}),
1740
+ })
1741
+ ).settings;
1742
+ await validateScheduledTaskMachineTarget({
1743
+ settings: catalogSettings,
1744
+ db: deps.db,
1745
+ grant,
1746
+ runMode: task.runMode,
1747
+ agentConfig: task.agentConfig,
1748
+ requireOnline: true,
1749
+ });
1717
1750
  await requireLimit(deps, {
1718
1751
  accountId: grant.accountId,
1719
1752
  workspaceId: grant.workspaceId,
@@ -3425,6 +3458,7 @@ function registerPreferenceRegistryTools(
3425
3458
  }
3426
3459
 
3427
3460
  const MemoryKindSchema = z4.enum(["preference", "semantic", "procedural", "decision", "episodic"]);
3461
+ const MemoryWriteKindSchema = z4.enum(["semantic", "decision", "episodic"]);
3428
3462
 
3429
3463
  function scheduledTaskReceipt(
3430
3464
  operation: string,
@@ -3516,6 +3550,11 @@ export function memorySlackPublicationActor(
3516
3550
  };
3517
3551
  }
3518
3552
 
3553
+ function memoryPreview(text: string): string {
3554
+ const normalized = text.replace(/\s+/g, " ").trim();
3555
+ return normalized.length <= 120 ? normalized : `${normalized.slice(0, 119)}…`;
3556
+ }
3557
+
3519
3558
  function registerMemoryTools(
3520
3559
  server: McpServer,
3521
3560
  deps: ApiRouteDeps,
@@ -3524,10 +3563,17 @@ function registerMemoryTools(
3524
3563
  json: JsonResult,
3525
3564
  promptMode: WorkspaceMemoryPromptMode,
3526
3565
  ): void {
3566
+ const publicationInputSchema = z4.object({
3567
+ importance: z4.enum(["major", "normal", "minor"]),
3568
+ audience: z4.literal("workspace"),
3569
+ slackMode: z4.enum(["auto", "review", "never"]),
3570
+ shareSummary: z4.string().trim().min(1).max(4_096),
3571
+ });
3572
+
3527
3573
  server.registerTool(
3528
3574
  "memory_search",
3529
3575
  {
3530
- description: `${MEMORY_SEARCH_TOOL_DESCRIPTION} Legacy preference-kind records are excluded from this tool because structured preferences are the only behavioral authority. To save something the user explicitly asked to keep, use \`remember\`; for your own findings use task notes and their promotion tools.`,
3576
+ description: `${MEMORY_SEARCH_TOOL_DESCRIPTION} All existing Memory kinds are searchable. Legacy preference and procedure records are historical context, not active instructions; Skills and workspace instructions remain the behavioral authorities. When workspace Memory is enabled, use memory_save autonomously for durable facts, decisions, incidents, fixes, and outcomes, and memory_correct when an existing record is wrong or outdated.`,
3531
3577
  inputSchema: {
3532
3578
  query: z4.string().min(1),
3533
3579
  kind: MemoryKindSchema.optional(),
@@ -3550,10 +3596,239 @@ function registerMemoryTools(
3550
3596
  }),
3551
3597
  );
3552
3598
 
3553
- // Memory V1 writes are retired. Explicit user-directed knowledge goes
3554
- // through `remember`; an agent's own findings go through task notes and
3555
- // governed promotion. `memory_search` stays: reading the existing record
3556
- // set is still how an agent recalls what a workspace already knows.
3599
+ // Memory writes are agent-only. Human creation and curation use the REST/UI
3600
+ // surface; a non-attempt MCP principal may search but cannot mutate Memory.
3601
+ if (exactAgentAttemptClaims(grant) === null) return;
3602
+
3603
+ server.registerTool(
3604
+ "memory_save",
3605
+ {
3606
+ description: MEMORY_SAVE_TOOL_DESCRIPTION,
3607
+ inputSchema: {
3608
+ text: z4.string().min(1),
3609
+ kind: MemoryWriteKindSchema,
3610
+ confidence: z4.number().min(0).max(1).optional(),
3611
+ replaces_id: z4.string().min(1).optional(),
3612
+ slack_publication: publicationInputSchema.optional(),
3613
+ },
3614
+ },
3615
+ async ({ text, kind, confidence, replaces_id, slack_publication }) => {
3616
+ const actor = await requireLiveAgentAttemptAuthorization(deps.db, grant, sessionId);
3617
+ const result = await saveWorkspaceMemoryWithSlackPublication(
3618
+ deps.db,
3619
+ {
3620
+ accountId: grant.accountId,
3621
+ workspaceId: grant.workspaceId,
3622
+ sessionId,
3623
+ text,
3624
+ kind,
3625
+ ...(confidence !== undefined ? { confidence } : {}),
3626
+ ...(replaces_id ? { replacesId: replaces_id } : {}),
3627
+ origin: "agent",
3628
+ },
3629
+ slack_publication
3630
+ ? {
3631
+ distribution: MemorySlackPublicationDistribution.parse(slack_publication),
3632
+ actor: memorySlackPublicationActor(actor, sessionId, grant.subjectLabel ?? null)
3633
+ .actor,
3634
+ ownerLabel: actor.initiator.label ?? grant.subjectLabel ?? null,
3635
+ }
3636
+ : null,
3637
+ deps.getDocumentServices().embedder,
3638
+ );
3639
+ let timelineWarning: string | null = null;
3640
+ try {
3641
+ await appendAndPublishEvents(deps.db, deps.bus, grant.workspaceId, sessionId, [
3642
+ {
3643
+ type: "memory.saved",
3644
+ payload: {
3645
+ memoryId: result.memory.id,
3646
+ kind: result.memory.kind,
3647
+ preview: memoryPreview(result.memory.text),
3648
+ deduped: result.deduped,
3649
+ ...(result.superseded ? { supersededMemoryId: result.superseded.id } : {}),
3650
+ },
3651
+ },
3652
+ ]);
3653
+ } catch {
3654
+ timelineWarning = "Memory committed, but its session timeline event could not be recorded.";
3655
+ console.warn("workspace memory save: committed without session timeline event", {
3656
+ errorClass: "MemoryTimelineOperationError",
3657
+ errorCode: "memory_save_timeline_append_failed",
3658
+ origin: "api",
3659
+ workspaceId: grant.workspaceId,
3660
+ sessionId,
3661
+ memoryId: result.memory.id,
3662
+ });
3663
+ }
3664
+ const changed = !result.deduped || result.updated || result.superseded !== null;
3665
+ const outcome =
3666
+ result.updated || result.superseded !== null
3667
+ ? "updated"
3668
+ : result.deduped
3669
+ ? "unchanged"
3670
+ : "created";
3671
+ return json(
3672
+ mcpMutationReceipt({
3673
+ operation: "memory_save",
3674
+ committed: true,
3675
+ outcome,
3676
+ changed,
3677
+ resource: {
3678
+ type: "knowledge_memory",
3679
+ id: result.memory.id,
3680
+ state: result.memory.status,
3681
+ },
3682
+ relatedResources: result.superseded
3683
+ ? [
3684
+ {
3685
+ type: "knowledge_memory",
3686
+ id: result.superseded.id,
3687
+ state: result.superseded.status,
3688
+ },
3689
+ ]
3690
+ : undefined,
3691
+ timestamp: result.memory.updatedAt,
3692
+ idempotency: { status: "not_supported" },
3693
+ warnings: [
3694
+ ...(!result.embedded
3695
+ ? ["Memory committed without a vector embedding; keyword search remains available."]
3696
+ : []),
3697
+ ...(timelineWarning ? [timelineWarning] : []),
3698
+ ],
3699
+ facts: {
3700
+ deduped: result.deduped,
3701
+ dedupeReason: result.dedupeReason,
3702
+ updatedInPlace: result.updated,
3703
+ embedded: result.embedded,
3704
+ slackPublicationDecision: result.slackPublication.decision?.eligible
3705
+ ? "eligible"
3706
+ : (result.slackPublication.decision?.reason ?? "not_requested"),
3707
+ slackPublicationId:
3708
+ result.slackPublication.enqueue?.kind === "enqueued" ||
3709
+ result.slackPublication.enqueue?.kind === "replayed"
3710
+ ? result.slackPublication.enqueue.publication.id
3711
+ : null,
3712
+ slackPublicationState:
3713
+ result.slackPublication.enqueue?.kind === "enqueued" ||
3714
+ result.slackPublication.enqueue?.kind === "replayed"
3715
+ ? result.slackPublication.enqueue.publication.state
3716
+ : null,
3717
+ },
3718
+ }),
3719
+ );
3720
+ },
3721
+ );
3722
+
3723
+ server.registerTool(
3724
+ "memory_correct",
3725
+ {
3726
+ description: MEMORY_CORRECT_TOOL_DESCRIPTION,
3727
+ inputSchema: {
3728
+ id: z4.string().min(1),
3729
+ reason: z4.string().min(1).optional(),
3730
+ replacement_text: z4.string().min(1).optional(),
3731
+ slack_publication: publicationInputSchema.optional(),
3732
+ },
3733
+ },
3734
+ async ({ id, reason, replacement_text, slack_publication }) => {
3735
+ const actor = await requireLiveAgentAttemptAuthorization(deps.db, grant, sessionId);
3736
+ const result = await correctWorkspaceMemoryWithSlackPublication(
3737
+ deps.db,
3738
+ {
3739
+ accountId: grant.accountId,
3740
+ workspaceId: grant.workspaceId,
3741
+ sessionId,
3742
+ id,
3743
+ ...(reason ? { reason } : {}),
3744
+ ...(replacement_text ? { replacementText: replacement_text } : {}),
3745
+ origin: "agent",
3746
+ },
3747
+ slack_publication
3748
+ ? {
3749
+ distribution: MemorySlackPublicationDistribution.parse(slack_publication),
3750
+ actor: memorySlackPublicationActor(actor, sessionId, grant.subjectLabel ?? null)
3751
+ .actor,
3752
+ ownerLabel: actor.initiator.label ?? grant.subjectLabel ?? null,
3753
+ }
3754
+ : null,
3755
+ deps.getDocumentServices().embedder,
3756
+ );
3757
+ let timelineWarning: string | null = null;
3758
+ try {
3759
+ await appendAndPublishEvents(deps.db, deps.bus, grant.workspaceId, sessionId, [
3760
+ {
3761
+ type: "memory.corrected",
3762
+ payload: {
3763
+ memoryId: result.memory.id,
3764
+ kind: result.memory.kind,
3765
+ preview: memoryPreview(result.memory.text),
3766
+ action: result.action,
3767
+ ...(reason ? { reason: memoryPreview(reason) } : {}),
3768
+ ...(result.replacement
3769
+ ? {
3770
+ replacementMemoryId: result.replacement.id,
3771
+ replacementPreview: memoryPreview(result.replacement.text),
3772
+ }
3773
+ : {}),
3774
+ },
3775
+ },
3776
+ ]);
3777
+ } catch {
3778
+ timelineWarning =
3779
+ "Memory correction committed, but its session timeline event could not be recorded.";
3780
+ console.warn("workspace memory correction: committed without session timeline event", {
3781
+ errorClass: "MemoryTimelineOperationError",
3782
+ errorCode: "memory_correct_timeline_append_failed",
3783
+ origin: "api",
3784
+ workspaceId: grant.workspaceId,
3785
+ sessionId,
3786
+ memoryId: result.memory.id,
3787
+ });
3788
+ }
3789
+ return json(
3790
+ mcpMutationReceipt({
3791
+ operation: "memory_correct",
3792
+ committed: true,
3793
+ outcome: "updated",
3794
+ changed: true,
3795
+ resource: {
3796
+ type: "knowledge_memory",
3797
+ id: result.memory.id,
3798
+ state: result.memory.status,
3799
+ },
3800
+ relatedResources: result.replacement
3801
+ ? [
3802
+ {
3803
+ type: "knowledge_memory",
3804
+ id: result.replacement.id,
3805
+ state: result.replacement.status,
3806
+ },
3807
+ ]
3808
+ : undefined,
3809
+ timestamp: (result.replacement ?? result.memory).updatedAt,
3810
+ idempotency: { status: "not_supported" },
3811
+ warnings: timelineWarning ? [timelineWarning] : [],
3812
+ facts: {
3813
+ correctionAction: result.action,
3814
+ slackPublicationDecision: result.slackPublication.decision?.eligible
3815
+ ? "eligible"
3816
+ : (result.slackPublication.decision?.reason ?? "not_requested"),
3817
+ slackPublicationId:
3818
+ result.slackPublication.enqueue?.kind === "enqueued" ||
3819
+ result.slackPublication.enqueue?.kind === "replayed"
3820
+ ? result.slackPublication.enqueue.publication.id
3821
+ : null,
3822
+ slackPublicationState:
3823
+ result.slackPublication.enqueue?.kind === "enqueued" ||
3824
+ result.slackPublication.enqueue?.kind === "replayed"
3825
+ ? result.slackPublication.enqueue.publication.state
3826
+ : null,
3827
+ },
3828
+ }),
3829
+ );
3830
+ },
3831
+ );
3557
3832
  }
3558
3833
 
3559
3834
  // Fleet tools (M7 bring-your-own-compute). Session-scoped (they steer THIS
@@ -4155,7 +4430,7 @@ function registerWorkspaceOrchestrationTools(
4155
4430
  includeLastMessage: z4.boolean().optional(),
4156
4431
  orderBy: z4.enum(["createdAt", "updatedAt", "relevance"]).optional(),
4157
4432
  updatedAfter: z4.string().max(64).optional(),
4158
- query: z4.string().max(WORK_DISCOVERY_QUERY_MAX_CHARS).optional(),
4433
+ query: z4.string().max(MCP_DISCOVERY_QUERY_MAX_UTF16_CODE_UNITS).optional(),
4159
4434
  statuses: z4
4160
4435
  .array(
4161
4436
  z4.enum([
@@ -4443,7 +4718,7 @@ function registerWorkspaceOrchestrationTools(
4443
4718
  server.registerTool(
4444
4719
  "session_wait",
4445
4720
  {
4446
- description: `Block until a watched session has new durable events after your cursor, until your own session has pending machine input (a child result, an agent message, a steer), or until maxWaitSeconds (default ${SESSION_WAIT_DEFAULT_SECONDS}, max ${SESSION_WAIT_MAX_SECONDS}) elapses. Use this for short waits inside the current turn instead of sleeping and polling session_events/session_get/sessions_list while a child or peer session works; for long waits end this turn with goal_wait rather than looping session_wait for hours while holding the turn and sandbox. Pass each target's sessionId and afterSequence (its last seen sequence, 0 for a new session); returns immediately when anything already changed. Only turn lifecycle, agent.message.completed, blocking failures, goal facts, and session status/control changes count as a change; raw deltas, tool receipts, and sandbox diagnostics never wake it. Each changed target returns a bounded compact summary of up to ${SESSION_WAIT_EVENTS_PER_TARGET} exact durable events plus latestSequence (pass it back as the next afterSequence) and hasMore (drill down with session_events after=latestSequence). ownPendingUpdates > 0 means your own session has machine input that is delivered only when your next turn is claimed: finish this turn to receive it, or pass includeOwnPendingUpdates=false to keep waiting on the targets. timedOut=true means nothing changed; liveFanout=false means the live bus was unavailable and the wait relied on the deadline re-check. The whole result is byte-bounded: summaries are shortened first, then newest rows dropped, so a changed target may come back with events=[] and hasMore=true; read those rows with session_events after=latestSequence. The wait cannot exceed ${SESSION_WAIT_MAX_SECONDS} seconds because the MCP client request timeout is 60 seconds.`,
4721
+ description: `Block until a watched session has new durable events after your cursor, until your own session has pending machine input (a child result, an agent message, a steer), or until maxWaitSeconds (default ${SESSION_WAIT_DEFAULT_SECONDS}, max ${SESSION_WAIT_MAX_SECONDS}) elapses. Use this for short waits inside the current turn instead of sleeping and polling session_events/session_get/sessions_list while a child or peer session works; for long waits end this turn with goal_wait rather than looping session_wait for hours while holding the turn and sandbox. Pass each target's sessionId and afterSequence (its last seen sequence, 0 for a new session). waitFor=change is the backward-compatible default and returns on turn lifecycle, agent.message.completed, blocking failures, goal facts, or session status/control changes. waitFor=completion is the child-result join: it ignores progress, completed commentary messages, goal facts, maintenance turns, and continuation segment settlements and returns only for a result-bearing final turn or a blocking state. A goal.completed event records goal state but is not a terminal child result. Raw deltas, tool receipts, sandbox diagnostics, and unrelated progress never wake either mode. Each changed target returns a bounded compact summary of up to ${SESSION_WAIT_EVENTS_PER_TARGET} exact durable events plus latestSequence (pass it back as the next afterSequence) and hasMore (drill down with session_events after=latestSequence). ownPendingUpdates > 0 means your own session has machine input that is delivered only when your next turn is claimed: finish this turn to receive it, or pass includeOwnPendingUpdates=false to keep waiting on the targets. timedOut=true means nothing changed; liveFanout=false means the live bus was unavailable and the wait relied on the deadline re-check. The whole result is byte-bounded: summaries are shortened first, then newest rows dropped, so a changed target may come back with events=[] and hasMore=true; read those rows with session_events after=latestSequence. The wait cannot exceed ${SESSION_WAIT_MAX_SECONDS} seconds because the MCP client request timeout is 60 seconds.`,
4447
4722
  inputSchema: {
4448
4723
  targets: z4
4449
4724
  .array(
@@ -4460,10 +4735,16 @@ function registerWorkspaceOrchestrationTools(
4460
4735
  .describe(
4461
4736
  "Also return when your own session has pending machine input (default true).",
4462
4737
  ),
4738
+ waitFor: z4
4739
+ .enum(["change", "completion"])
4740
+ .optional()
4741
+ .describe(
4742
+ "change (default) returns on relevant activity; completion ignores messages, goal/progress, maintenance, and continuation segments until a result-bearing final turn or blocker.",
4743
+ ),
4463
4744
  maxWaitSeconds: z4.number().int().min(1).max(SESSION_WAIT_MAX_SECONDS).optional(),
4464
4745
  },
4465
4746
  },
4466
- async ({ targets, includeOwnPendingUpdates, maxWaitSeconds }, extra) => {
4747
+ async ({ targets, includeOwnPendingUpdates, waitFor, maxWaitSeconds }, extra) => {
4467
4748
  const distinct = new Set(targets.map((target) => target.sessionId));
4468
4749
  if (distinct.size !== targets.length) {
4469
4750
  throw new Error("session_wait targets must name distinct sessions");
@@ -4475,6 +4756,8 @@ function registerWorkspaceOrchestrationTools(
4475
4756
  await requireSession(deps.db, grant.workspaceId, target.sessionId);
4476
4757
  }
4477
4758
  const ownSessionId = includeOwnPendingUpdates === false ? null : callerSessionId;
4759
+ const targetEventTypes =
4760
+ waitFor === "completion" ? SESSION_WAIT_COMPLETION_EVENT_TYPES : SESSION_WAIT_EVENT_TYPES;
4478
4761
  // The API serves one transport per POST, so the worker's MCP cancel
4479
4762
  // notification never reaches this handler; the route binds the HTTP
4480
4763
  // request's abort to transport.close() (mcp/request-abort.ts), which
@@ -4491,6 +4774,9 @@ function registerWorkspaceOrchestrationTools(
4491
4774
  targets,
4492
4775
  ownSessionId,
4493
4776
  maxWaitMs: (maxWaitSeconds ?? SESSION_WAIT_DEFAULT_SECONDS) * 1_000,
4777
+ targetEventTypes,
4778
+ targetEventMatches:
4779
+ waitFor === "completion" ? sessionWaitCompletionEventMatches : undefined,
4494
4780
  signal,
4495
4781
  source: {
4496
4782
  reauthorizeTargets: async (sessionIds) => {
@@ -4509,7 +4795,7 @@ function registerWorkspaceOrchestrationTools(
4509
4795
  direction: "after",
4510
4796
  limit: SESSION_WAIT_EVENTS_PER_TARGET,
4511
4797
  payloadMode: "full",
4512
- includeTypes: SESSION_WAIT_EVENT_TYPES,
4798
+ includeTypes: targetEventTypes,
4513
4799
  maxBytes: SESSION_EVENT_MCP_MAX_BYTES * 4,
4514
4800
  });
4515
4801
  return { events: page.events, hasMore: page.hasMore };
@@ -4538,6 +4824,14 @@ function registerWorkspaceOrchestrationTools(
4538
4824
  const sessionCreateInput = z4
4539
4825
  .object({
4540
4826
  initialMessage: z4.string().min(1),
4827
+ title: z4
4828
+ .string()
4829
+ .min(1)
4830
+ .max(SESSION_TITLE_MAX_CHARACTERS)
4831
+ .optional()
4832
+ .describe(
4833
+ "Concise semantic title for the child session. Omit only when the delegated goal or initial message already provides a suitable title; OpenGeni derives a sensitive-safe bounded fallback from that text.",
4834
+ ),
4541
4835
  instructions: z4.string().min(1).max(SESSION_INSTRUCTIONS_MAX_CHARACTERS).optional(),
4542
4836
  goal: z4.unknown().optional(),
4543
4837
  resources: z4.array(z4.unknown()).optional(),
@@ -4620,7 +4914,7 @@ function registerWorkspaceOrchestrationTools(
4620
4914
  "session_create",
4621
4915
  {
4622
4916
  description:
4623
- "Spawn a new agent session (a worker). The child inherits this session's visibility; a private session can only create a same-owner private child. Give a goal-bearing child its delegated objective. Its goal.rootConstraints may be an exact applicable subset of this accepted turn's frozen root constraints; omit that field to inherit all of them. Omit sandbox for the safe default: compatible children share the creator's box, while a different Variable Set, Rig, or machineTarget gets its own box. Use 'new' for deliberate isolation or {groupId} for a strict compatible sibling join. Put targetSandboxId and its optional workingDir together inside machineTarget; a machineTarget is always an own-box create even when the parent is backend none. To create a non-delegating leaf, pass a narrowed firstPartyMcpTools list that omits session_create; do not use a child-local depth override. Public REST/SDK callers retain advanced absolute depth and explicit shared-placement controls.",
4917
+ "Spawn a new agent session (a worker) only for a concrete, bounded subtask that can run independently and has a defined integration point in your current work. Do not delegate work you will also perform yourself; track the child and join its actual result before completing dependent work. Give the child a concise semantic title; if omitted, OpenGeni derives one from its delegated goal or initial message. The child inherits this session's visibility; a private session can only create a same-owner private child. Give a goal-bearing child its delegated objective. Its goal.rootConstraints may be an exact applicable subset of this accepted turn's frozen root constraints; omit that field to inherit all of them. Omit sandbox for the safe default: compatible children share the creator's box, while a different Variable Set, Rig, or machineTarget gets its own box. Use 'new' for deliberate isolation or {groupId} for a strict compatible sibling join. Put targetSandboxId and its optional workingDir together inside machineTarget; a machineTarget is always an own-box create even when the parent is backend none. To create a non-delegating leaf, pass a narrowed firstPartyMcpTools list that omits session_create; do not use a child-local depth override. Public REST/SDK callers retain advanced absolute depth and explicit shared-placement controls.",
4624
4918
  inputSchema: sessionCreateInput,
4625
4919
  },
4626
4920
  async (args) => {
@@ -4633,18 +4927,25 @@ function registerWorkspaceOrchestrationTools(
4633
4927
  if (callerSessionId !== null) {
4634
4928
  await authorizeFirstPartySession(deps, grant, callerSessionId, "session.child.create");
4635
4929
  }
4636
- const { machineTarget, ...request } = args;
4637
- const result = await createSessionForRequestWithOutcome(deps, grant, grant.workspaceId, {
4638
- ...request,
4639
- ...(machineTarget
4640
- ? {
4641
- targetSandboxId: machineTarget.targetSandboxId,
4642
- ...(machineTarget.workingDir !== undefined
4643
- ? { workingDir: machineTarget.workingDir }
4644
- : {}),
4645
- }
4646
- : {}),
4647
- });
4930
+ const { machineTarget, title, ...request } = args;
4931
+ const result = await createSessionForRequestWithOutcome(
4932
+ deps,
4933
+ grant,
4934
+ grant.workspaceId,
4935
+ {
4936
+ ...request,
4937
+ ...(machineTarget
4938
+ ? {
4939
+ targetSandboxId: machineTarget.targetSandboxId,
4940
+ ...(machineTarget.workingDir !== undefined
4941
+ ? { workingDir: machineTarget.workingDir }
4942
+ : {}),
4943
+ }
4944
+ : {}),
4945
+ },
4946
+ undefined,
4947
+ title === undefined ? {} : { automaticTitleCandidate: title },
4948
+ );
4648
4949
  return json(sessionCreateMutationReceipt(result, Boolean(request.idempotencyKey)));
4649
4950
  } catch (error) {
4650
4951
  return orchestrationFailureResult("session_create", error);
@@ -81,7 +81,51 @@ export const SESSION_WAIT_EVENT_TYPES = [
81
81
  "goal.continuation",
82
82
  ] as const satisfies readonly SessionEventType[];
83
83
 
84
- const SESSION_WAIT_EVENT_TYPE_SET: ReadonlySet<string> = new Set(SESSION_WAIT_EVENT_TYPES);
84
+ /**
85
+ * Settlement and blocking events that make a child result usable.
86
+ * Goal facts are deliberately absent: an agent can complete its durable goal
87
+ * before it emits the final assistant message and settles the turn. Completed
88
+ * agent messages are also absent because commentary messages use the same
89
+ * event type; an ordinary result-bearing `turn.completed` carries the
90
+ * authoritative final output.
91
+ */
92
+ export const SESSION_WAIT_COMPLETION_EVENT_TYPES = [
93
+ "turn.completed",
94
+ "turn.failed",
95
+ "turn.cancelled",
96
+ "turn.superseded",
97
+ "turn.capacity_waiting",
98
+ "session.requiresAction",
99
+ "session.humanInput.requested",
100
+ "session.control.paused",
101
+ "tool.auth_needed",
102
+ "credential.auth_needed",
103
+ "rig.setup.failed",
104
+ "goal.paused",
105
+ ] as const satisfies readonly SessionEventType[];
106
+
107
+ const SESSION_WAIT_COMPLETION_EVENT_TYPE_SET: ReadonlySet<string> = new Set(
108
+ SESSION_WAIT_COMPLETION_EVENT_TYPES,
109
+ );
110
+
111
+ /**
112
+ * A completed turn is result-bearing only when it carries the ordinary final
113
+ * output. Segment-limit and maintenance turns settle one execution segment
114
+ * while the session still has work to do, so they must not release a parent.
115
+ */
116
+ export function sessionWaitCompletionEventMatches(event: SessionEvent): boolean {
117
+ if (!SESSION_WAIT_COMPLETION_EVENT_TYPE_SET.has(event.type)) return false;
118
+ if (event.type !== "turn.completed") return true;
119
+ if (event.payload === null || typeof event.payload !== "object" || Array.isArray(event.payload)) {
120
+ return false;
121
+ }
122
+ const payload = event.payload as Record<string, unknown>;
123
+ return (
124
+ Object.prototype.hasOwnProperty.call(payload, "output") &&
125
+ !Object.prototype.hasOwnProperty.call(payload, "segmentLimit") &&
126
+ !Object.prototype.hasOwnProperty.call(payload, "maintenance")
127
+ );
128
+ }
85
129
 
86
130
  /** The self-session event that announces a newly pending machine input. */
87
131
  export const SESSION_WAIT_OWN_PENDING_EVENT_TYPE =
@@ -188,6 +232,10 @@ export type SessionWaitInput = {
188
232
  targets: readonly SessionWaitTarget[];
189
233
  ownSessionId: string | null;
190
234
  maxWaitMs: number;
235
+ /** Target events that end the wait. Defaults to the ordinary activity set. */
236
+ targetEventTypes?: readonly SessionEventType[] | undefined;
237
+ /** Optional payload-aware refinement applied after the event-type filter. */
238
+ targetEventMatches?: ((event: SessionEvent) => boolean) | undefined;
191
239
  source: SessionWaitSource;
192
240
  signal?: AbortSignal | undefined;
193
241
  now?: (() => number) | undefined;
@@ -206,6 +254,11 @@ export async function waitForSessionChanges(input: SessionWaitInput): Promise<Se
206
254
  let liveFanout = true;
207
255
  let waited = false;
208
256
  const ownSessionId = input.source.readOwnPendingUpdateKinds ? input.ownSessionId : null;
257
+ const targetEventTypeSet: ReadonlySet<string> = new Set(
258
+ input.targetEventTypes ?? SESSION_WAIT_EVENT_TYPES,
259
+ );
260
+ const targetEventMatches = (event: SessionEvent): boolean =>
261
+ targetEventTypeSet.has(event.type) && (input.targetEventMatches?.(event) ?? true);
209
262
 
210
263
  // One subscription per distinct session; a session may be both a target and
211
264
  // the caller's own session, in which case either condition wakes the wait.
@@ -230,11 +283,7 @@ export async function waitForSessionChanges(input: SessionWaitInput): Promise<Se
230
283
  const after = targetAfter.get(sessionId);
231
284
  for (const event of events) {
232
285
  if (event.sessionId !== sessionId) continue;
233
- if (
234
- after !== undefined &&
235
- event.sequence > after &&
236
- SESSION_WAIT_EVENT_TYPE_SET.has(event.type)
237
- ) {
286
+ if (after !== undefined && event.sequence > after && targetEventMatches(event)) {
238
287
  return true;
239
288
  }
240
289
  if (sessionId === ownSessionId && event.type === SESSION_WAIT_OWN_PENDING_EVENT_TYPE) {
@@ -274,8 +323,7 @@ export async function waitForSessionChanges(input: SessionWaitInput): Promise<Se
274
323
  const changed: SessionWaitTargetResult[] = [];
275
324
  for (const { target, read } of targetReads) {
276
325
  const events = read.events.filter(
277
- (event) =>
278
- event.sequence > target.afterSequence && SESSION_WAIT_EVENT_TYPE_SET.has(event.type),
326
+ (event) => event.sequence > target.afterSequence && targetEventMatches(event),
279
327
  );
280
328
  if (events.length === 0) continue;
281
329
  changed.push({